UI update complete?

This commit is contained in:
Jim Lancaster
2026-08-02 13:33:09 -05:00
parent ed6f9dfe25
commit 9653060c2a
26 changed files with 2523 additions and 72 deletions
+27 -24
View File
@@ -13,41 +13,41 @@ Status legend:
| Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes | | Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes |
|---|---|---|---|---| |---|---|---|---|---|
| Read detail and metadata | RD-1, RD-2, RD-7 | Planned | src/transcription/ui/pages/jobs_page.py; src/transcription/ui/components/table/jobs.py; src/transcription/ui/components/transcript.py | No dedicated Document detail page yet; Document metadata is not rendered as first-class UI. | | Read detail and metadata | RD-1, RD-2, RD-7 | Implemented | src/transcription/ui/pages/documents_page.py; src/transcription/services/documents.py; tests/ui/test_documents_page.py | Dedicated Document detail route renders metadata, read-only system timestamps, and invalid/missing-id states. |
| Related sections and empty states | RD-3, RD-4, RD-5, RD-6 | Planned | src/transcription/ui/pages/jobs_page.py | Document-scoped related sections are defined in docs, not yet implemented in a dedicated Document view. | | Related sections and empty states | RD-3, RD-4, RD-5, RD-6 | Implemented | src/transcription/ui/pages/documents_page.py; src/transcription/services/documents.py; tests/ui/test_documents_page.py | Document detail now links to document-scoped Jobs and Sources views, including empty-state guidance and filtered record rendering. |
| Update entry and validation | UP-1, UP-2, UP-3, UP-4, UP-5, UP-6 | Planned | src/transcription/services/documents.py | Service update path exists; no dedicated Document edit UI flow yet. | | Update entry and validation | UP-1, UP-2, UP-3, UP-4, UP-5, UP-6 | Implemented | src/transcription/ui/pages/documents_page.py; src/transcription/services/documents.py; tests/ui/test_documents_page.py; tests/services/test_document_service.py | Dedicated edit page includes required-field validation messaging, date parsing rules, and save path routed back to document detail. |
| Delete controls and guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Planned | src/transcription/services/documents.py | Service delete path exists; no UI delete control or dependency guard UX yet. | | Delete controls and guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Implemented | src/transcription/ui/pages/documents_page.py; src/transcription/services/documents.py; tests/ui/test_documents_page.py; tests/services/test_document_service.py | Dedicated delete page provides permanent-action confirmation, dependency-category blocking, and guarded backend delete behavior. |
## Person ## Person
| Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes | | Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes |
|---|---|---|---|---| |---|---|---|---|---|
| Create flow and validation | CR-1, CR-2, CR-3, CR-4, CR-5 | Planned | src/transcription/services/documents.py | Person create service exists; no dedicated Person page/form yet. | | Create flow and validation | CR-1, CR-2, CR-3, CR-4, CR-5 | Implemented | src/transcription/ui/pages/people_page.py; src/transcription/services/documents.py; tests/ui/test_people_page.py | Dedicated Person create page with required full_name validation, optional field handling, and success routing to detail. |
| Read detail and linked documents | RD-1, RD-2, RD-3, RD-4 | Planned | src/transcription/services/documents.py; src/transcription/ui | No dedicated Person detail UI in current pages. | | Read detail and linked documents | RD-1, RD-2, RD-3, RD-4 | Implemented | src/transcription/ui/pages/people_page.py; src/transcription/services/documents.py; tests/ui/test_people_page.py; tests/services/test_document_service.py | Person detail route renders metadata, read-only timestamps, linked-document section, and invalid/missing-id states. |
| Update behavior | UP-1, UP-2, UP-3, UP-4, UP-5 | Planned | src/transcription/services/documents.py | Service update exists; no first-class Person edit surface yet. | | Update behavior | UP-1, UP-2, UP-3, UP-4, UP-5 | Implemented | src/transcription/ui/pages/people_page.py; src/transcription/services/documents.py; tests/ui/test_people_page.py; tests/services/test_document_service.py | Dedicated Person edit page supports allowed fields, required full_name validation, and save path back to detail. |
| Delete behavior and guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Planned | src/transcription/services/documents.py | Service delete exists; relationship-aware UI guard flow is not implemented. | | Delete behavior and guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Implemented | src/transcription/ui/pages/people_page.py; src/transcription/services/documents.py; tests/ui/test_people_page.py; tests/services/test_document_service.py | Dedicated delete page provides permanent-action confirmation, linked-document blocking message, and guarded backend delete behavior. |
## Source ## Source
| Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes | | Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes |
|---|---|---|---|---| |---|---|---|---|---|
| Create entry and required links | CR-1, CR-2, CR-4, CR-5 | Partial | src/transcription/services/store.py; src/transcription/ui/pages/upload_page.py | Upload flow creates Document, Job, Source, and JobSource together; first-release job-context-only Source create intent is documented but not exposed as dedicated Source create UI. | | Create entry and required links | CR-1, CR-2, CR-4, CR-5 | Implemented | src/transcription/services/store.py; src/transcription/ui/pages/jobs_page.py; src/transcription/ui/pages/upload_page.py; tests/services/test_store.py; tests/ui/test_jobs_page.py | Source upload/create is job-create-context only (legacy upload route redirects), with required Document and JobSource linkage enforced. |
| Ordering and filename policy | CR-3 | Partial | src/transcription/services/store.py | Current implementation persists generated filenames and assigns page_number in upload flow; target policy requires strict alphabetical ordering guidance and UUID.extension convention. | | Ordering and filename policy | CR-3 | Implemented | src/transcription/services/store.py; tests/services/test_store.py; tests/ui/test_jobs_page.py | Multi-file/folder uploads are ordered alphabetically by original filename, helper text is visible, and stored filenames use generated unique-id plus extension. |
| Read and revision visibility | RD-1, RD-2, RD-3 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/ui/components/job_detail.py; src/transcription/ui/components/transcript.py | Source preview and revision context are available primarily in job detail flow. | | Read and revision visibility | RD-1, RD-2, RD-3 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/ui/components/job_detail.py; src/transcription/ui/components/transcript.py | Source preview and revision context are available primarily in job detail flow. |
| Revision update behavior | UP-1, UP-2, UP-3, UP-4 | Implemented | src/transcription/services/transcription.py; src/transcription/ui/pages/jobs_page.py; src/transcription/ui/components/transcript.py | Revised text editing and save feedback path exists in job detail revision flow. | | Revision update behavior | UP-1, UP-2, UP-3, UP-4 | Implemented | src/transcription/services/transcription.py; src/transcription/ui/pages/jobs_page.py; src/transcription/ui/components/transcript.py | Revised text editing and save feedback path exists in job detail revision flow. |
| Delete and dependency guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Planned | src/transcription/services/transcription.py | Source delete service path exists; dedicated UI delete control and guardrail messaging are not implemented. | | Delete and dependency guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/services/transcription.py; tests/ui/test_jobs_page.py; tests/services/test_transcription_service.py; tests/services/test_v2_crud.py | Job-detail source context now exposes delete entry with confirmation copy and dependency-aware service guardrails for multi-job links. |
## Job ## Job
| Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes | | Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes |
|---|---|---|---|---| |---|---|---|---|---|
| Create entry and required links | CR-1, CR-2, CR-5, CR-6 | Partial | src/transcription/services/store.py; src/transcription/ui/pages/upload_page.py; src/transcription/ui/pages/jobs_page.py | Current create path is upload-first and implicit; target requires explicit Create job flow from Jobs page. | | Create entry and required links | CR-1, CR-2, CR-5, CR-6 | Implemented | src/transcription/services/store.py; src/transcription/ui/pages/jobs_page.py; tests/ui/test_jobs_page.py; tests/services/test_store.py | Jobs list now has explicit Create entry and `/jobs/new` create flow with Document selection, upload validation, and submit routing to job detail. |
| Source ordering and upload behavior | CR-3 | Partial | src/transcription/services/store.py | Current upload path is single-file create and uses service-assigned page defaults; target requires multi-file or folder handling with alphabetical ordering guidance. | | Source ordering and upload behavior | CR-3 | Implemented | src/transcription/services/store.py; src/transcription/ui/pages/jobs_page.py; tests/services/test_store.py; tests/ui/test_jobs_page.py | Multi-file and folder upload affordances are present, uploads are sorted alphabetically by original filename, and helper guidance is shown in create UI. |
| Provider/model/prompt visibility | CR-4, RD-4 | Partial | src/transcription/services/workflows.py; src/transcription/ui/pages/jobs_page.py | Values are populated in workflow updates but not rendered as first-class fields in current job detail. | | Provider/model/prompt visibility | CR-4, RD-4 | Implemented | src/transcription/services/workflows.py; src/transcription/ui/pages/jobs_page.py; tests/ui/test_jobs_page.py | Provider/model/prompt fields are visible in create and detail flows when known (with pending fallback labels). |
| Jobs list and detail read states | RD-1, RD-2, RD-3, RD-5 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/ui/components/table/jobs.py; tests/ui/test_jobs_page.py | Jobs list, detail route, and invalid/missing id states are present. | | Jobs list and detail read states | RD-1, RD-2, RD-3, RD-5 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/ui/components/table/jobs.py; tests/ui/test_jobs_page.py | Jobs list, detail route, and invalid/missing id states are present. |
| Revision update behavior | UP-1, UP-2, UP-3, UP-4 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/ui/components/transcript.py; src/transcription/services/transcription.py | Revision editing and save feedback exist in job detail source context. | | Revision update behavior | UP-1, UP-2, UP-3, UP-4 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/ui/components/transcript.py; src/transcription/services/transcription.py | Revision editing and save feedback exist in job detail source context. |
| Lifecycle visibility and retry indicators | UP-5 | Partial | src/transcription/services/jobs.py; src/transcription/services/workflows.py; src/transcription/ui/pages/jobs_page.py | Status and retry_count are visible, but lifecycle controls remain system-managed and create-mode visibility targets are still pending. | | Lifecycle visibility and retry indicators | UP-5 | Implemented | src/transcription/services/jobs.py; src/transcription/services/workflows.py; src/transcription/ui/pages/jobs_page.py; tests/ui/test_jobs_page.py | Job detail now surfaces lifecycle status plus retry/update metadata while lifecycle fields remain system-managed (no direct user edit controls). |
| Delete and dependency guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Planned | src/transcription/services/jobs.py | Backend delete exists; dedicated UI delete flow and policy messaging are not implemented. | | Delete and dependency guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/services/jobs.py; tests/ui/test_jobs_page.py; tests/services/test_job_service.py | Dedicated Job delete page provides permanent-action confirmation, processing-state blocked-delete messaging, and dependent JobSource cleanup path when deletion is allowed. |
## Quality Gate Coverage ## Quality Gate Coverage
@@ -55,7 +55,7 @@ Status legend:
|---|---|---|---| |---|---|---|---|
| Separation of intent vs implementation | QG-1 across entities | Implemented | user-journey.md, schema-mapping.md, and acceptance-criteria.md are maintained per entity. | | Separation of intent vs implementation | QG-1 across entities | Implemented | user-journey.md, schema-mapping.md, and acceptance-criteria.md are maintained per entity. |
| Traceability from criteria to implementation | QG-2 across entities | Implemented | This matrix provides criterion-to-code anchors and current status tags. | | Traceability from criteria to implementation | QG-2 across entities | Implemented | This matrix provides criterion-to-code anchors and current status tags. |
| First-release constraints | QG-3 across entities | Partial | Constraints are documented for Document, Person, Source, and Job; implementation remains mixed across entities. | | First-release constraints | QG-3 across entities | Implemented | Constraints are documented and aligned with current flows: jobs-first source upload, visible provider/model/prompt context, and system-managed lifecycle fields. |
## Supporting Entity Coverage ## Supporting Entity Coverage
@@ -66,10 +66,13 @@ Status legend:
## Suggested Implementation Order ## Suggested Implementation Order
1. Document: add dedicated detail/read surface and metadata rendering. 1. Aggregate final acceptance review across Document, Person, Source, and Job criteria.
2. Document: add edit and delete UI with dependency guardrails.
3. Person: add create/read/update/delete pages and relationship-aware delete constraints. ## Aggregate Final Review Snapshot (2026-08-02)
4. Job: add explicit Create job flow from Jobs page with Document selection and source upload controls.
5. Job: render provider/model/prompt_name visibility in create and detail when known. | Entity | Acceptance IDs still not fully met | Evidence | Notes |
6. Source: align create flow behavior with documented job-context-only invariant and ordering/filename policy. |---|---|---|---|
7. Source: add delete UI with dependency checks and blocked-delete guidance. | Document | None | src/transcription/ui/pages/documents_page.py; tests/ui/test_documents_page.py | Document criteria are covered by dedicated detail/edit/delete pages and document-scoped related views. |
| Person | None | src/transcription/ui/pages/people_page.py; tests/ui/test_people_page.py | Person criteria are covered by dedicated create/detail/edit/delete pages with relationship-aware delete guardrails. |
| Source | None | src/transcription/services/store.py; src/transcription/ui/pages/jobs_page.py; tests/services/test_store.py; tests/services/test_transcription_service.py | Source criteria are covered by job-context create behavior, ordering/filename policy, revision flow, and delete guardrails. |
| Job | None | src/transcription/ui/pages/jobs_page.py; src/transcription/services/jobs.py; tests/ui/test_jobs_page.py; tests/services/test_job_service.py | Job criteria are covered by create/read/revision/lifecycle visibility and delete guardrails in dedicated routes. |
+4 -9
View File
@@ -20,10 +20,8 @@ from .config import Settings
from .config import configure_logging from .config import configure_logging
from .config import get_settings from .config import get_settings
from .db import create_all from .db import create_all
from .db import dispose_database_runtime
from .db import initialize_database_runtime from .db import initialize_database_runtime
from .db.engine import get_database_url
from .db.engine import resolve_engine
from .db.session import dispose_session_factory
from .services import ServiceBundle from .services import ServiceBundle
from .services.jobs import JobService from .services.jobs import JobService
from .ui import register_pages from .ui import register_pages
@@ -41,7 +39,7 @@ async def _lifespan(app: FastAPI):
app.state.runtime = initialize_database_runtime(settings=settings) app.state.runtime = initialize_database_runtime(settings=settings)
if settings.should_bootstrap_schema: if settings.should_bootstrap_schema:
await create_all(engine=resolve_engine(settings=settings)) await create_all(engine=app.state.runtime.engine)
settings.upload_dir.mkdir(parents=True, exist_ok=True) settings.upload_dir.mkdir(parents=True, exist_ok=True)
settings.prompt_dir.mkdir(parents=True, exist_ok=True) settings.prompt_dir.mkdir(parents=True, exist_ok=True)
@@ -49,10 +47,7 @@ async def _lifespan(app: FastAPI):
await _recover_stale_processing_jobs(app) await _recover_stale_processing_jobs(app)
async with AsyncExitStack() as stack: async with AsyncExitStack() as stack:
stack.push_async_callback( stack.push_async_callback(dispose_database_runtime)
dispose_session_factory,
database_url=get_database_url(settings),
)
stop_event, worker_notifier = await stack.enter_async_context( stop_event, worker_notifier = await stack.enter_async_context(
worker_consumer_lifespan( worker_consumer_lifespan(
session_factory=app.state.runtime.session_factory, session_factory=app.state.runtime.session_factory,
@@ -95,7 +90,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
@app.get("/ui", include_in_schema=False) @app.get("/ui", include_in_schema=False)
async def ui_redirect() -> RedirectResponse: async def ui_redirect() -> RedirectResponse:
return RedirectResponse(url="/ui/upload", status_code=status.HTTP_307_TEMPORARY_REDIRECT) return RedirectResponse(url="/ui/jobs", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
@app.get("/healthz") @app.get("/healthz")
def health() -> dict[str, str]: def health() -> dict[str, str]:
+12
View File
@@ -151,6 +151,18 @@ class Job(SQLModel, table=True):
return "unknown" return "unknown"
@property
def error_detail(self) -> str | None:
"""Return the first available source-level error detail for the job."""
if not self.job_sources:
return None
for job_source in self.job_sources:
if job_source.error_detail:
return job_source.error_detail
return None
class Source(SQLModel, table=True): class Source(SQLModel, table=True):
"""A document source image or PDF page.""" """A document source image or PDF page."""
+107 -2
View File
@@ -1,6 +1,8 @@
import logging import logging
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC
from datetime import datetime
from pathlib import Path from pathlib import Path
from uuid import UUID from uuid import UUID
@@ -35,6 +37,14 @@ class DocumentAlreadyExistsError(DocumentError):
"""Raised when a document with the same name already exists in the database.""" """Raised when a document with the same name already exists in the database."""
class DocumentDeleteBlockedError(DocumentError):
"""Raised when a document delete is blocked by dependent records."""
class PersonDeleteBlockedError(DocumentError):
"""Raised when a person delete is blocked by linked documents."""
@dataclass(frozen=True) @dataclass(frozen=True)
class UploadJobResult: class UploadJobResult:
"""Summary of created upload records.""" """Summary of created upload records."""
@@ -102,6 +112,7 @@ class DocumentService(ServiceBase):
async def update_document(self, document: Document, *, session: AsyncSession | None = None) -> Document: async def update_document(self, document: Document, *, session: AsyncSession | None = None) -> Document:
"""Update an existing document in the database.""" """Update an existing document in the database."""
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
document.updated_at = datetime.now(UTC)
merged = await _session.merge(document) merged = await _session.merge(document)
await self._finalize(session=_session, caller_session=session, refresh=(merged,)) await self._finalize(session=_session, caller_session=session, refresh=(merged,))
return merged return merged
@@ -109,7 +120,36 @@ class DocumentService(ServiceBase):
async def delete_document(self, document: Document, *, session: AsyncSession | None = None) -> None: async def delete_document(self, document: Document, *, session: AsyncSession | None = None) -> None:
"""Delete a document from the database.""" """Delete a document from the database."""
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
await _session.delete(document) existing = await _session.get(
Document,
document.id,
options=(
selectinload(Document.jobs), # pyright: ignore[reportArgumentType]
selectinload(Document.sources), # pyright: ignore[reportArgumentType]
),
)
if existing is None:
raise DocumentError(
f"Document with id {document.id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the document id and retry.",
)
has_jobs = bool(existing.jobs)
has_sources = bool(existing.sources)
if has_jobs or has_sources:
blocked_by: list[str] = []
if has_sources:
blocked_by.append("Sources")
if has_jobs:
blocked_by.append("Jobs")
raise DocumentDeleteBlockedError(
f"Document delete blocked by related records: {', '.join(blocked_by)}",
category=ErrorCategory.VALIDATION,
suggestion="Remove related Sources and Jobs first, then retry deletion.",
)
await _session.delete(existing)
await self._finalize(session=_session, caller_session=session) await self._finalize(session=_session, caller_session=session)
async def create_person(self, person: Person, *, session: AsyncSession | None = None) -> Person: async def create_person(self, person: Person, *, session: AsyncSession | None = None) -> Person:
@@ -131,9 +171,31 @@ class DocumentService(ServiceBase):
) )
return person return person
async def read_person_detail(self, person_id: UUID, *, session: AsyncSession | None = None) -> Person:
"""Read a person with eagerly loaded document links for UI detail rendering."""
async with self._session_scope(session) as _session:
query = (
select(Person)
.options(
selectinload(Person.document_people).selectinload(DocumentPerson.document), # pyright: ignore[reportArgumentType]
)
.where(Person.id == person_id)
.execution_options(populate_existing=True)
)
person = (await _session.exec(query)).first()
if person is None:
raise DocumentError(
f"Person with id {person_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the person id and retry.",
)
return person
async def update_person(self, person: Person, *, session: AsyncSession | None = None) -> Person: async def update_person(self, person: Person, *, session: AsyncSession | None = None) -> Person:
"""Update an existing person in the database.""" """Update an existing person in the database."""
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
person.updated_at = datetime.now(UTC)
merged = await _session.merge(person) merged = await _session.merge(person)
await self._finalize(session=_session, caller_session=session, refresh=(merged,)) await self._finalize(session=_session, caller_session=session, refresh=(merged,))
return merged return merged
@@ -141,7 +203,28 @@ class DocumentService(ServiceBase):
async def delete_person(self, person: Person, *, session: AsyncSession | None = None) -> None: async def delete_person(self, person: Person, *, session: AsyncSession | None = None) -> None:
"""Delete a person from the database.""" """Delete a person from the database."""
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
await _session.delete(person) existing = await _session.get(
Person,
person.id,
options=(
selectinload(Person.document_people), # pyright: ignore[reportArgumentType]
),
)
if existing is None:
raise DocumentError(
f"Person with id {person.id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the person id and retry.",
)
if existing.document_people:
raise PersonDeleteBlockedError(
"Person delete blocked by linked documents",
category=ErrorCategory.VALIDATION,
suggestion="Remove linked DocumentPerson records first, then retry deletion.",
)
await _session.delete(existing)
await self._finalize(session=_session, caller_session=session) await self._finalize(session=_session, caller_session=session)
async def create_document_person( async def create_document_person(
@@ -215,6 +298,28 @@ class DocumentService(ServiceBase):
result = await _session.exec(select(Document)) result = await _session.exec(select(Document))
return result.all() return result.all()
async def read_document_detail(self, document_id: UUID, *, session: AsyncSession | None = None) -> Document:
"""Read a document with eagerly loaded relations for UI detail rendering."""
async with self._session_scope(session) as _session:
query = (
select(Document)
.options(
selectinload(Document.jobs), # pyright: ignore[reportArgumentType]
selectinload(Document.sources), # pyright: ignore[reportArgumentType]
selectinload(Document.document_people).selectinload(DocumentPerson.person), # pyright: ignore[reportArgumentType]
)
.where(Document.id == document_id)
.execution_options(populate_existing=True)
)
document = (await _session.exec(query)).first()
if document is None:
raise DocumentError(
f"Document with id {document_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the document id and retry.",
)
return document
async def list_people(self, *, session: AsyncSession | None = None) -> Sequence[Person]: async def list_people(self, *, session: AsyncSession | None = None) -> Sequence[Person]:
"""List all people in the database.""" """List all people in the database."""
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
+37
View File
@@ -7,6 +7,8 @@ from sqlalchemy.orm import selectinload
from sqlmodel import select from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from ..errors import AppError
from ..errors import ErrorCategory
from ..db.models import Job from ..db.models import Job
from ..db.models import JobSource from ..db.models import JobSource
from ..db.models import JobStatus from ..db.models import JobStatus
@@ -14,6 +16,10 @@ from ..db.models import Source
from .base import ServiceBase from .base import ServiceBase
class JobDeleteBlockedError(AppError):
"""Raised when a job delete operation is blocked by lifecycle policy."""
class JobService(ServiceBase): class JobService(ServiceBase):
"""Thin service class for managing jobs in the database.""" """Thin service class for managing jobs in the database."""
@@ -183,3 +189,34 @@ class JobService(ServiceBase):
await self._finalize(session=_session, caller_session=session, refresh=stale_jobs) await self._finalize(session=_session, caller_session=session, refresh=stale_jobs)
return len(stale_jobs) return len(stale_jobs)
async def delete_job_with_guardrails(self, *, job_id: UUID, session: AsyncSession | None = None) -> None:
"""Delete a job with lifecycle guardrails and dependent cleanup policy.
Policy:
- Block when the job is actively processing.
- Otherwise remove related JobSource rows, then delete the job.
"""
async with self._session_scope(session) as _session:
query = (
select(Job)
.options(selectinload(Job.job_sources)) # pyright: ignore[reportArgumentType]
.where(Job.id == job_id)
.execution_options(populate_existing=True)
)
job = (await _session.exec(query)).first()
if job is None:
raise ValueError(f"Job with id {job_id} not found")
if job.status == JobStatus.PROCESSING:
raise JobDeleteBlockedError(
"Job delete blocked while status is processing",
category=ErrorCategory.VALIDATION,
suggestion="Wait for processing to complete, or move the job out of processing before deleting.",
)
for job_source in list(job.job_sources):
await _session.delete(job_source)
await _session.delete(job)
await self._finalize(session=_session, caller_session=session)
+132 -1
View File
@@ -1,9 +1,13 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
import logging import logging
from pathlib import Path from pathlib import Path
from uuid import UUID
from uuid import uuid4 from uuid import uuid4
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from transcription.config import Settings from transcription.config import Settings
@@ -27,6 +31,15 @@ class UploadError(AppError):
"""Raised when uploaded content cannot be persisted safely.""" """Raised when uploaded content cannot be persisted safely."""
@dataclass(frozen=True)
class JobCreateResult:
"""Summary of explicit Job create records."""
document_id: UUID
job_id: UUID
source_ids: tuple[UUID, ...]
async def create_upload_job( async def create_upload_job(
*, *,
filename: str, filename: str,
@@ -65,6 +78,66 @@ async def create_upload_job(
) )
async def create_job_for_document(
*,
document_id: UUID,
uploads: Sequence[tuple[str, bytes]],
session: AsyncSession,
provider: str | None = None,
model: str | None = None,
prompt_name: str | None = None,
settings: Settings | None = None,
) -> JobCreateResult:
"""Create a queued job for an existing document with one or more uploaded sources."""
if not uploads:
raise UploadError(
"At least one upload is required to create a job",
category=ErrorCategory.VALIDATION,
suggestion="Upload one or more files and try again.",
)
runtime_settings = settings or get_settings()
sorted_uploads = sorted(uploads, key=lambda item: Path(item[0]).name.casefold())
stored_uploads: list[tuple[str, Path]] = []
for filename, file_bytes in sorted_uploads:
stored_uploads.append(
(
filename,
store_file(
filename=filename,
file_bytes=file_bytes,
settings=runtime_settings,
),
)
)
try:
job, source_ids = await _create_job_for_document_records(
session=session,
document_id=document_id,
stored_uploads=stored_uploads,
provider=provider,
model=model,
prompt_name=prompt_name,
)
except Exception as exc:
for _, stored_path in stored_uploads:
_best_effort_delete(stored_path)
raise UploadError(
"Failed to create job records from uploads",
category=ErrorCategory.INFRA_TRANSIENT,
suggestion="Retry creation. If this keeps happening, verify database availability.",
retriable=True,
) from exc
logger.info("Created explicit job document_id=%s job_id=%s sources=%s", document_id, job.id, len(source_ids))
return JobCreateResult(
document_id=document_id,
job_id=job.id,
source_ids=tuple(source_ids),
)
async def _create_upload_records( async def _create_upload_records(
*, *,
session: AsyncSession, session: AsyncSession,
@@ -105,6 +178,63 @@ async def _create_upload_records(
return document, job return document, job
async def _create_job_for_document_records(
*,
session: AsyncSession,
document_id: UUID,
stored_uploads: Sequence[tuple[str, Path]],
provider: str | None,
model: str | None,
prompt_name: str | None,
) -> tuple[Job, list[UUID]]:
document = await session.get(Document, document_id)
if document is None:
raise UploadError(
f"Document with id {document_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Select an existing document and retry.",
)
existing_sources = (
await session.exec(select(Source).where(Source.document_id == document_id))
).all()
next_page_number = (max((source.page_number for source in existing_sources), default=0) + 1)
job = Job(
document_id=document_id,
provider=(provider or None),
model=(model or None),
prompt_name=(prompt_name or None),
)
session.add(job)
await session.flush()
source_ids: list[UUID] = []
for page_offset, (original_filename, stored_path) in enumerate(stored_uploads):
source = Source(
document_id=document_id,
page_number=next_page_number + page_offset,
upload_name=Path(original_filename).name,
filename=stored_path.name,
file_path=str(stored_path),
)
session.add(source)
await session.flush()
source_ids.append(source.id)
session.add(
JobSource(
job_id=job.id,
source_id=source.id,
status=JobSourceStatus.PENDING,
)
)
await session.commit()
await session.refresh(job)
return job, source_ids
def _best_effort_delete(path: Path) -> None: def _best_effort_delete(path: Path) -> None:
try: try:
if path.exists(): if path.exists():
@@ -164,4 +294,5 @@ def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
def _build_stored_filename(filename: str) -> str: def _build_stored_filename(filename: str) -> str:
safe_name = Path(filename).name safe_name = Path(filename).name
return f"{uuid4()}_{safe_name}" suffix = Path(safe_name).suffix.lower()
return f"{uuid4()}{suffix}"
@@ -51,6 +51,10 @@ class TranscriptionNotFoundError(TranscriptionError):
"""Raised when a transcription-related resource is not found.""" """Raised when a transcription-related resource is not found."""
class SourceDeleteBlockedError(TranscriptionError):
"""Raised when source deletion is blocked by dependency policy."""
class TranscriptionService(ServiceBase): class TranscriptionService(ServiceBase):
"""Service class for job transcription output and page-level source revisions.""" """Service class for job transcription output and page-level source revisions."""
@@ -164,6 +168,56 @@ class TranscriptionService(ServiceBase):
await _session.delete(job_source) await _session.delete(job_source)
await self._finalize(session=_session, caller_session=session) await self._finalize(session=_session, caller_session=session)
async def delete_source_from_job_context(
self,
*,
job_id: UUID,
source_id: UUID,
session: AsyncSession | None = None,
) -> None:
"""Delete a source from an active job context with dependency guardrails.
Policy:
- Allowed when exactly one JobSource link exists and it points at ``job_id``.
- Blocked when additional JobSource links exist (history/shared dependencies).
"""
async with self._session_scope(session) as _session:
source = await _session.get(
Source,
source_id,
options=(
selectinload(Source.job_sources), # pyright: ignore[reportArgumentType]
),
)
if source is None:
raise TranscriptionNotFoundError(
f"Source with id {source_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the source id and retry.",
)
linked_job_sources = list(source.job_sources)
matching_links = [job_source for job_source in linked_job_sources if job_source.job_id == job_id]
if not matching_links:
raise TranscriptionNotFoundError(
f"Source {source_id} is not linked to job {job_id}",
category=ErrorCategory.NOT_FOUND,
suggestion="Open the source from its linked job context and retry.",
)
if len(linked_job_sources) > len(matching_links):
raise SourceDeleteBlockedError(
"Source delete blocked by related job history",
category=ErrorCategory.VALIDATION,
suggestion="Remove additional JobSource links first, then retry deletion.",
)
for job_source in matching_links:
await _session.delete(job_source)
await _session.delete(source)
await self._finalize(session=_session, caller_session=session)
async def list_job_sources( async def list_job_sources(
self, self,
*, *,
+11 -1
View File
@@ -70,7 +70,17 @@ async def process_queued_job(
source_job = await services.jobs.read_job(job_id=job.id, session=session) source_job = await services.jobs.read_job(job_id=job.id, session=session)
source = _resolve_primary_source(source_job) source = _resolve_primary_source(source_job)
assert source is not None, f"Job {job.id} has no associated source record." if source is None:
candidate_sources = await services.transcriptions.list_sources(document_id=job.document_id, session=session)
source = next(iter(sorted(candidate_sources, key=lambda item: item.page_number)), None)
if source is None:
error = AppError(
f"Job {job.id} has no associated source record.",
category=ErrorCategory.VALIDATION,
suggestion="Attach at least one source to the job and retry.",
)
return await _finalize_failed(job=job, services=services, error=error, session=session)
started_at = asyncio.get_running_loop().time() started_at = asyncio.get_running_loop().time()
try: try:
+4
View File
@@ -3,7 +3,9 @@
from fastapi import FastAPI from fastapi import FastAPI
from nicegui import ui from nicegui import ui
from transcription.ui.pages.documents_page import register_page as register_documents_page
from transcription.ui.pages.jobs_page import register_page as register_jobs_page from transcription.ui.pages.jobs_page import register_page as register_jobs_page
from transcription.ui.pages.people_page import register_page as register_people_page
from transcription.ui.pages.upload_page import register_page as register_upload_page from transcription.ui.pages.upload_page import register_page as register_upload_page
from transcription.ui.resources import read_css from transcription.ui.resources import read_css
@@ -23,5 +25,7 @@ def register_pages(app: FastAPI) -> None:
"""Register all NiceGUI pages and mount them onto the FastAPI app.""" """Register all NiceGUI pages and mount them onto the FastAPI app."""
_register_global_styles(app) _register_global_styles(app)
register_upload_page() register_upload_page()
register_documents_page()
register_people_page()
register_jobs_page() register_jobs_page()
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False) ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False)
+7 -2
View File
@@ -7,7 +7,8 @@ from nicegui import ui
from transcription.ui.resources import read_css from transcription.ui.resources import read_css
NAV_ITEMS: tuple[tuple[str, str, str], ...] = ( NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
("Upload", "/upload", "upload_file"), ("Documents", "/documents", "description"),
("People", "/people", "group"),
("Jobs", "/jobs", "work_history"), ("Jobs", "/jobs", "work_history"),
) )
@@ -15,6 +16,10 @@ NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
def _is_active_path(*, current_path: str, item_path: str) -> bool: def _is_active_path(*, current_path: str, item_path: str) -> bool:
if item_path == "/jobs": if item_path == "/jobs":
return current_path == "/jobs" or current_path.startswith("/jobs/") return current_path == "/jobs" or current_path.startswith("/jobs/")
if item_path == "/documents":
return current_path == "/documents" or current_path.startswith("/documents/")
if item_path == "/people":
return current_path == "/people" or current_path.startswith("/people/")
return current_path == item_path return current_path == item_path
@@ -34,7 +39,7 @@ def _render_nav_button(*, label: str, path: str, icon: str, current_path: str) -
def _normalize_path(current_path: str | None) -> str: def _normalize_path(current_path: str | None) -> str:
normalized = (current_path or "").strip() normalized = (current_path or "").strip()
if not normalized: if not normalized:
return "/upload" return "/jobs"
return normalized.rstrip("/") or "/" return normalized.rstrip("/") or "/"
@@ -0,0 +1,373 @@
"""Documents list and detail page registration."""
from __future__ import annotations
from datetime import date
from uuid import UUID
from nicegui import ui
from transcription.db.models import Document
from transcription.errors import ErrorCategory
from transcription.services.documents import DocumentDeleteBlockedError
from transcription.services.documents import DocumentError
from transcription.services.documents import DocumentService
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.error_presenter import show_error
from ...db.session import SessionFactoryDep
def register_page() -> None:
"""Register documents list and detail routes."""
@ui.page("/documents")
async def documents_page(session_factory: SessionFactoryDep) -> None:
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
ui.label("Documents").classes("text-h5 text-weight-medium")
try:
documents = sorted(
await document_service.list_documents(),
key=lambda item: item.created_at,
reverse=True,
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.list")
return
if not documents:
ui.label("No documents yet.").classes("text-body1 vibe-text-muted")
return
with ui.column().classes("w-full gap-2"):
for document in documents:
with ui.card().classes("w-full"):
with ui.row().classes("w-full items-center justify-between"):
with ui.column().classes("gap-1"):
ui.label(document.name).classes("text-subtitle1 text-weight-medium")
ui.label(f"Type: {document.document_type or 'unspecified'}").classes("text-body2")
ui.button(
"Open",
on_click=lambda _=None, document_id=document.id: ui.navigate.to(f"/documents/{document_id}"),
icon="open_in_new",
).props("flat")
@ui.page("/documents/{document_id}")
async def document_detail_page(document_id: str, session_factory: SessionFactoryDep) -> None:
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
try:
parsed_document_id = UUID(document_id)
except ValueError:
ui.label("Invalid document id").classes("text-h6 text-negative")
return
try:
document = await document_service.read_document_detail(document_id=parsed_document_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-negative")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.read")
return
ui.label(document.name).classes("text-h5 text-weight-medium")
ui.label(f"Document type: {document.document_type or 'unspecified'}").classes("text-subtitle1")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Edit document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"), icon="edit").props(
'unelevated color="primary"'
)
ui.button(
"Delete document",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/delete"),
icon="delete",
).props("outline color=negative")
with ui.column().classes("w-full gap-1"):
ui.label(f"Exact date: {document.document_date.isoformat() if document.document_date else 'not set'}")
ui.label(f"Approximate date: {document.document_date_raw or 'not set'}")
ui.label(f"Location created: {document.location_created or 'not set'}")
ui.label(f"Archive identifier: {document.archive_identifier or 'not set'}")
ui.label(f"Notes: {document.notes or 'not set'}")
ui.label(f"Created at (read-only): {document.created_at.isoformat()}").classes("text-body2")
ui.label(f"Updated at (read-only): {document.updated_at.isoformat()}").classes("text-body2")
ui.separator()
ui.label("Related people").classes("text-subtitle1 text-weight-medium")
if not document.document_people:
ui.label("No linked people yet.").classes("text-body2 vibe-text-muted")
else:
for link in document.document_people:
person = link.person
person_label = person.full_name if person is not None else "Unknown person"
ui.label(f"{person_label} ({link.role.value})").classes("text-body2")
ui.separator()
ui.label("Sources").classes("text-subtitle1 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
ui.button(
"View sources",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/sources"),
icon="description",
).props("flat")
if not document.sources:
ui.label("No sources added yet.").classes("text-body2 vibe-text-muted")
ui.button(
"Add sources",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="upload_file",
).props('unelevated color="primary"')
else:
for source in sorted(document.sources, key=lambda item: item.page_number):
ui.label(f"Page {source.page_number}: {source.upload_name}").classes("text-body2")
ui.separator()
ui.label("Jobs").classes("text-subtitle1 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
ui.button("View jobs", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"), icon="work_history").props(
"flat"
)
if not document.jobs:
ui.label("No jobs created yet.").classes("text-body2 vibe-text-muted")
ui.button(
"Create job",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="add",
).props('unelevated color="primary"')
else:
with ui.column().classes("w-full gap-1"):
for job in sorted(document.jobs, key=lambda item: item.date_created, reverse=True):
with ui.row().classes("w-full items-center justify-between"):
ui.label(f"{job.status.value} - {job.id}").classes("text-body2")
ui.button("Open", on_click=lambda _=None, job_id=job.id: ui.navigate.to(f"/jobs/{job_id}"), icon="open_in_new").props("flat")
@ui.page("/documents/{document_id}/jobs")
async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> None:
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
try:
parsed_document_id = UUID(document_id)
except ValueError:
ui.label("Invalid document id").classes("text-h6 text-negative")
return
try:
document = await document_service.read_document_detail(document_id=parsed_document_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-negative")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.jobs")
return
ui.label(f"Jobs for {document.name}").classes("text-h5 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
ui.button("Create job", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="add").props(
'unelevated color="primary"'
)
if not document.jobs:
ui.label("No jobs created yet.").classes("text-body2 vibe-text-muted")
return
for job in sorted(document.jobs, key=lambda item: item.date_created, reverse=True):
with ui.card().classes("w-full"):
with ui.row().classes("w-full items-center justify-between"):
ui.label(f"{job.status.value} - {job.id}").classes("text-body2")
ui.button("Open", on_click=lambda _=None, job_id=job.id: ui.navigate.to(f"/jobs/{job_id}"), icon="open_in_new").props(
"flat"
)
@ui.page("/documents/{document_id}/sources")
async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> None:
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
try:
parsed_document_id = UUID(document_id)
except ValueError:
ui.label("Invalid document id").classes("text-h6 text-negative")
return
try:
document = await document_service.read_document_detail(document_id=parsed_document_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-negative")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.sources")
return
ui.label(f"Sources for {document.name}").classes("text-h5 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
ui.button("Add sources", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="upload_file").props(
'unelevated color="primary"'
)
if not document.sources:
ui.label("No sources added yet.").classes("text-body2 vibe-text-muted")
return
for source in sorted(document.sources, key=lambda item: item.page_number):
with ui.card().classes("w-full"):
ui.label(f"Page {source.page_number}: {source.upload_name}").classes("text-body2")
ui.label(f"Stored filename: {source.filename}").classes("text-body2")
@ui.page("/documents/{document_id}/edit")
async def document_edit_page(document_id: str, session_factory: SessionFactoryDep) -> None:
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
try:
parsed_document_id = UUID(document_id)
except ValueError:
ui.label("Invalid document id").classes("text-h6 text-negative")
return
try:
document = await document_service.read_document_detail(document_id=parsed_document_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-negative")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.edit.read")
return
ui.label("Edit document").classes("text-h5 text-weight-medium")
ui.label("Document name and document type are required.").classes("text-body2 vibe-text-muted")
name_input = ui.input(label="Document name", value=document.name).props("outlined")
document_type_input = ui.input(label="Document type", value=document.document_type or "").props("outlined")
date_input = ui.input(
label="Exact date (YYYY-MM-DD)",
value=document.document_date.isoformat() if document.document_date else "",
).props("outlined")
date_raw_input = ui.input(label="Approximate date", value=document.document_date_raw or "").props("outlined")
location_input = ui.input(label="Document location", value=document.location_created or "").props("outlined")
archive_input = ui.input(label="Archive identifier", value=document.archive_identifier or "").props("outlined")
notes_input = ui.textarea(label="Notes", value=document.notes or "").props("outlined autogrow")
async def submit_edit() -> None:
candidate_name = (name_input.value or "").strip()
candidate_type = (document_type_input.value or "").strip()
if not candidate_name:
ui.notify("Document name is required.", type="warning")
return
if not candidate_type:
ui.notify("Document type is required.", type="warning")
return
parsed_date: date | None = None
candidate_date_text = (date_input.value or "").strip()
if candidate_date_text:
try:
parsed_date = date.fromisoformat(candidate_date_text)
except ValueError:
ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
return
candidate = Document(
id=document.id,
name=candidate_name,
document_type=candidate_type,
document_date=parsed_date,
document_date_raw=(date_raw_input.value or "").strip() or None,
location_created=(location_input.value or "").strip() or None,
notes=(notes_input.value or "").strip() or None,
archive_identifier=(archive_input.value or "").strip() or None,
created_at=document.created_at,
updated_at=document.updated_at,
)
try:
await document_service.update_document(candidate)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Save failed", operation="documents.edit.save")
return
ui.notify("Document updated", type="positive")
ui.navigate.to(f"/documents/{document.id}")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Save changes", on_click=submit_edit, icon="save").props('unelevated color="primary"')
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
@ui.page("/documents/{document_id}/delete")
async def document_delete_page(document_id: str, session_factory: SessionFactoryDep) -> None:
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
try:
parsed_document_id = UUID(document_id)
except ValueError:
ui.label("Invalid document id").classes("text-h6 text-negative")
return
try:
document = await document_service.read_document_detail(document_id=parsed_document_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-negative")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.delete.read")
return
ui.label("Delete document").classes("text-h5 text-weight-medium")
ui.label(f"Document: {document.name}").classes("text-subtitle1")
has_sources = bool(document.sources)
has_jobs = bool(document.jobs)
if has_sources or has_jobs:
ui.label("Delete is blocked because related records exist.").classes("text-negative text-weight-medium")
categories: list[str] = []
if has_sources:
categories.append("Sources")
if has_jobs:
categories.append("Jobs")
ui.label(f"Dependencies present: {', '.join(categories)}").classes("text-body2")
ui.label("Remove related records first, then retry deletion.").classes("text-body2 vibe-text-muted")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history")
return
ui.label("This action permanently deletes the document.").classes("text-negative")
async def submit_delete() -> None:
try:
await document_service.delete_document(document)
except DocumentDeleteBlockedError as exc:
ui.notify(exc.message, type="warning")
ui.navigate.to(f"/documents/{document.id}/delete")
return
except DocumentError as exc:
if exc.category == ErrorCategory.NOT_FOUND:
ui.notify("Document not found.", type="warning")
ui.navigate.to("/documents")
return
show_error(exc, title="Delete failed", operation="documents.delete")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Delete failed", operation="documents.delete")
return
ui.notify("Document deleted", type="positive")
ui.navigate.to("/documents")
with ui.row().classes("w-full items-center gap-2"):
ui.button(
"Delete document permanently",
on_click=submit_delete,
icon="delete_forever",
).props('unelevated color="negative"')
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
+239 -2
View File
@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
from fastapi import Request
from pathlib import Path
from uuid import UUID from uuid import UUID
from nicegui import ui from nicegui import ui
@@ -9,11 +11,17 @@ from nicegui import ui
from transcription.db.models import Job from transcription.db.models import Job
from transcription.db.models import JobStatus from transcription.db.models import JobStatus
from transcription.db.models import Source from transcription.db.models import Source
from transcription.db.session import session_scope
from transcription.services.documents import DocumentService
from transcription.services.jobs import JobDeleteBlockedError
from transcription.services.jobs import JobService from transcription.services.jobs import JobService
from transcription.services.transcription import SourceDeleteBlockedError
from transcription.services.store import create_job_for_document
from transcription.services.transcription import TranscriptionService from transcription.services.transcription import TranscriptionService
from transcription.ui.components.app_shell import render_navigation_header from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.error_presenter import show_error from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.table.jobs import render_jobs_table from transcription.ui.components.table.jobs import render_jobs_table
from transcription.worker import resolve_worker_notifier
from ...db.session import SessionFactoryDep from ...db.session import SessionFactoryDep
from ..components.document_panzoom import render_document_panzoom from ..components.document_panzoom import render_document_panzoom
@@ -45,9 +53,134 @@ def register_page() -> None: # noqa: PLR0915
] ]
render_jobs_table(jobs) render_jobs_table(jobs)
ui.button("Refresh", on_click=render_table.refresh, icon="refresh") with ui.row().classes("w-full items-center gap-2"):
ui.button("Create job", on_click=lambda: ui.navigate.to("/jobs/new"), icon="add").props(
'unelevated color="primary"'
)
ui.button("Refresh", on_click=render_table.refresh, icon="refresh")
await render_table() await render_table()
@ui.page("/jobs/new")
async def job_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
documents_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
ui.label("Create job").classes("text-h5 text-weight-medium")
documents = await documents_service.list_documents()
if not documents:
ui.label("No documents available. Create a Document before creating a Job.").classes(
"text-body1 text-warning"
)
with ui.row():
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back")
return
uploaded_files: list[tuple[str, bytes]] = []
document_options = {str(document.id): document.name for document in documents}
document_select = ui.select(document_options, label="Document").props("outlined")
requested_document_id = request.query_params.get("document_id")
if requested_document_id in document_options:
document_select.value = requested_document_id
provider_input = ui.input(label="Provider").props("outlined")
model_input = ui.input(label="Model").props("outlined")
prompt_input = ui.input(label="Prompt").props("outlined")
ui.label("Source files").classes("text-subtitle1 text-weight-medium")
ui.label(
"Files are processed alphabetically by original filename. Use leading numbers such as 001, 002, 003 to control order."
).classes("text-body2 vibe-text-muted")
@ui.refreshable
def render_upload_list() -> None:
if not uploaded_files:
ui.label("No files uploaded yet.").classes("text-body2 vibe-text-muted")
return
def remove_file(index: int) -> None:
if 0 <= index < len(uploaded_files):
removed_name, _ = uploaded_files.pop(index)
ui.notify(f"Removed {removed_name}", type="info")
render_upload_list.refresh()
def clear_files() -> None:
uploaded_files.clear()
ui.notify("Cleared queued files", type="info")
render_upload_list.refresh()
ordered_uploads = sorted(
enumerate(uploaded_files),
key=lambda item: Path(item[1][0]).name.casefold(),
)
with ui.column().classes("gap-1"):
for index, (filename, _) in ordered_uploads:
with ui.row().classes("w-full items-center justify-between"):
ui.label(Path(filename).name).classes("text-body2")
ui.button(icon="delete", on_click=lambda idx=index: remove_file(idx)).props("flat round dense")
with ui.row().classes("w-full justify-end"):
ui.button("Clear files", on_click=clear_files, icon="clear_all").props("flat")
async def on_upload(event) -> None:
payload = await event.file.read()
uploaded_files.append((event.file.name, payload))
ui.notify(f"Added {event.file.name}", type="positive")
render_upload_list.refresh()
ui.upload(
on_upload=on_upload,
auto_upload=True,
label="Select source files",
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf" multiple')
ui.upload(
on_upload=on_upload,
auto_upload=True,
label="Upload folder",
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf" webkitdirectory directory multiple')
render_upload_list()
async def submit_create() -> None:
selected_document = document_select.value
if not selected_document:
ui.notify("Document is required.", type="warning")
return
if not uploaded_files:
ui.notify("At least one source file is required.", type="warning")
return
try:
document_id = UUID(str(selected_document))
except ValueError:
ui.notify("Selected document id is invalid.", type="warning")
return
try:
async with session_scope(session_factory=session_factory) as session:
result = await create_job_for_document(
document_id=document_id,
uploads=uploaded_files,
provider=(provider_input.value or None),
model=(model_input.value or None),
prompt_name=(prompt_input.value or None),
session=session,
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Create job failed", operation="jobs.create")
return
resolve_worker_notifier(request.app.state).notify()
ui.notify(f"Created job {result.job_id}", type="positive")
ui.navigate.to(f"/jobs/{result.job_id}")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Submit for transcription", on_click=submit_create, icon="play_arrow").props(
'unelevated color="primary"'
)
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back")
@ui.page("/jobs/{job_id}") @ui.page("/jobs/{job_id}")
async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None: # noqa: PLR0915 async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None: # noqa: PLR0915
jobs_service = JobService(session_factory=session_factory) jobs_service = JobService(session_factory=session_factory)
@@ -85,6 +218,20 @@ def register_page() -> None: # noqa: PLR0915
case _: case _:
ui.label(f"{job.status.value}").classes("text-subtitle1 text-weight-medium") ui.label(f"{job.status.value}").classes("text-subtitle1 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
ui.button(
"Delete job",
on_click=lambda: ui.navigate.to(f"/jobs/{parsed_job_id}/delete"),
icon="delete",
).props("outline color=negative")
with ui.column().classes("gap-1"):
ui.label(f"Provider: {job.provider or 'pending'}").classes("text-body2")
ui.label(f"Model: {job.model or 'pending'}").classes("text-body2")
ui.label(f"Prompt: {job.prompt_name or 'pending'}").classes("text-body2")
ui.label(f"Retry count: {job.retry_count}").classes("text-body2")
ui.label(f"Last updated: {job.date_updated.isoformat()}").classes("text-body2")
render_original_transcription_card(job=job) render_original_transcription_card(job=job)
@ui.refreshable @ui.refreshable
@@ -102,6 +249,26 @@ def register_page() -> None: # noqa: PLR0915
editor = ui.textarea(label="Revision text", value=default_revision_text).props("autogrow outlined") editor = ui.textarea(label="Revision text", value=default_revision_text).props("autogrow outlined")
editor.classes("w-full") editor.classes("w-full")
async def delete_source() -> None:
try:
await transcription_service.delete_source_from_job_context(
job_id=parsed_job_id,
source_id=refreshed_source.id,
)
except SourceDeleteBlockedError as exc:
ui.notify(exc.message, type="warning")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Delete source failed", operation="jobs.delete_source")
return
ui.notify("Source deleted", type="positive")
ui.navigate.to(f"/jobs/{parsed_job_id}")
async def confirm_delete_source() -> None:
delete_dialog.close()
await delete_source()
async def save_revision() -> None: async def save_revision() -> None:
candidate = (editor.value or "").strip() candidate = (editor.value or "").strip()
if not candidate: if not candidate:
@@ -120,7 +287,22 @@ def register_page() -> None: # noqa: PLR0915
ui.notify("Revision saved", type="positive") ui.notify("Revision saved", type="positive")
await render_revision_panel.refresh() await render_revision_panel.refresh()
with ui.row().classes("w-full justify-end"): with ui.row().classes("w-full justify-end gap-2"):
with ui.dialog() as delete_dialog, ui.card().classes("min-w-[22rem]"):
ui.label("Delete source").classes("text-subtitle1 text-weight-medium")
ui.label("This permanently deletes the source from this job context.").classes("text-body2")
ui.label("If related job history exists, deletion may be blocked.").classes(
"text-body2 vibe-text-muted"
)
with ui.row().classes("w-full justify-end gap-2"):
ui.button("Cancel", on_click=delete_dialog.close)
ui.button(
"Delete source",
on_click=confirm_delete_source,
icon="delete_forever",
).props('unelevated color="negative"')
ui.button("Delete source", on_click=delete_dialog.open, icon="delete").props("outline color=negative")
ui.button( ui.button(
"Create revision" if current_revision_text is None else "Update revision", "Create revision" if current_revision_text is None else "Update revision",
on_click=save_revision, on_click=save_revision,
@@ -138,6 +320,61 @@ def register_page() -> None: # noqa: PLR0915
await render_revision_panel() await render_revision_panel()
@ui.page("/jobs/{job_id}/delete")
async def job_delete_page(job_id: str, session_factory: SessionFactoryDep) -> None:
jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
try:
parsed_job_id = UUID(job_id)
except ValueError:
ui.label("Invalid job id").classes("text-h6 text-negative")
return
try:
job = await jobs_service.read_job(job_id=parsed_job_id)
except ValueError:
ui.label("Job not found").classes("text-h6 text-negative")
return
ui.label("Delete job").classes("text-h5 text-weight-medium")
ui.label(f"Job: {job.id}").classes("text-subtitle1")
if job.status == JobStatus.PROCESSING:
ui.label("Delete is blocked while the job is processing.").classes("text-negative text-weight-medium")
ui.label("Wait for processing to complete, then retry delete.").classes("text-body2 vibe-text-muted")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Back to Job", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back")
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history")
return
ui.label("This action permanently deletes the job.").classes("text-negative")
if job.job_sources:
ui.label("Related JobSource links will be removed as part of delete.").classes("text-body2")
async def submit_delete() -> None:
try:
await jobs_service.delete_job_with_guardrails(job_id=job.id)
except JobDeleteBlockedError as exc:
ui.notify(exc.message, type="warning")
return
except ValueError:
ui.notify("Job not found.", type="warning")
ui.navigate.to("/jobs")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Delete job failed", operation="jobs.delete")
return
ui.notify("Job deleted", type="positive")
ui.navigate.to("/jobs")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Delete job permanently", on_click=submit_delete, icon="delete_forever").props(
'unelevated color="negative"'
)
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back")
def _resolve_primary_source(job: Job) -> Source | None: def _resolve_primary_source(job: Job) -> Source | None:
if not job.job_sources: if not job.job_sources:
+356
View File
@@ -0,0 +1,356 @@
"""People list and detail page registration."""
from __future__ import annotations
from datetime import date
from uuid import UUID
from nicegui import ui
from transcription.db.models import Person
from transcription.errors import ErrorCategory
from transcription.services.documents import DocumentError
from transcription.services.documents import DocumentService
from transcription.services.documents import PersonDeleteBlockedError
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.error_presenter import show_error
from ...db.session import SessionFactoryDep
def _parse_optional_date(value: str | None, *, label: str) -> date | None:
candidate = (value or "").strip()
if not candidate:
return None
try:
return date.fromisoformat(candidate)
except ValueError as exc:
raise ValueError(f"{label} must use YYYY-MM-DD.") from exc
def register_page() -> None: # noqa: PLR0915
"""Register people list and CRUD routes."""
@ui.page("/people")
async def people_page(session_factory: SessionFactoryDep) -> None:
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
with ui.row().classes("w-full items-center justify-between"):
ui.label("People").classes("text-h5 text-weight-medium")
ui.button("Create new person", on_click=lambda: ui.navigate.to("/people/new"), icon="person_add").props(
'unelevated color="primary"'
)
try:
people = sorted(
await people_service.list_people(),
key=lambda item: item.created_at,
reverse=True,
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.list")
return
if not people:
ui.label("No people yet.").classes("text-body1 vibe-text-muted")
return
with ui.column().classes("w-full gap-2"):
for person in people:
with ui.card().classes("w-full"):
with ui.row().classes("w-full items-center justify-between"):
with ui.column().classes("gap-1"):
ui.label(person.full_name).classes("text-subtitle1 text-weight-medium")
ui.label(f"Display name: {person.display_name or 'not set'}").classes("text-body2")
ui.button(
"Open",
on_click=lambda _=None, person_id=person.id: ui.navigate.to(f"/people/{person_id}"),
icon="open_in_new",
).props("flat")
@ui.page("/people/new")
async def person_create_page(session_factory: SessionFactoryDep) -> None:
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
ui.label("Create person").classes("text-h5 text-weight-medium")
ui.label("Full name is required.").classes("text-body2 vibe-text-muted")
full_name_input = ui.input(label="Full name").props("outlined")
display_name_input = ui.input(label="Display name").props("outlined")
maiden_name_input = ui.input(label="Maiden name").props("outlined")
birth_date_input = ui.input(label="Birth date (YYYY-MM-DD)").props("outlined")
birth_date_raw_input = ui.input(label="Birth date (approximate/raw)").props("outlined")
birth_place_input = ui.input(label="Birth place").props("outlined")
death_date_input = ui.input(label="Death date (YYYY-MM-DD)").props("outlined")
death_date_raw_input = ui.input(label="Death date (approximate/raw)").props("outlined")
death_place_input = ui.input(label="Death place").props("outlined")
biography_input = ui.textarea(label="Biography").props("outlined autogrow")
portrait_path_input = ui.input(label="Portrait path").props("outlined")
async def submit_create() -> None:
full_name = (full_name_input.value or "").strip()
if not full_name:
ui.notify("Full name is required.", type="warning")
return
try:
birth_date = _parse_optional_date(birth_date_input.value, label="Birth date")
death_date = _parse_optional_date(death_date_input.value, label="Death date")
except ValueError as exc:
ui.notify(str(exc), type="warning")
return
candidate = Person(
full_name=full_name,
display_name=(display_name_input.value or "").strip() or None,
maiden_name=(maiden_name_input.value or "").strip() or None,
birth_date=birth_date,
birth_date_raw=(birth_date_raw_input.value or "").strip() or None,
birth_place=(birth_place_input.value or "").strip() or None,
death_date=death_date,
death_date_raw=(death_date_raw_input.value or "").strip() or None,
death_place=(death_place_input.value or "").strip() or None,
biography=(biography_input.value or "").strip() or None,
portrait_path=(portrait_path_input.value or "").strip() or None,
)
try:
created = await people_service.create_person(candidate)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Create failed", operation="people.create")
return
ui.notify("Person created", type="positive")
ui.navigate.to(f"/people/{created.id}")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Save person", on_click=submit_create, icon="save").props('unelevated color="primary"')
ui.button("Cancel", on_click=lambda: ui.navigate.to("/people"), icon="arrow_back")
@ui.page("/people/{person_id}")
async def person_detail_page(person_id: str, session_factory: SessionFactoryDep) -> None:
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
try:
parsed_person_id = UUID(person_id)
except ValueError:
ui.label("Invalid person id").classes("text-h6 text-negative")
return
try:
person = await people_service.read_person_detail(parsed_person_id)
except DocumentError:
ui.label("Person not found").classes("text-h6 text-negative")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.read")
return
ui.label(person.full_name).classes("text-h5 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Edit person", on_click=lambda: ui.navigate.to(f"/people/{person.id}/edit"), icon="edit").props(
'unelevated color="primary"'
)
ui.button(
"Delete person",
on_click=lambda: ui.navigate.to(f"/people/{person.id}/delete"),
icon="delete",
).props("outline color=negative")
with ui.column().classes("w-full gap-1"):
ui.label(f"Display name: {person.display_name or 'not set'}")
ui.label(f"Maiden name: {person.maiden_name or 'not set'}")
ui.label(f"Birth date: {person.birth_date.isoformat() if person.birth_date else 'not set'}")
ui.label(f"Birth date (approximate): {person.birth_date_raw or 'not set'}")
ui.label(f"Birth place: {person.birth_place or 'not set'}")
ui.label(f"Death date: {person.death_date.isoformat() if person.death_date else 'not set'}")
ui.label(f"Death date (approximate): {person.death_date_raw or 'not set'}")
ui.label(f"Death place: {person.death_place or 'not set'}")
ui.label(f"Biography: {person.biography or 'not set'}")
ui.label(f"Portrait path: {person.portrait_path or 'not set'}")
ui.label(f"Created at (read-only): {person.created_at.isoformat()}").classes("text-body2")
ui.label(f"Updated at (read-only): {person.updated_at.isoformat()}").classes("text-body2")
ui.separator()
ui.label("Linked documents").classes("text-subtitle1 text-weight-medium")
if not person.document_people:
ui.label("No linked documents yet.").classes("text-body2 vibe-text-muted")
ui.label("Link this person from a Document workflow.").classes("text-body2 vibe-text-muted")
return
with ui.column().classes("w-full gap-1"):
for link in person.document_people:
document = link.document
if document is None:
continue
with ui.row().classes("w-full items-center justify-between"):
ui.label(f"{document.name} ({link.role.value})").classes("text-body2")
ui.button(
"Open",
on_click=lambda _=None, document_id=document.id: ui.navigate.to(f"/documents/{document_id}"),
icon="open_in_new",
).props("flat")
@ui.page("/people/{person_id}/edit")
async def person_edit_page(person_id: str, session_factory: SessionFactoryDep) -> None:
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
try:
parsed_person_id = UUID(person_id)
except ValueError:
ui.label("Invalid person id").classes("text-h6 text-negative")
return
try:
person = await people_service.read_person_detail(parsed_person_id)
except DocumentError:
ui.label("Person not found").classes("text-h6 text-negative")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.edit.read")
return
ui.label("Edit person").classes("text-h5 text-weight-medium")
ui.label("Full name is required.").classes("text-body2 vibe-text-muted")
full_name_input = ui.input(label="Full name", value=person.full_name).props("outlined")
display_name_input = ui.input(label="Display name", value=person.display_name or "").props("outlined")
maiden_name_input = ui.input(label="Maiden name", value=person.maiden_name or "").props("outlined")
birth_date_input = ui.input(
label="Birth date (YYYY-MM-DD)",
value=person.birth_date.isoformat() if person.birth_date else "",
).props("outlined")
birth_date_raw_input = ui.input(label="Birth date (approximate/raw)", value=person.birth_date_raw or "").props(
"outlined"
)
birth_place_input = ui.input(label="Birth place", value=person.birth_place or "").props("outlined")
death_date_input = ui.input(
label="Death date (YYYY-MM-DD)",
value=person.death_date.isoformat() if person.death_date else "",
).props("outlined")
death_date_raw_input = ui.input(label="Death date (approximate/raw)", value=person.death_date_raw or "").props(
"outlined"
)
death_place_input = ui.input(label="Death place", value=person.death_place or "").props("outlined")
biography_input = ui.textarea(label="Biography", value=person.biography or "").props("outlined autogrow")
portrait_path_input = ui.input(label="Portrait path", value=person.portrait_path or "").props("outlined")
async def submit_edit() -> None:
full_name = (full_name_input.value or "").strip()
if not full_name:
ui.notify("Full name is required.", type="warning")
return
try:
birth_date = _parse_optional_date(birth_date_input.value, label="Birth date")
death_date = _parse_optional_date(death_date_input.value, label="Death date")
except ValueError as exc:
ui.notify(str(exc), type="warning")
return
candidate = Person(
id=person.id,
full_name=full_name,
display_name=(display_name_input.value or "").strip() or None,
maiden_name=(maiden_name_input.value or "").strip() or None,
birth_date=birth_date,
birth_date_raw=(birth_date_raw_input.value or "").strip() or None,
birth_place=(birth_place_input.value or "").strip() or None,
death_date=death_date,
death_date_raw=(death_date_raw_input.value or "").strip() or None,
death_place=(death_place_input.value or "").strip() or None,
biography=(biography_input.value or "").strip() or None,
portrait_path=(portrait_path_input.value or "").strip() or None,
metadata_=person.metadata_,
created_at=person.created_at,
updated_at=person.updated_at,
)
try:
await people_service.update_person(candidate)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Save failed", operation="people.edit.save")
return
ui.notify("Person updated", type="positive")
ui.navigate.to(f"/people/{person.id}")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Save changes", on_click=submit_edit, icon="save").props('unelevated color="primary"')
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back")
@ui.page("/people/{person_id}/delete")
async def person_delete_page(person_id: str, session_factory: SessionFactoryDep) -> None:
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
try:
parsed_person_id = UUID(person_id)
except ValueError:
ui.label("Invalid person id").classes("text-h6 text-negative")
return
try:
person = await people_service.read_person_detail(parsed_person_id)
except DocumentError:
ui.label("Person not found").classes("text-h6 text-negative")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.delete.read")
return
ui.label("Delete person").classes("text-h5 text-weight-medium")
ui.label(f"Person: {person.full_name}").classes("text-subtitle1")
if person.document_people:
ui.label("Delete is blocked because linked documents exist.").classes("text-negative text-weight-medium")
ui.label(f"Linked documents: {len(person.document_people)}").classes("text-body2")
ui.label("Remove document links first, then retry deletion.").classes("text-body2 vibe-text-muted")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Back to Person", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back")
ui.button("Go to Documents", on_click=lambda: ui.navigate.to("/documents"), icon="description")
return
ui.label("This action permanently deletes the person.").classes("text-negative")
async def submit_delete() -> None:
try:
await people_service.delete_person(person)
except PersonDeleteBlockedError as exc:
ui.notify(exc.message, type="warning")
ui.navigate.to(f"/people/{person.id}/delete")
return
except DocumentError as exc:
if exc.category == ErrorCategory.NOT_FOUND:
ui.notify("Person not found.", type="warning")
ui.navigate.to("/people")
return
show_error(exc, title="Delete failed", operation="people.delete")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Delete failed", operation="people.delete")
return
ui.notify("Person deleted", type="positive")
ui.navigate.to("/people")
with ui.row().classes("w-full items-center gap-2"):
ui.button(
"Delete person permanently",
on_click=submit_delete,
icon="delete_forever",
).props('unelevated color="negative"')
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back")
+5 -16
View File
@@ -3,29 +3,18 @@
from __future__ import annotations from __future__ import annotations
from fastapi import Request from fastapi import Request
from fastapi.responses import RedirectResponse
from starlette import status
from nicegui import ui from nicegui import ui
from transcription.db import session_scope
from transcription.services.store import create_upload_job
from transcription.ui.components.app_shell import render_navigation_header from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.upload import render_upload_widget
from transcription.worker import resolve_worker_notifier
def register_page() -> None: def register_page() -> None:
"""Register the upload page route.""" """Register the upload page route."""
@ui.page("/upload", title="Upload Document") @ui.page("/upload", title="Upload Document")
def upload_page(request: Request) -> None: def upload_page(request: Request) -> RedirectResponse:
_ = request
render_navigation_header(current_path="/upload") render_navigation_header(current_path="/upload")
return RedirectResponse(url="/ui/jobs/new", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
async def submit_upload(filename: str, file_bytes: bytes):
async with session_scope() as session:
return await create_upload_job(
filename=filename,
file_bytes=file_bytes,
session=session,
)
notify_worker = resolve_worker_notifier(request.app.state)
render_upload_widget(submitter=submit_upload, notifier=notify_worker)
+188
View File
@@ -0,0 +1,188 @@
from __future__ import annotations
from datetime import UTC
from datetime import datetime
from uuid import uuid4
import pytest
from transcription.db.models import Document
from transcription.db.models import Job
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import Person
from transcription.db.models import Source
from transcription.services.documents import DocumentDeleteBlockedError
from transcription.services.documents import DocumentError
from transcription.services.documents import PersonDeleteBlockedError
from transcription.services.documents import DocumentService
@pytest.mark.asyncio
async def test_read_document_detail_allows_missing_sources(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
created = await service.create_document(
Document(
id=uuid4(),
name="detail-doc",
document_type="letter",
)
)
detail = await service.read_document_detail(created.id)
assert detail.id == created.id
assert detail.sources == []
@pytest.mark.asyncio
async def test_update_document_refreshes_updated_timestamp(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
created = await service.create_document(
Document(
id=uuid4(),
name="timestamp-doc",
document_type="letter",
updated_at=datetime(2000, 1, 1, tzinfo=UTC),
)
)
original_updated_at = created.updated_at
created.notes = "updated"
updated = await service.update_document(created)
assert updated.notes == "updated"
assert updated.updated_at >= original_updated_at
@pytest.mark.asyncio
async def test_delete_document_blocks_when_dependencies_exist(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
document = await service.create_document(
Document(
id=uuid4(),
name="blocked-delete",
document_type="record",
)
)
async with service._session_scope() as session:
session.add(
Source(
document_id=document.id,
page_number=1,
upload_name="001_page.png",
filename="001_page.png",
file_path="uploads/001_page.png",
)
)
session.add(Job(document_id=document.id))
await session.commit()
with pytest.raises(DocumentDeleteBlockedError):
await service.delete_document(document)
@pytest.mark.asyncio
async def test_delete_document_succeeds_when_unlinked(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
document = await service.create_document(
Document(
id=uuid4(),
name="free-delete",
document_type="memo",
)
)
await service.delete_document(document)
with pytest.raises(DocumentError):
await service.read_document_detail(document.id)
@pytest.mark.asyncio
async def test_read_person_detail_loads_document_links(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
document = await service.create_document(
Document(
id=uuid4(),
name="linked-doc",
document_type="letter",
)
)
person = await service.create_person(Person(full_name="Linked Person"))
await service.create_document_person(
DocumentPerson(
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
)
)
detail = await service.read_person_detail(person.id)
assert detail.id == person.id
assert len(detail.document_people) == 1
assert detail.document_people[0].document is not None
assert detail.document_people[0].document.name == "linked-doc"
@pytest.mark.asyncio
async def test_update_person_refreshes_updated_timestamp(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
created = await service.create_person(
Person(
full_name="timestamp-person",
updated_at=datetime(2000, 1, 1, tzinfo=UTC),
)
)
original_updated_at = created.updated_at
created.display_name = "updated"
updated = await service.update_person(created)
assert updated.display_name == "updated"
assert updated.updated_at >= original_updated_at
@pytest.mark.asyncio
async def test_delete_person_blocks_when_linked_documents_exist(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
document = await service.create_document(
Document(
id=uuid4(),
name="block-person-delete-doc",
document_type="record",
)
)
person = await service.create_person(Person(full_name="Blocked Person"))
await service.create_document_person(
DocumentPerson(
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
)
)
with pytest.raises(PersonDeleteBlockedError):
await service.delete_person(person)
@pytest.mark.asyncio
async def test_delete_person_succeeds_when_unlinked(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
person = await service.create_person(Person(full_name="Free Person"))
await service.delete_person(person)
with pytest.raises(DocumentError):
await service.read_person_detail(person.id)
+109
View File
@@ -9,6 +9,7 @@ from transcription.db.models import JobSourceStatus
from transcription.db.models import JobStatus from transcription.db.models import JobStatus
from transcription.db.models import Source from transcription.db.models import Source
from transcription.services.documents import DocumentService from transcription.services.documents import DocumentService
from transcription.services.jobs import JobDeleteBlockedError
from transcription.services.jobs import JobService from transcription.services.jobs import JobService
@@ -107,3 +108,111 @@ class TestJobService:
next_job = await job_service.read_next_queued_job() next_job = await job_service.read_next_queued_job()
assert next_job is not None assert next_job is not None
assert next_job.id == first.id assert next_job.id == first.id
@pytest.mark.asyncio
async def test_create_job_persists_provider_model_prompt(
self,
job_service: JobService,
document_service: DocumentService,
):
document = Document(id=uuid4(), name="provider-doc")
await document_service.create_document(document=document)
job = Job(
document_id=document.id,
provider="openrouter",
model="google/gemini-2.5-flash",
prompt_name="transcribe_document.md",
)
await job_service.create_job(job=job)
fetched = await job_service.read_job(job_id=job.id)
assert fetched.provider == "openrouter"
assert fetched.model == "google/gemini-2.5-flash"
assert fetched.prompt_name == "transcribe_document.md"
@pytest.mark.asyncio
async def test_read_job_resolves_filename_from_linked_source(
self,
job_service: JobService,
document_service: DocumentService,
):
document = Document(id=uuid4(), name="filename-doc")
await document_service.create_document(document=document)
job = Job(document_id=document.id)
await job_service.create_job(job=job)
async with job_service._session_scope() as session:
source = Source(
document_id=document.id,
page_number=1,
upload_name="page_001.png",
filename="stored_page_001.png",
file_path="/uploads/stored_page_001.png",
)
session.add(source)
await session.flush()
session.add(
JobSource(
job_id=job.id,
source_id=source.id,
status=JobSourceStatus.PENDING,
)
)
await session.commit()
fetched = await job_service.read_job(job_id=job.id)
assert fetched.filename == "stored_page_001.png"
@pytest.mark.asyncio
async def test_delete_job_with_guardrails_blocks_processing_jobs(
self,
job_service: JobService,
document_service: DocumentService,
):
document = Document(id=uuid4(), name="processing-delete-doc")
await document_service.create_document(document=document)
job = Job(document_id=document.id, status=JobStatus.PROCESSING)
await job_service.create_job(job=job)
with pytest.raises(JobDeleteBlockedError):
await job_service.delete_job_with_guardrails(job_id=job.id)
@pytest.mark.asyncio
async def test_delete_job_with_guardrails_removes_jobsource_links(
self,
job_service: JobService,
document_service: DocumentService,
):
document = Document(id=uuid4(), name="delete-job-doc")
await document_service.create_document(document=document)
job = Job(document_id=document.id, status=JobStatus.QUEUED)
await job_service.create_job(job=job)
async with job_service._session_scope() as session:
source = Source(
document_id=document.id,
page_number=1,
upload_name="delete-job-source.jpg",
filename="stored-delete-job-source.jpg",
file_path="/uploads/stored-delete-job-source.jpg",
)
session.add(source)
await session.flush()
session.add(
JobSource(
job_id=job.id,
source_id=source.id,
status=JobSourceStatus.PENDING,
)
)
await session.commit()
await job_service.delete_job_with_guardrails(job_id=job.id)
with pytest.raises(ValueError):
await job_service.read_job(job_id=job.id)
+72
View File
@@ -0,0 +1,72 @@
from uuid import uuid4
import pytest
from sqlmodel import select
from transcription.config import Settings
from transcription.db.models import Document
from transcription.db.models import Job
from transcription.db.models import JobSource
from transcription.db.models import Source
from transcription.services.store import UploadError
from transcription.services.store import create_job_for_document
@pytest.mark.asyncio
async def test_create_job_for_document_requires_at_least_one_upload(async_session, tmp_path):
document = Document(id=uuid4(), name="needs-upload")
async_session.add(document)
await async_session.commit()
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
with pytest.raises(UploadError):
await create_job_for_document(
document_id=document.id,
uploads=[],
session=async_session,
settings=settings,
)
@pytest.mark.asyncio
async def test_create_job_for_document_sorts_uploads_and_creates_links(async_session, tmp_path):
document = Document(id=uuid4(), name="ordered-upload-doc")
async_session.add(document)
await async_session.commit()
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
result = await create_job_for_document(
document_id=document.id,
uploads=[
("folder/b_page.pdf", b"b"),
("folder/A_page.pdf", b"a"),
],
provider="openrouter",
model="test-model",
prompt_name="transcribe_document.md",
session=async_session,
settings=settings,
)
created_job = await async_session.get(Job, result.job_id)
assert created_job is not None
assert created_job.provider == "openrouter"
assert created_job.model == "test-model"
assert created_job.prompt_name == "transcribe_document.md"
sources = (
await async_session.exec(
select(Source)
.where(Source.document_id == document.id)
.order_by(Source.page_number) # pyright: ignore[reportArgumentType]
)
).all()
assert [source.upload_name for source in sources] == ["A_page.pdf", "b_page.pdf"]
assert all(source.filename.endswith(".pdf") for source in sources)
assert all("A_page" not in source.filename and "b_page" not in source.filename for source in sources)
job_sources = (await async_session.exec(select(JobSource).where(JobSource.job_id == result.job_id))).all()
assert len(job_sources) == 2
assert set(result.source_ids) == {job_source.source_id for job_source in job_sources}
@@ -12,6 +12,8 @@ from transcription.db.models import JobStatus
from transcription.db.models import Source from transcription.db.models import Source
from transcription.services.documents import DocumentService from transcription.services.documents import DocumentService
from transcription.services.jobs import JobService from transcription.services.jobs import JobService
from transcription.services.transcription import SourceDeleteBlockedError
from transcription.services.transcription import TranscriptionNotFoundError
from transcription.services.transcription import TranscriptionService from transcription.services.transcription import TranscriptionService
@@ -89,3 +91,66 @@ class TestTranscriptionServiceRevisionUpsert:
assert len(revisions) == 1 assert len(revisions) == 1
assert revisions[0].id == first.id assert revisions[0].id == first.id
assert revisions[0].revised_text == "Revision v2" assert revisions[0].revised_text == "Revision v2"
@pytest.mark.asyncio
async def test_delete_source_from_job_context_removes_source_and_single_link(self, default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
jobs = JobService(session_factory=default_session_factory)
transcriptions = TranscriptionService(session_factory=default_session_factory)
document = Document(id=uuid4(), name="delete-source-success")
await documents.create_document(document=document)
job = Job(document_id=document.id, status=JobStatus.QUEUED)
await jobs.create_job(job=job)
source = Source(
document_id=document.id,
page_number=1,
upload_name="delete.jpg",
filename="delete.jpg",
file_path="uploads/delete.jpg",
)
async with transcriptions._session_scope() as session:
session.add(source)
await session.flush()
session.add(JobSource(job_id=job.id, source_id=source.id, status=JobSourceStatus.PENDING))
await session.commit()
await session.refresh(source)
await transcriptions.delete_source_from_job_context(job_id=job.id, source_id=source.id)
with pytest.raises(TranscriptionNotFoundError):
await transcriptions.read_source(source.id)
@pytest.mark.asyncio
async def test_delete_source_from_job_context_blocks_when_other_job_links_exist(self, default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
jobs = JobService(session_factory=default_session_factory)
transcriptions = TranscriptionService(session_factory=default_session_factory)
document = Document(id=uuid4(), name="delete-source-blocked")
await documents.create_document(document=document)
job_one = Job(document_id=document.id, status=JobStatus.QUEUED)
job_two = Job(document_id=document.id, status=JobStatus.QUEUED)
await jobs.create_job(job=job_one)
await jobs.create_job(job=job_two)
source = Source(
document_id=document.id,
page_number=1,
upload_name="shared.jpg",
filename="shared.jpg",
file_path="uploads/shared.jpg",
)
async with transcriptions._session_scope() as session:
session.add(source)
await session.flush()
session.add(JobSource(job_id=job_one.id, source_id=source.id, status=JobSourceStatus.PENDING))
session.add(JobSource(job_id=job_two.id, source_id=source.id, status=JobSourceStatus.PENDING))
await session.commit()
await session.refresh(source)
with pytest.raises(SourceDeleteBlockedError):
await transcriptions.delete_source_from_job_context(job_id=job_one.id, source_id=source.id)
+88
View File
@@ -10,8 +10,10 @@ from transcription.db.models import JobSource
from transcription.db.models import JobSourceStatus from transcription.db.models import JobSourceStatus
from transcription.db.models import Person from transcription.db.models import Person
from transcription.db.models import Source from transcription.db.models import Source
from transcription.services.documents import DocumentDeleteBlockedError
from transcription.services.documents import DocumentService from transcription.services.documents import DocumentService
from transcription.services.jobs import JobService from transcription.services.jobs import JobService
from transcription.services.transcription import SourceDeleteBlockedError
from transcription.services.transcription import TranscriptionService from transcription.services.transcription import TranscriptionService
@@ -123,3 +125,89 @@ async def test_transcription_service_job_source_crud_uses_caller_session(default
await session.commit() await session.commit()
assert len(await transcriptions.list_job_sources(job_id=job.id)) == 0 assert len(await transcriptions.list_job_sources(job_id=job.id)) == 0
@pytest.mark.asyncio
async def test_document_detail_loads_linked_person_relationship(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
document = await documents.create_document(Document(id=uuid4(), name="detail-person-doc"))
person = await documents.create_person(Person(full_name="Grace Hopper"))
await documents.create_document_person(
DocumentPerson(
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
)
)
detail = await documents.read_document_detail(document.id)
assert len(detail.document_people) == 1
link = detail.document_people[0]
assert link.person is not None
assert link.person.full_name == "Grace Hopper"
assert link.role == DocumentPersonRole.AUTHOR
@pytest.mark.asyncio
async def test_document_delete_is_blocked_with_source_and_job_dependencies(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
jobs = JobService(session_factory=default_session_factory)
transcriptions = TranscriptionService(session_factory=default_session_factory)
document = await documents.create_document(Document(id=uuid4(), name="blocked-by-deps"))
job = await jobs.create_job(Job(document_id=document.id))
source = await transcriptions.create_source(
Source(
document_id=document.id,
page_number=1,
upload_name="blocked.jpg",
filename="blocked.jpg",
file_path="uploads/blocked.jpg",
)
)
await transcriptions.create_job_source(
JobSource(
job_id=job.id,
source_id=source.id,
status=JobSourceStatus.PENDING,
)
)
with pytest.raises(DocumentDeleteBlockedError) as exc_info:
await documents.delete_document(document)
message = exc_info.value.message
assert "Sources" in message
assert "Jobs" in message
@pytest.mark.asyncio
async def test_source_delete_blocks_when_linked_to_multiple_jobs(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
jobs = JobService(session_factory=default_session_factory)
transcriptions = TranscriptionService(session_factory=default_session_factory)
document = await documents.create_document(Document(id=uuid4(), name="multi-job-source-doc"))
job_one = await jobs.create_job(Job(document_id=document.id))
job_two = await jobs.create_job(Job(document_id=document.id))
source = await transcriptions.create_source(
Source(
document_id=document.id,
page_number=1,
upload_name="shared-page.jpg",
filename="shared-page.jpg",
file_path="uploads/shared-page.jpg",
)
)
await transcriptions.create_job_source(
JobSource(job_id=job_one.id, source_id=source.id, status=JobSourceStatus.PENDING)
)
await transcriptions.create_job_source(
JobSource(job_id=job_two.id, source_id=source.id, status=JobSourceStatus.PENDING)
)
with pytest.raises(SourceDeleteBlockedError):
await transcriptions.delete_source_from_job_context(job_id=job_one.id, source_id=source.id)
+8 -3
View File
@@ -71,13 +71,18 @@ class TestProviderSettings:
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
_make_settings(provider="not-a-provider") _make_settings(provider="not-a-provider")
def test_optional_fields_default_to_none(self): def test_optional_provider_header_fields_default_to_none(self):
"""provider_model, openrouter_http_referer, and openrouter_app_title are None when unset.""" """openrouter_http_referer and openrouter_app_title are None when unset."""
settings = _make_settings() settings = _make_settings()
assert settings.provider_model is None
assert settings.openrouter_http_referer is None assert settings.openrouter_http_referer is None
assert settings.openrouter_app_title is None assert settings.openrouter_app_title is None
def test_provider_model_accepts_env_default(self, monkeypatch):
"""provider_model is sourced when provided through environment configuration."""
monkeypatch.setenv("PROVIDER_MODEL", "google/gemini-2.5-flash")
settings = Settings(openrouter_api_key="test-key-abc123")
assert settings.provider_model == "google/gemini-2.5-flash"
class TestPathSettings: class TestPathSettings:
"""Verify filesystem path field types.""" """Verify filesystem path field types."""
+5
View File
@@ -22,10 +22,12 @@ from transcription.db import create_all
from transcription.db import initialize_database_runtime from transcription.db import initialize_database_runtime
from transcription.db import session_scope from transcription.db import session_scope
from transcription.db.models import Document from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import Job from transcription.db.models import Job
from transcription.db.models import JobSource from transcription.db.models import JobSource
from transcription.db.models import JobSourceStatus from transcription.db.models import JobSourceStatus
from transcription.db.models import JobStatus from transcription.db.models import JobStatus
from transcription.db.models import Person
from transcription.db.models import Source from transcription.db.models import Source
RevisionSeed = str RevisionSeed = str
@@ -58,9 +60,12 @@ def clear_ui_database(app_client: tuple[FastAPI, TestClient]) -> None:
async def _clear() -> None: async def _clear() -> None:
async with session_scope() as session: async with session_scope() as session:
await session.exec(delete(JobSource))
await session.exec(delete(DocumentPerson))
await session.exec(delete(Source)) await session.exec(delete(Source))
await session.exec(delete(Job)) await session.exec(delete(Job))
await session.exec(delete(Document)) await session.exec(delete(Document))
await session.exec(delete(Person))
await session.commit() await session.commit()
asyncio.run(_clear()) asyncio.run(_clear())
+331
View File
@@ -0,0 +1,331 @@
"""Tests for the documents page routes."""
import asyncio
from datetime import UTC
from datetime import date
from datetime import datetime
from uuid import uuid4
import pytest
from transcription.db import session_scope
from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import Job
from transcription.db.models import Person
from transcription.db.models import Source
@pytest.mark.integration
class TestDocumentsPageRendering:
"""Verify document list/detail routes render expected read states."""
def test_documents_page_renders_empty_state(self, app_client):
"""GET /ui/documents renders empty-state text when no records exist."""
_, client = app_client
response = client.get("/ui/documents")
assert response.status_code == 200
assert "Documents" in response.text
assert "No documents yet." in response.text
def test_documents_page_lists_seeded_documents(self, app_client):
"""GET /ui/documents lists seeded document cards."""
_, client = app_client
async def _seed_document() -> None:
async with session_scope() as session:
session.add(Document(name="Seeded Document", document_type="letter"))
await session.commit()
asyncio.run(_seed_document())
response = client.get("/ui/documents")
assert response.status_code == 200
assert "Seeded Document" in response.text
assert "Type: letter" in response.text
def test_document_detail_page_renders_metadata_and_empty_related_sections(self, app_client):
"""GET /ui/documents/{document_id} shows metadata and related empty states."""
_, client = app_client
async def _seed_document() -> str:
async with session_scope() as session:
document = Document(
name="Zenna Letter",
document_type="letter",
document_date=date(1885, 7, 13),
document_date_raw="c. 1885",
location_created="Ohio",
notes="Family archive",
archive_identifier="BOX-1-FOLDER-2",
)
session.add(document)
await session.commit()
await session.refresh(document)
return str(document.id)
document_id = asyncio.run(_seed_document())
response = client.get(f"/ui/documents/{document_id}")
assert response.status_code == 200
assert "Zenna Letter" in response.text
assert "Document type: letter" in response.text
assert "Exact date: 1885-07-13" in response.text
assert "Approximate date: c. 1885" in response.text
assert "Location created: Ohio" in response.text
assert "Archive identifier: BOX-1-FOLDER-2" in response.text
assert "Notes: Family archive" in response.text
assert "Created at (read-only):" in response.text
assert "Updated at (read-only):" in response.text
assert "No linked people yet." in response.text
assert "No sources added yet." in response.text
assert "No jobs created yet." in response.text
assert "Add sources" in response.text
assert "Create job" in response.text
assert "View sources" in response.text
assert "View jobs" in response.text
assert "Edit document" in response.text
assert "Delete document" in response.text
def test_document_detail_page_renders_related_people_sources_and_jobs(self, app_client):
"""GET /ui/documents/{document_id} shows related records when present."""
_, client = app_client
async def _seed_related() -> str:
async with session_scope() as session:
document = Document(name="Roster", document_type="record")
person = Person(full_name="Jane Doe")
session.add(document)
session.add(person)
await session.flush()
session.add(
DocumentPerson(
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
)
)
session.add(
Source(
document_id=document.id,
page_number=1,
upload_name="001_page.png",
filename="stored_001_page.png",
file_path="/tmp/stored_001_page.png",
)
)
session.add(
Job(
document_id=document.id,
)
)
await session.commit()
await session.refresh(document)
return str(document.id)
document_id = asyncio.run(_seed_related())
response = client.get(f"/ui/documents/{document_id}")
assert response.status_code == 200
assert "Jane Doe (author)" in response.text
assert "Page 1: 001_page.png" in response.text
assert "queued -" in response.text
def test_document_jobs_page_filters_to_document_context(self, app_client):
_, client = app_client
async def _seed() -> str:
async with session_scope() as session:
target = Document(name="Target", document_type="letter")
other = Document(name="Other", document_type="record")
session.add(target)
session.add(other)
await session.flush()
session.add(Job(document_id=target.id))
session.add(Job(document_id=other.id))
await session.commit()
await session.refresh(target)
return str(target.id)
document_id = asyncio.run(_seed())
response = client.get(f"/ui/documents/{document_id}/jobs")
assert response.status_code == 200
assert "Jobs for Target" in response.text
assert "Jobs for Other" not in response.text
def test_document_sources_page_filters_to_document_context(self, app_client):
_, client = app_client
async def _seed() -> str:
async with session_scope() as session:
target = Document(name="Target", document_type="letter")
other = Document(name="Other", document_type="record")
session.add(target)
session.add(other)
await session.flush()
session.add(
Source(
document_id=target.id,
page_number=1,
upload_name="target_page.png",
filename="target_stored.png",
file_path="/tmp/target_stored.png",
)
)
session.add(
Source(
document_id=other.id,
page_number=1,
upload_name="other_page.png",
filename="other_stored.png",
file_path="/tmp/other_stored.png",
)
)
await session.commit()
await session.refresh(target)
return str(target.id)
document_id = asyncio.run(_seed())
response = client.get(f"/ui/documents/{document_id}/sources")
assert response.status_code == 200
assert "Sources for Target" in response.text
assert "target_page.png" in response.text
assert "other_page.png" not in response.text
def test_document_detail_page_rejects_invalid_id(self, app_client):
"""GET /ui/documents/{document_id} shows validation feedback for malformed IDs."""
_, client = app_client
response = client.get("/ui/documents/not-a-uuid")
assert response.status_code == 200
assert "Invalid document id" in response.text
def test_document_detail_page_handles_missing_document(self, app_client):
"""GET /ui/documents/{document_id} shows not-found state for unknown IDs."""
_, client = app_client
response = client.get(f"/ui/documents/{uuid4()}")
assert response.status_code == 200
assert "Document not found" in response.text
def test_document_edit_page_renders_expected_fields(self, app_client):
"""GET /ui/documents/{document_id}/edit renders editable fields and save controls."""
_, client = app_client
async def _seed_document() -> str:
async with session_scope() as session:
document = Document(
name="Editable Document",
document_type="memo",
document_date_raw="c. 1900",
)
session.add(document)
await session.commit()
await session.refresh(document)
return str(document.id)
document_id = asyncio.run(_seed_document())
response = client.get(f"/ui/documents/{document_id}/edit")
assert response.status_code == 200
assert "Edit document" in response.text
assert "Document name and document type are required." in response.text
assert "Document name" in response.text
assert "Document type" in response.text
assert "Exact date (YYYY-MM-DD)" in response.text
assert "Approximate date" in response.text
assert "Document location" in response.text
assert "Archive identifier" in response.text
assert "Notes" in response.text
assert "Save changes" in response.text
def test_document_delete_page_shows_confirmation_when_unlinked(self, app_client):
"""GET /ui/documents/{document_id}/delete renders permanent-action confirmation if unlinked."""
_, client = app_client
async def _seed_document() -> str:
async with session_scope() as session:
document = Document(name="Safe Delete", document_type="letter")
session.add(document)
await session.commit()
await session.refresh(document)
return str(document.id)
document_id = asyncio.run(_seed_document())
response = client.get(f"/ui/documents/{document_id}/delete")
assert response.status_code == 200
assert "Delete document" in response.text
assert "This action permanently deletes the document." in response.text
assert "Delete document permanently" in response.text
def test_document_delete_page_shows_blocked_state_when_dependencies_exist(self, app_client):
"""GET /ui/documents/{document_id}/delete explains blocked deletion with dependency categories."""
_, client = app_client
async def _seed_related() -> str:
async with session_scope() as session:
document = Document(name="Blocked Delete", document_type="record")
session.add(document)
await session.flush()
session.add(
Source(
document_id=document.id,
page_number=1,
upload_name="001_page.png",
filename="stored_001_page.png",
file_path="/tmp/stored_001_page.png",
)
)
session.add(Job(document_id=document.id))
await session.commit()
await session.refresh(document)
return str(document.id)
document_id = asyncio.run(_seed_related())
response = client.get(f"/ui/documents/{document_id}/delete")
assert response.status_code == 200
assert "Delete is blocked because related records exist." in response.text
assert "Dependencies present: Sources, Jobs" in response.text
assert "Go to Jobs" in response.text
def test_job_create_page_preselects_document_query_param(self, app_client):
"""GET /ui/jobs/new?document_id=... includes the selected document in rendered state."""
_, client = app_client
async def _seed_document() -> str:
async with session_scope() as session:
document = Document(
name="Preselected Document",
document_type="letter",
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add(document)
await session.commit()
await session.refresh(document)
return str(document.id)
document_id = asyncio.run(_seed_document())
response = client.get(f"/ui/jobs/new?document_id={document_id}")
assert response.status_code == 200
assert "Preselected Document" in response.text
+63
View File
@@ -1,10 +1,13 @@
"""Tests for the jobs page route.""" """Tests for the jobs page route."""
import asyncio
from pathlib import Path from pathlib import Path
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
from transcription.db import session_scope
from transcription.db.models import Document
from transcription.db.models import JobStatus from transcription.db.models import JobStatus
@@ -18,8 +21,39 @@ class TestPageRendering:
response = client.get("/ui/jobs") response = client.get("/ui/jobs")
assert response.status_code == 200 assert response.status_code == 200
assert "Create job" in response.text
assert "No jobs yet." in response.text assert "No jobs yet." in response.text
def test_job_create_page_requires_existing_documents(self, app_client):
"""GET /ui/jobs/new shows guidance when no Documents exist."""
_, client = app_client
response = client.get("/ui/jobs/new")
assert response.status_code == 200
assert "Create job" in response.text
assert "No documents available. Create a Document before creating a Job." in response.text
def test_job_create_page_lists_available_documents(self, app_client):
"""GET /ui/jobs/new renders document choices when Documents exist."""
_, client = app_client
async def _seed_document() -> None:
async with session_scope() as session:
session.add(Document(name="Seeded Document"))
await session.commit()
asyncio.run(_seed_document())
response = client.get("/ui/jobs/new")
assert response.status_code == 200
assert "Create job" in response.text
assert "Seeded Document" in response.text
assert "Files are processed alphabetically by original filename." in response.text
assert "No files uploaded yet." in response.text
assert "Upload folder" in response.text
def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job): def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job):
"""GET /ui/jobs lists seeded jobs from the in-memory database.""" """GET /ui/jobs lists seeded jobs from the in-memory database."""
_, client = app_client _, client = app_client
@@ -54,6 +88,14 @@ class TestPageRendering:
assert "original text" in response.text assert "original text" in response.text
assert "Document preview" in response.text assert "Document preview" in response.text
assert "/uploads/detail.pdf" in response.text assert "/uploads/detail.pdf" in response.text
assert "Provider:" in response.text
assert "Model:" in response.text
assert "Prompt:" in response.text
assert "Retry count:" in response.text
assert "Last updated:" in response.text
assert "Delete job" in response.text
assert "Delete source" in response.text
assert "This permanently deletes the source from this job context." in response.text
def test_job_detail_page_rejects_invalid_id(self, app_client): def test_job_detail_page_rejects_invalid_id(self, app_client):
"""GET /ui/jobs/{job_id} shows validation feedback for malformed IDs.""" """GET /ui/jobs/{job_id} shows validation feedback for malformed IDs."""
@@ -105,3 +147,24 @@ class TestPageRendering:
assert "Revision Editor" in response.text assert "Revision Editor" in response.text
assert "Update revision" in response.text assert "Update revision" in response.text
assert "hello" in response.text assert "hello" in response.text
def test_job_delete_page_shows_confirmation_when_not_processing(self, app_client, seed_job):
_, client = app_client
job_id = seed_job(filename="delete-ready.pdf", status=JobStatus.TRANSCRIBED)
response = client.get(f"/ui/jobs/{job_id}/delete")
assert response.status_code == 200
assert "Delete job" in response.text
assert "This action permanently deletes the job." in response.text
assert "Delete job permanently" in response.text
def test_job_delete_page_shows_blocked_state_when_processing(self, app_client, seed_job):
_, client = app_client
job_id = seed_job(filename="delete-blocked.pdf", status=JobStatus.PROCESSING)
response = client.get(f"/ui/jobs/{job_id}/delete")
assert response.status_code == 200
assert "Delete is blocked while the job is processing." in response.text
assert "Wait for processing to complete, then retry delete." in response.text
+6 -2
View File
@@ -11,8 +11,12 @@ class TestPageRegistration:
"""Mounted UI routes respond successfully when the full app is created.""" """Mounted UI routes respond successfully when the full app is created."""
_, client = app_client _, client = app_client
upload_response = client.get("/ui/upload") upload_response = client.get("/ui/upload", follow_redirects=False)
documents_response = client.get("/ui/documents")
people_response = client.get("/ui/people")
jobs_response = client.get("/ui/jobs") jobs_response = client.get("/ui/jobs")
assert upload_response.status_code == 200 assert upload_response.status_code == 307
assert documents_response.status_code == 200
assert people_response.status_code == 200
assert jobs_response.status_code == 200 assert jobs_response.status_code == 200
+214
View File
@@ -0,0 +1,214 @@
"""Tests for the people page routes."""
import asyncio
from datetime import date
from uuid import uuid4
import pytest
from transcription.db import session_scope
from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import Person
@pytest.mark.integration
class TestPeoplePageRendering:
"""Verify people routes render expected CRUD read states."""
def test_people_page_renders_empty_state(self, app_client):
_, client = app_client
response = client.get("/ui/people")
assert response.status_code == 200
assert "People" in response.text
assert "Create new person" in response.text
assert "No people yet." in response.text
def test_people_page_lists_seeded_people(self, app_client):
_, client = app_client
async def _seed_person() -> None:
async with session_scope() as session:
session.add(Person(full_name="Ada Lovelace", display_name="Ada"))
await session.commit()
asyncio.run(_seed_person())
response = client.get("/ui/people")
assert response.status_code == 200
assert "Ada Lovelace" in response.text
assert "Display name: Ada" in response.text
def test_person_create_page_renders_fields(self, app_client):
_, client = app_client
response = client.get("/ui/people/new")
assert response.status_code == 200
assert "Create person" in response.text
assert "Full name is required." in response.text
assert "Birth date (YYYY-MM-DD)" in response.text
assert "Death date (YYYY-MM-DD)" in response.text
assert "Biography" in response.text
assert "Save person" in response.text
def test_person_detail_page_renders_metadata_and_empty_links(self, app_client):
_, client = app_client
async def _seed_person() -> str:
async with session_scope() as session:
person = Person(
full_name="Grace Hopper",
display_name="Grace",
maiden_name="Murray",
birth_date=date(1906, 12, 9),
birth_date_raw="1906",
birth_place="New York",
death_date=date(1992, 1, 1),
death_date_raw="1992",
death_place="Arlington",
biography="Computer pioneer",
portrait_path="/images/grace.jpg",
)
session.add(person)
await session.commit()
await session.refresh(person)
return str(person.id)
person_id = asyncio.run(_seed_person())
response = client.get(f"/ui/people/{person_id}")
assert response.status_code == 200
assert "Grace Hopper" in response.text
assert "Display name: Grace" in response.text
assert "Maiden name: Murray" in response.text
assert "Birth date: 1906-12-09" in response.text
assert "Death date: 1992-01-01" in response.text
assert "Biography: Computer pioneer" in response.text
assert "Portrait path: /images/grace.jpg" in response.text
assert "Created at (read-only):" in response.text
assert "Updated at (read-only):" in response.text
assert "No linked documents yet." in response.text
assert "Link this person from a Document workflow." in response.text
def test_person_detail_page_renders_linked_documents(self, app_client):
_, client = app_client
async def _seed_links() -> str:
async with session_scope() as session:
person = Person(full_name="Linked Person")
document = Document(name="Linked Document", document_type="letter")
session.add(person)
session.add(document)
await session.flush()
session.add(
DocumentPerson(
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
)
)
await session.commit()
await session.refresh(person)
return str(person.id)
person_id = asyncio.run(_seed_links())
response = client.get(f"/ui/people/{person_id}")
assert response.status_code == 200
assert "Linked Document (author)" in response.text
def test_person_detail_page_handles_invalid_id(self, app_client):
_, client = app_client
response = client.get("/ui/people/not-a-uuid")
assert response.status_code == 200
assert "Invalid person id" in response.text
def test_person_detail_page_handles_missing_person(self, app_client):
_, client = app_client
response = client.get(f"/ui/people/{uuid4()}")
assert response.status_code == 200
assert "Person not found" in response.text
def test_person_edit_page_renders_expected_fields(self, app_client):
_, client = app_client
async def _seed_person() -> str:
async with session_scope() as session:
person = Person(full_name="Editable Person", display_name="EP")
session.add(person)
await session.commit()
await session.refresh(person)
return str(person.id)
person_id = asyncio.run(_seed_person())
response = client.get(f"/ui/people/{person_id}/edit")
assert response.status_code == 200
assert "Edit person" in response.text
assert "Full name is required." in response.text
assert "Full name" in response.text
assert "Save changes" in response.text
def test_person_delete_page_shows_confirmation_when_unlinked(self, app_client):
_, client = app_client
async def _seed_person() -> str:
async with session_scope() as session:
person = Person(full_name="Safe Delete")
session.add(person)
await session.commit()
await session.refresh(person)
return str(person.id)
person_id = asyncio.run(_seed_person())
response = client.get(f"/ui/people/{person_id}/delete")
assert response.status_code == 200
assert "Delete person" in response.text
assert "This action permanently deletes the person." in response.text
assert "Delete person permanently" in response.text
def test_person_delete_page_shows_blocked_state_when_linked_documents_exist(self, app_client):
_, client = app_client
async def _seed_links() -> str:
async with session_scope() as session:
person = Person(full_name="Blocked Delete")
document = Document(name="Linked Document", document_type="record")
session.add(person)
session.add(document)
await session.flush()
session.add(
DocumentPerson(
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
)
)
await session.commit()
await session.refresh(person)
return str(person.id)
person_id = asyncio.run(_seed_links())
response = client.get(f"/ui/people/{person_id}/delete")
assert response.status_code == 200
assert "Delete is blocked because linked documents exist." in response.text
assert "Linked documents: 1" in response.text
assert "Go to Documents" in response.text
+6 -10
View File
@@ -16,21 +16,17 @@ class TestPageRendering:
assert response.headers["location"] == "/ui" assert response.headers["location"] == "/ui"
def test_ui_redirects_to_upload(self, app_client): def test_ui_redirects_to_upload(self, app_client):
"""GET /ui redirects to the upload page.""" """GET /ui redirects to the jobs page."""
_, client = app_client _, client = app_client
response = client.get("/ui", follow_redirects=False) response = client.get("/ui", follow_redirects=False)
assert response.status_code == 307 assert response.status_code == 307
assert response.headers["location"] == "/ui/upload" assert response.headers["location"] == "/ui/jobs"
def test_upload_page_renders_expected_controls(self, app_client): def test_upload_page_renders_expected_controls(self, app_client):
"""GET /ui/upload returns the page shell and upload controls.""" """GET /ui/upload redirects to the job-create flow."""
_, client = app_client _, client = app_client
response = client.get("/ui/upload") response = client.get("/ui/upload", follow_redirects=False)
assert response.status_code == 200 assert response.status_code == 307
assert "VibeScribe" in response.text assert response.headers["location"] == "/ui/jobs/new"
assert "Upload Document" in response.text
assert "Select document file" in response.text
assert "Upload" in response.text
assert "Jobs" in response.text