From 9653060c2a9bc627b293f7fbb0fcf2d060b35f38 Mon Sep 17 00:00:00 2001 From: Jim Lancaster <40281233+zoltan57@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:33:09 -0500 Subject: [PATCH] UI update complete? --- docs/ui/entities/traceability-matrix.md | 51 +-- src/transcription/app.py | 13 +- src/transcription/db/models.py | 12 + src/transcription/services/documents.py | 109 +++++- src/transcription/services/jobs.py | 37 ++ src/transcription/services/store.py | 133 ++++++- src/transcription/services/transcription.py | 54 +++ src/transcription/services/workflows.py | 12 +- src/transcription/ui/__init__.py | 4 + src/transcription/ui/components/app_shell.py | 9 +- src/transcription/ui/pages/documents_page.py | 373 +++++++++++++++++++ src/transcription/ui/pages/jobs_page.py | 241 +++++++++++- src/transcription/ui/pages/people_page.py | 356 ++++++++++++++++++ src/transcription/ui/pages/upload_page.py | 21 +- tests/services/test_document_service.py | 188 ++++++++++ tests/services/test_job_service.py | 109 ++++++ tests/services/test_store.py | 72 ++++ tests/services/test_transcription_service.py | 65 ++++ tests/services/test_v2_crud.py | 88 +++++ tests/test_config.py | 11 +- tests/ui/conftest.py | 5 + tests/ui/test_documents_page.py | 331 ++++++++++++++++ tests/ui/test_jobs_page.py | 63 ++++ tests/ui/test_pages_registration.py | 8 +- tests/ui/test_people_page.py | 214 +++++++++++ tests/ui/test_upload_page.py | 16 +- 26 files changed, 2523 insertions(+), 72 deletions(-) create mode 100644 src/transcription/ui/pages/documents_page.py create mode 100644 src/transcription/ui/pages/people_page.py create mode 100644 tests/services/test_document_service.py create mode 100644 tests/services/test_store.py create mode 100644 tests/ui/test_documents_page.py create mode 100644 tests/ui/test_people_page.py diff --git a/docs/ui/entities/traceability-matrix.md b/docs/ui/entities/traceability-matrix.md index 31206bd..c6fb5a4 100644 --- a/docs/ui/entities/traceability-matrix.md +++ b/docs/ui/entities/traceability-matrix.md @@ -13,41 +13,41 @@ Status legend: | 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. | -| 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. | -| 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. | -| 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. | +| 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 | 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 | 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 | 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 | 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. | -| 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. | -| 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. | -| 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. | +| 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 | 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 | 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 | 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 | 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. | -| 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. | +| 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 | 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. | | 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 | 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. | -| 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. | -| 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. | +| 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 | 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 | 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. | | 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. | -| 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. | +| 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 | 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 @@ -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. | | 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 @@ -66,10 +66,13 @@ Status legend: ## Suggested Implementation Order -1. Document: add dedicated detail/read surface and metadata rendering. -2. Document: add edit and delete UI with dependency guardrails. -3. Person: add create/read/update/delete pages and relationship-aware delete constraints. -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. -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. +1. Aggregate final acceptance review across Document, Person, Source, and Job criteria. + +## Aggregate Final Review Snapshot (2026-08-02) + +| Entity | Acceptance IDs still not fully met | Evidence | Notes | +|---|---|---|---| +| 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. | diff --git a/src/transcription/app.py b/src/transcription/app.py index c6dead8..07cce4b 100644 --- a/src/transcription/app.py +++ b/src/transcription/app.py @@ -20,10 +20,8 @@ from .config import Settings from .config import configure_logging from .config import get_settings from .db import create_all +from .db import dispose_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.jobs import JobService from .ui import register_pages @@ -41,7 +39,7 @@ async def _lifespan(app: FastAPI): app.state.runtime = initialize_database_runtime(settings=settings) 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.prompt_dir.mkdir(parents=True, exist_ok=True) @@ -49,10 +47,7 @@ async def _lifespan(app: FastAPI): await _recover_stale_processing_jobs(app) async with AsyncExitStack() as stack: - stack.push_async_callback( - dispose_session_factory, - database_url=get_database_url(settings), - ) + stack.push_async_callback(dispose_database_runtime) stop_event, worker_notifier = await stack.enter_async_context( worker_consumer_lifespan( 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) 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") def health() -> dict[str, str]: diff --git a/src/transcription/db/models.py b/src/transcription/db/models.py index 128a9a5..0ba8f9b 100644 --- a/src/transcription/db/models.py +++ b/src/transcription/db/models.py @@ -151,6 +151,18 @@ class Job(SQLModel, table=True): 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): """A document source image or PDF page.""" diff --git a/src/transcription/services/documents.py b/src/transcription/services/documents.py index 6fca9fc..5318c05 100644 --- a/src/transcription/services/documents.py +++ b/src/transcription/services/documents.py @@ -1,6 +1,8 @@ import logging from collections.abc import Sequence from dataclasses import dataclass +from datetime import UTC +from datetime import datetime from pathlib import Path from uuid import UUID @@ -35,6 +37,14 @@ class DocumentAlreadyExistsError(DocumentError): """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) class UploadJobResult: """Summary of created upload records.""" @@ -102,6 +112,7 @@ class DocumentService(ServiceBase): async def update_document(self, document: Document, *, session: AsyncSession | None = None) -> Document: """Update an existing document in the database.""" async with self._session_scope(session) as _session: + document.updated_at = datetime.now(UTC) merged = await _session.merge(document) await self._finalize(session=_session, caller_session=session, refresh=(merged,)) return merged @@ -109,7 +120,36 @@ class DocumentService(ServiceBase): async def delete_document(self, document: Document, *, session: AsyncSession | None = None) -> None: """Delete a document from the database.""" 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) async def create_person(self, person: Person, *, session: AsyncSession | None = None) -> Person: @@ -131,9 +171,31 @@ class DocumentService(ServiceBase): ) 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: """Update an existing person in the database.""" async with self._session_scope(session) as _session: + person.updated_at = datetime.now(UTC) merged = await _session.merge(person) await self._finalize(session=_session, caller_session=session, refresh=(merged,)) return merged @@ -141,7 +203,28 @@ class DocumentService(ServiceBase): async def delete_person(self, person: Person, *, session: AsyncSession | None = None) -> None: """Delete a person from the database.""" 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) async def create_document_person( @@ -215,6 +298,28 @@ class DocumentService(ServiceBase): result = await _session.exec(select(Document)) 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]: """List all people in the database.""" async with self._session_scope(session) as _session: diff --git a/src/transcription/services/jobs.py b/src/transcription/services/jobs.py index 71b330a..4d84d62 100644 --- a/src/transcription/services/jobs.py +++ b/src/transcription/services/jobs.py @@ -7,6 +7,8 @@ from sqlalchemy.orm import selectinload from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession +from ..errors import AppError +from ..errors import ErrorCategory from ..db.models import Job from ..db.models import JobSource from ..db.models import JobStatus @@ -14,6 +16,10 @@ from ..db.models import Source from .base import ServiceBase +class JobDeleteBlockedError(AppError): + """Raised when a job delete operation is blocked by lifecycle policy.""" + + class JobService(ServiceBase): """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) 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) diff --git a/src/transcription/services/store.py b/src/transcription/services/store.py index 83eb506..72c6ad1 100644 --- a/src/transcription/services/store.py +++ b/src/transcription/services/store.py @@ -1,9 +1,13 @@ from __future__ import annotations +from collections.abc import Sequence +from dataclasses import dataclass import logging from pathlib import Path +from uuid import UUID from uuid import uuid4 +from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession from transcription.config import Settings @@ -27,6 +31,15 @@ class UploadError(AppError): """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( *, 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( *, session: AsyncSession, @@ -105,6 +178,63 @@ async def _create_upload_records( 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: try: if path.exists(): @@ -164,4 +294,5 @@ def _validate_upload(*, filename: str, file_bytes: bytes) -> None: def _build_stored_filename(filename: str) -> str: safe_name = Path(filename).name - return f"{uuid4()}_{safe_name}" + suffix = Path(safe_name).suffix.lower() + return f"{uuid4()}{suffix}" diff --git a/src/transcription/services/transcription.py b/src/transcription/services/transcription.py index 5f2e94c..1f26d35 100644 --- a/src/transcription/services/transcription.py +++ b/src/transcription/services/transcription.py @@ -51,6 +51,10 @@ class TranscriptionNotFoundError(TranscriptionError): """Raised when a transcription-related resource is not found.""" +class SourceDeleteBlockedError(TranscriptionError): + """Raised when source deletion is blocked by dependency policy.""" + + class TranscriptionService(ServiceBase): """Service class for job transcription output and page-level source revisions.""" @@ -164,6 +168,56 @@ class TranscriptionService(ServiceBase): await _session.delete(job_source) 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( self, *, diff --git a/src/transcription/services/workflows.py b/src/transcription/services/workflows.py index d85b45b..89af504 100644 --- a/src/transcription/services/workflows.py +++ b/src/transcription/services/workflows.py @@ -70,7 +70,17 @@ async def process_queued_job( source_job = await services.jobs.read_job(job_id=job.id, session=session) 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() try: diff --git a/src/transcription/ui/__init__.py b/src/transcription/ui/__init__.py index 200d86a..30d4678 100644 --- a/src/transcription/ui/__init__.py +++ b/src/transcription/ui/__init__.py @@ -3,7 +3,9 @@ from fastapi import FastAPI 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.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.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_global_styles(app) register_upload_page() + register_documents_page() + register_people_page() register_jobs_page() ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False) diff --git a/src/transcription/ui/components/app_shell.py b/src/transcription/ui/components/app_shell.py index 77b622f..5257f27 100644 --- a/src/transcription/ui/components/app_shell.py +++ b/src/transcription/ui/components/app_shell.py @@ -7,7 +7,8 @@ from nicegui import ui from transcription.ui.resources import read_css NAV_ITEMS: tuple[tuple[str, str, str], ...] = ( - ("Upload", "/upload", "upload_file"), + ("Documents", "/documents", "description"), + ("People", "/people", "group"), ("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: if item_path == "/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 @@ -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: normalized = (current_path or "").strip() if not normalized: - return "/upload" + return "/jobs" return normalized.rstrip("/") or "/" diff --git a/src/transcription/ui/pages/documents_page.py b/src/transcription/ui/pages/documents_page.py new file mode 100644 index 0000000..1379798 --- /dev/null +++ b/src/transcription/ui/pages/documents_page.py @@ -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") diff --git a/src/transcription/ui/pages/jobs_page.py b/src/transcription/ui/pages/jobs_page.py index 61d4466..5dc39b3 100644 --- a/src/transcription/ui/pages/jobs_page.py +++ b/src/transcription/ui/pages/jobs_page.py @@ -2,6 +2,8 @@ from __future__ import annotations +from fastapi import Request +from pathlib import Path from uuid import UUID from nicegui import ui @@ -9,11 +11,17 @@ from nicegui import ui from transcription.db.models import Job from transcription.db.models import JobStatus 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.transcription import SourceDeleteBlockedError +from transcription.services.store import create_job_for_document from transcription.services.transcription import TranscriptionService from transcription.ui.components.app_shell import render_navigation_header from transcription.ui.components.error_presenter import show_error from transcription.ui.components.table.jobs import render_jobs_table +from transcription.worker import resolve_worker_notifier from ...db.session import SessionFactoryDep from ..components.document_panzoom import render_document_panzoom @@ -45,9 +53,134 @@ def register_page() -> None: # noqa: PLR0915 ] 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() + @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}") async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None: # noqa: PLR0915 jobs_service = JobService(session_factory=session_factory) @@ -85,6 +218,20 @@ def register_page() -> None: # noqa: PLR0915 case _: 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) @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.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: candidate = (editor.value or "").strip() if not candidate: @@ -120,7 +287,22 @@ def register_page() -> None: # noqa: PLR0915 ui.notify("Revision saved", type="positive") 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( "Create revision" if current_revision_text is None else "Update revision", on_click=save_revision, @@ -138,6 +320,61 @@ def register_page() -> None: # noqa: PLR0915 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: if not job.job_sources: diff --git a/src/transcription/ui/pages/people_page.py b/src/transcription/ui/pages/people_page.py new file mode 100644 index 0000000..4bee64c --- /dev/null +++ b/src/transcription/ui/pages/people_page.py @@ -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") diff --git a/src/transcription/ui/pages/upload_page.py b/src/transcription/ui/pages/upload_page.py index 6819a07..1c6d292 100644 --- a/src/transcription/ui/pages/upload_page.py +++ b/src/transcription/ui/pages/upload_page.py @@ -3,29 +3,18 @@ from __future__ import annotations from fastapi import Request +from fastapi.responses import RedirectResponse +from starlette import status 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.upload import render_upload_widget -from transcription.worker import resolve_worker_notifier def register_page() -> None: """Register the upload page route.""" @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") - - 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) + return RedirectResponse(url="/ui/jobs/new", status_code=status.HTTP_307_TEMPORARY_REDIRECT) diff --git a/tests/services/test_document_service.py b/tests/services/test_document_service.py new file mode 100644 index 0000000..923c5de --- /dev/null +++ b/tests/services/test_document_service.py @@ -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) diff --git a/tests/services/test_job_service.py b/tests/services/test_job_service.py index 4a9df72..2844ef8 100644 --- a/tests/services/test_job_service.py +++ b/tests/services/test_job_service.py @@ -9,6 +9,7 @@ from transcription.db.models import JobSourceStatus from transcription.db.models import JobStatus from transcription.db.models import Source from transcription.services.documents import DocumentService +from transcription.services.jobs import JobDeleteBlockedError from transcription.services.jobs import JobService @@ -107,3 +108,111 @@ class TestJobService: next_job = await job_service.read_next_queued_job() assert next_job is not None 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) diff --git a/tests/services/test_store.py b/tests/services/test_store.py new file mode 100644 index 0000000..c4d8e7a --- /dev/null +++ b/tests/services/test_store.py @@ -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} diff --git a/tests/services/test_transcription_service.py b/tests/services/test_transcription_service.py index 781f19b..7c5efde 100644 --- a/tests/services/test_transcription_service.py +++ b/tests/services/test_transcription_service.py @@ -12,6 +12,8 @@ from transcription.db.models import JobStatus from transcription.db.models import Source from transcription.services.documents import DocumentService from transcription.services.jobs import JobService +from transcription.services.transcription import SourceDeleteBlockedError +from transcription.services.transcription import TranscriptionNotFoundError from transcription.services.transcription import TranscriptionService @@ -89,3 +91,66 @@ class TestTranscriptionServiceRevisionUpsert: assert len(revisions) == 1 assert revisions[0].id == first.id 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) diff --git a/tests/services/test_v2_crud.py b/tests/services/test_v2_crud.py index ab3ca83..0bad960 100644 --- a/tests/services/test_v2_crud.py +++ b/tests/services/test_v2_crud.py @@ -10,8 +10,10 @@ from transcription.db.models import JobSource from transcription.db.models import JobSourceStatus from transcription.db.models import Person from transcription.db.models import Source +from transcription.services.documents import DocumentDeleteBlockedError from transcription.services.documents import DocumentService from transcription.services.jobs import JobService +from transcription.services.transcription import SourceDeleteBlockedError 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() 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) diff --git a/tests/test_config.py b/tests/test_config.py index cf050b8..fdc1e6b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -71,13 +71,18 @@ class TestProviderSettings: with pytest.raises(ValidationError): _make_settings(provider="not-a-provider") - def test_optional_fields_default_to_none(self): - """provider_model, openrouter_http_referer, and openrouter_app_title are None when unset.""" + def test_optional_provider_header_fields_default_to_none(self): + """openrouter_http_referer and openrouter_app_title are None when unset.""" settings = _make_settings() - assert settings.provider_model is None assert settings.openrouter_http_referer 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: """Verify filesystem path field types.""" diff --git a/tests/ui/conftest.py b/tests/ui/conftest.py index c02dbc8..aa4a507 100644 --- a/tests/ui/conftest.py +++ b/tests/ui/conftest.py @@ -22,10 +22,12 @@ from transcription.db import create_all from transcription.db import initialize_database_runtime from transcription.db import session_scope from transcription.db.models import Document +from transcription.db.models import DocumentPerson from transcription.db.models import Job from transcription.db.models import JobSource from transcription.db.models import JobSourceStatus from transcription.db.models import JobStatus +from transcription.db.models import Person from transcription.db.models import Source RevisionSeed = str @@ -58,9 +60,12 @@ def clear_ui_database(app_client: tuple[FastAPI, TestClient]) -> None: async def _clear() -> None: 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(Job)) await session.exec(delete(Document)) + await session.exec(delete(Person)) await session.commit() asyncio.run(_clear()) diff --git a/tests/ui/test_documents_page.py b/tests/ui/test_documents_page.py new file mode 100644 index 0000000..657f786 --- /dev/null +++ b/tests/ui/test_documents_page.py @@ -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 diff --git a/tests/ui/test_jobs_page.py b/tests/ui/test_jobs_page.py index 0ca1bd5..5ecc9cf 100644 --- a/tests/ui/test_jobs_page.py +++ b/tests/ui/test_jobs_page.py @@ -1,10 +1,13 @@ """Tests for the jobs page route.""" +import asyncio from pathlib import Path from uuid import uuid4 import pytest +from transcription.db import session_scope +from transcription.db.models import Document from transcription.db.models import JobStatus @@ -18,8 +21,39 @@ class TestPageRendering: response = client.get("/ui/jobs") assert response.status_code == 200 + assert "Create job" 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): """GET /ui/jobs lists seeded jobs from the in-memory database.""" _, client = app_client @@ -54,6 +88,14 @@ class TestPageRendering: assert "original text" in response.text assert "Document preview" 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): """GET /ui/jobs/{job_id} shows validation feedback for malformed IDs.""" @@ -105,3 +147,24 @@ class TestPageRendering: assert "Revision Editor" in response.text assert "Update revision" 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 diff --git a/tests/ui/test_pages_registration.py b/tests/ui/test_pages_registration.py index 542f5a8..c79cf4c 100644 --- a/tests/ui/test_pages_registration.py +++ b/tests/ui/test_pages_registration.py @@ -11,8 +11,12 @@ class TestPageRegistration: """Mounted UI routes respond successfully when the full app is created.""" _, 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") - 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 diff --git a/tests/ui/test_people_page.py b/tests/ui/test_people_page.py new file mode 100644 index 0000000..c4717fc --- /dev/null +++ b/tests/ui/test_people_page.py @@ -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 diff --git a/tests/ui/test_upload_page.py b/tests/ui/test_upload_page.py index 0ab2dfa..2c0a8af 100644 --- a/tests/ui/test_upload_page.py +++ b/tests/ui/test_upload_page.py @@ -16,21 +16,17 @@ class TestPageRendering: assert response.headers["location"] == "/ui" 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 response = client.get("/ui", follow_redirects=False) 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): - """GET /ui/upload returns the page shell and upload controls.""" + """GET /ui/upload redirects to the job-create flow.""" _, client = app_client - response = client.get("/ui/upload") + response = client.get("/ui/upload", follow_redirects=False) - assert response.status_code == 200 - assert "VibeScribe" in response.text - assert "Upload Document" in response.text - assert "Select document file" in response.text - assert "Upload" in response.text - assert "Jobs" in response.text + assert response.status_code == 307 + assert response.headers["location"] == "/ui/jobs/new"