From 7db4df172923ce0c67853fc7f69d7d69487eae32 Mon Sep 17 00:00:00 2001 From: Jim Lancaster <40281233+zoltan57@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:30:33 -0500 Subject: [PATCH] V4.4 Complete --- docs/ui/pages/documents.md | 35 ++- docs/ver4/architecture_v4.md | 44 +-- docs/ver4/requirements_v4.md | 26 +- docs/ver4/schema_v4.md | 30 +- src/transcription/api/v4_documents.py | 60 +--- src/transcription/api/v4_print.py | 54 ++++ src/transcription/app.py | 2 + src/transcription/db/models.py | 18 +- src/transcription/db/operations.py | 116 ++----- src/transcription/db/registries.py | 20 ++ src/transcription/services/documents.py | 101 +++++++ src/transcription/services/people.py | 286 ++++++++++++------ src/transcription/services/workflows.py | 37 ++- src/transcription/ui/__init__.py | 2 + .../ui/components/linked_people.py | 180 +++++++++++ src/transcription/ui/pages/documents_page.py | 204 +++++-------- src/transcription/ui/pages/people_page.py | 4 +- .../ui/pages/print_preview_page.py | 165 ++++++++++ src/transcription/ui/pages/settings_page.py | 224 +++++++++----- src/transcription/ui/static/theme.css | 63 ++++ tests/api/test_v4_documents.py | 42 ++- tests/services/test_document_service.py | 33 +- tests/services/test_settings_services.py | 52 ++-- tests/services/test_v2_crud.py | 30 +- tests/services/test_v44_workflows.py | 166 ++++++++++ tests/test_db.py | 97 +----- tests/test_models.py | 14 +- tests/ui/test_documents_page.py | 13 +- tests/ui/test_jobs_page.py | 6 +- tests/ui/test_people_page.py | 10 +- tests/ui/test_print_preview_page.py | 103 +++++++ tests/ui/test_sources_page.py | 8 +- 32 files changed, 1529 insertions(+), 716 deletions(-) create mode 100644 src/transcription/api/v4_print.py create mode 100644 src/transcription/db/registries.py create mode 100644 src/transcription/ui/components/linked_people.py create mode 100644 src/transcription/ui/pages/print_preview_page.py create mode 100644 tests/services/test_v44_workflows.py create mode 100644 tests/ui/test_print_preview_page.py diff --git a/docs/ui/pages/documents.md b/docs/ui/pages/documents.md index 1578406..9adeff7 100644 --- a/docs/ui/pages/documents.md +++ b/docs/ui/pages/documents.md @@ -11,10 +11,11 @@ Documents manages the archival record for each historical artifact independently | `/documents` | Searchable archival Document list. | | `/documents/new` | Create a Document. | | `/documents/{document_id}` | View one Document and its related records. | -| `/documents/{document_id}/edit` | Edit metadata and people-by-role links. | +| `/documents/{document_id}/edit` | Edit metadata and the complete Linked People set. | | `/documents/{document_id}/delete` | Confirm or block deletion. | | `/documents/{document_id}/jobs` | Show Jobs belonging to the Document. | | `/documents/{document_id}/sources` | Redirect to the Document-filtered Sources list. | +| `/documents/{document_id}/print` | Preview and browser-print the persisted Document. | ## List Behavior @@ -42,7 +43,7 @@ Optional: - Document location. - Archive identifier. - Notes. -- Multiple people for every configured Person Role. +- Linked People, with exactly one Person Role per linked Person. Rules: @@ -53,6 +54,11 @@ Rules: - An invalid requested Person produces a warning rather than a broken form. - `return_to=jobs_new` returns a successful create to Job creation with the new Document selected. - Edit includes active and inactive Document Types so historical values remain maintainable. +- One Linked People table contains Select, Person, and Role columns. +- Add and Edit use an inline Person/Role editor; Save, Cancel, and Delete change staged UI state only. +- A Person may appear once per Document regardless of role. +- Existing inactive-role links remain visible; only active roles may be newly assigned. +- Document fields and the complete staged link set commit atomically on the main save. - Save success returns to Document Detail. ## Detail Behavior @@ -63,9 +69,20 @@ Rules: - System Logistics shows created and updated timestamps. - Related People are grouped by role and link to Person Detail. - **Sources & Pipeline Jobs** shows counts and actions for filtered Sources, Document Jobs, and adding a Job. -- **Edit Document** and **Delete** are available from the header. +- **Edit Document**, **Print**, and **Delete** are available from the header. - Invalid IDs and missing Documents produce explicit states without rendering a partial page. +## Print Behavior + +- Print opens a dedicated preview for persisted Document data. +- **Facsimile** places each Source image beside its current transcription and starts every Source on a new printed sheet. +- **Text only** omits images, joins single line breaks inside paragraphs, and preserves blank-line paragraph boundaries. +- Non-null revised text takes precedence over raw transcription, including an intentionally empty revision. +- Archival metadata resolves Author through the hidden built-in semantic identity, not its mutable label. +- Job metadata uses one oldest-to-newest column per Job and ends with Status. +- Stored text is escaped and Source media uses record-validated application URLs rather than local file paths. +- Printing uses the browser print dialog; server-generated PDFs are not provided. + ## Document Jobs Behavior - The page lists the Document's Jobs newest first with status and Job ID. @@ -84,10 +101,12 @@ Rules: - List columns, alignment, search, sorting, date fallback, and row navigation match this contract. - Create/edit enforce name, registered type, and valid exact-date input. -- Multiple people can be selected independently for each configured role. +- Linked People staging enforces one role and one row per Person. +- Document and Linked People writes never partially commit. - Person-first Document creation preselects the requested Person as author. - Detail links people, Sources, and Jobs to the correct records. - Delete never removes a Document with Source or Job dependencies. +- Both print formats preserve the frozen content, ordering, text-precedence, and safety contracts. - Service failures use the shared error presenter and never report false success. ## Implementation Anchors @@ -96,10 +115,14 @@ Rules: - `src/transcription/ui/components/table/documents.py` - `src/transcription/services/documents.py` - `src/transcription/services/people.py` +- `src/transcription/services/workflows.py` +- `src/transcription/ui/components/linked_people.py` +- `src/transcription/ui/pages/print_preview_page.py` +- `src/transcription/api/v4_print.py` - `tests/ui/test_documents_page.py` - `tests/services/test_document_service.py` ## Known Limitations and Deferred Work -- Document creation persists the Document before adding relationship links; a later link failure is surfaced but is not currently one atomic write. -- Source ordering controls are deferred to the [draft V4.3 scope](../../ver4.3/scope_boundary_v4_3.md). +- Source page ordering remains read-only in V4.4. +- Printing other entities, batch printing, and server-side export formats are deferred. diff --git a/docs/ver4/architecture_v4.md b/docs/ver4/architecture_v4.md index db0e046..7c7f058 100644 --- a/docs/ver4/architecture_v4.md +++ b/docs/ver4/architecture_v4.md @@ -10,8 +10,8 @@ This document describes the production architecture of the document transcriptio - Execute page transcription concurrently with bounded `asyncio` workers. - Maintain relational portability across SQLite and PostgreSQL. - Keep operator workflows cross-platform and Python-driven. -- Support many-to-many document-person relationships with extensible roles. -- Support registry-driven document type classification. +- Support one role-bearing link per Person and Document through an extensible role registry. +- Support registry-driven document classification with protected semantic built-ins. ## Core Capabilities @@ -20,8 +20,8 @@ This document describes the production architecture of the document transcriptio - Preserve original source files with SHA-256 digests and byte sizes. - Freeze prompt text, prompt hash, model, and explicitly configured sampling parameters on each `Job`. - Preserve page-level machine output, normalized metadata, and an SDK-serialized OpenRouter response snapshot on `JobSource`. -- Organize historical `Person` records through many-to-many Document relationships and extensible roles. -- Classify Documents through a UUID-identified registry with unique labels. +- Organize historical `Person` records through UUID-identified Document links and extensible roles. +- Classify Documents through a UUID-identified registry with hidden semantic built-ins and unique labels. - Maintain human revision separately from machine-generated text. - Isolate page failures so multi-page jobs can complete with partial success. - Operate across supported platforms through Python-based application and maintenance tooling. @@ -110,8 +110,8 @@ Responsibilities: - Jobs own job lifecycle state and transitions. - People own person records, relationship roles, document-person links, and portrait media. - Apply deterministic conflict handling for relationship-role writes. -- Use set-based synchronization for many-to-many relationship updates. -- Resolve and validate registry-backed document types by UUID. +- Synchronize each Document's complete Person link set in the same transaction as Document fields. +- Resolve and validate registry records by UUID; use hidden semantic keys only for application-owned built-in behavior. ### Source Media Policy @@ -146,11 +146,11 @@ Responsibilities: ### 2. Document-Person Relationship Management -1. User opens a document or person edit flow. -2. UI loads existing links grouped by role. -3. User adds or removes people within one or more roles. -4. Service computes add/remove deltas rather than replacing all links blindly. -5. Conflict checks enforce uniqueness and deterministic write semantics before persistence commits. +1. User opens Document Create or Edit. +2. UI loads one Linked People table containing Person and Role. +3. Add, Edit, and Delete operations change staged UI state only. +4. Service validates the complete desired set and computes deterministic add, update, and remove deltas. +5. Document fields and links commit once in one transaction; any failure leaves both unchanged. ### 3. Document Type Management @@ -159,6 +159,14 @@ Responsibilities: 3. Persistence stores the `document_type_id` reference. 4. Inactive types remain valid for historical rows but are excluded from default selectors. +### 4. Document Printing + +1. User opens Print from persisted Document Detail. +2. Service builds a safe projection containing archival metadata, semantic Author links, ordered Sources, current text, + and oldest-to-newest Job metadata. +3. The preview renders Facsimile or Text-only HTML without exposing local file paths. +4. An explicit action opens the browser print dialog; browser Save as PDF remains available. + ## V4 Domain Rules - `JobSource.raw_transcription` preserves page output for its Job execution. @@ -169,23 +177,25 @@ Responsibilities: - Every V4.2 provider call appends a distinct `ExecutionAttempt`; retries never rewrite earlier attempts. - Exact response bytes identify the OpenRouter HTTP boundary and are not labeled as native upstream-provider JSON. - Generic `ProcessingArtifact` records use versioned schemas, digests, and one inline or external content location. -- `DocumentPerson` links are unique for `(document_id, person_id, role_id)`. -- Relationship mutations are deterministic and set-based. -- `DocumentType.id` is canonical identity; its unique label may evolve. +- `DocumentPerson` links are unique for `(document_id, person_id)` and require one `role_id`. +- Relationship mutations are deterministic, set-based, and atomic with Document writes. +- `DocumentType.id` and `PersonRole.id` are canonical relationship identities; unique labels may evolve. +- Nullable immutable `semantic_key` values identify protected application-defined built-ins and are never public selectors. +- Current printable text uses non-null `Source.revised_text`; otherwise it uses `Source.raw_transcription`. ## Data Model Summary - `Document` has one `DocumentType`, many `Source` pages, many `Job` runs, and many `Person` records through `DocumentPerson`. - `Source` belongs to one `Document` and may participate in many `JobSource` executions. - `Job` has many `JobSource` rows. -- `PersonRole` defines available relationship roles. +- `PersonRole` defines available relationship roles; `DocumentType` and `PersonRole` may carry hidden semantic identity. ## Test Strategy - Unit tests for models, validation, hashing, and registry resolution. -- Service tests for CRUD, set-based sync, uniqueness conflicts, and deterministic relationship writes. +- Service tests for registry protection, atomic link synchronization, uniqueness conflicts, and print projections. - Async workflow tests for page isolation, partial failure handling, and stored evidence. -- UI integration tests for multi-page rendering, role grouping, and document type selection. +- UI integration tests for Linked People staging, registry selection, and safe print rendering. ## Related Local References diff --git a/docs/ver4/requirements_v4.md b/docs/ver4/requirements_v4.md index d32b417..2554934 100644 --- a/docs/ver4/requirements_v4.md +++ b/docs/ver4/requirements_v4.md @@ -11,36 +11,42 @@ This document defines the baseline requirements for the document transcription s | REQ-2 | Functional | Process page transcription asynchronously using an `asyncio` worker pool bounded by rate limits. | test | | REQ-3 | Functional | Persist submission-time request provenance and accurately labeled page-level SDK evidence; V4.2 adds exact OpenRouter-boundary transport evidence for new attempts. | test | | REQ-4 | Functional | Support job states `queued`, `processing`, `completed`, `partial_success`, and `failed`, plus page states `pending`, `transcribed`, and `failed`. | inspection | -| REQ-5 | Functional | Allow users to manage historical `Person` records and link multiple people per role to a `Document`. | test | +| REQ-5 | Functional | Allow users to manage historical `Person` records and link each Person to a Document once with exactly one role. | test | | REQ-6 | Functional | Support an extensible role taxonomy for document-person relationships. | inspection | -| REQ-7 | Policy Constraint | Enforce deterministic relationship-role writes with uniqueness on `(document_id, person_id, role_id)` and explicit conflict responses for invalid duplicate link attempts. | test | +| REQ-7 | Policy Constraint | Enforce deterministic relationship-role writes with uniqueness on `(document_id, person_id)` and explicit conflict responses for duplicate Person links. | test | | REQ-8 | Functional | Use set-based synchronization for document-person mutations so updates add and remove only the intended links. | test | | REQ-9 | Functional | Maintain immutable machine output on `Source.raw_transcription` while permitting inline human edits on `Source.revised_text`. | test | | REQ-10 | Functional | Support a UUID-identified `DocumentType` taxonomy with unique user-facing labels and active/inactive lifecycle control. | test | | REQ-11 | Data Constraint | Store `Document` type as a controlled reference to `DocumentType`. | test | | REQ-12 | Interface | Render multi-page transcriptions sequentially by `page_number` with document, people, and document-type metadata. | demonstration | -| REQ-13 | Interface | Document create/edit UI must support selecting multiple people per role and selecting an active document type from the registry. | demonstration | +| REQ-13 | Interface | Document create/edit UI must provide one staged Linked People table and select active registry entries by UUID and label. | demonstration | | REQ-14 | API Constraint | Expose additive, role-aware retrieval and write behavior for document-person links and UUID-based selection for document types. | test | | REQ-15 | Data Constraint | Calculate and store cryptographic file hashes (SHA-256) and file sizes for uploaded source images. | test | | REQ-16 | Data Constraint | Preserve a portable relational model across supported backends using SQLModel, SQLAlchemy, SQLite, and PostgreSQL. | inspection | | REQ-17 | Reliability | Ensure delete and update flows for documents, people, and relationship links remain deterministic and safe. | test | | REQ-18 | Operations Constraint | Keep canonical development, testing, restore, and recovery workflows OS-independent; for AI-run unit tests, require a pre-test backup of `./data` and an always-shown post-success confirmation prompt before any restore action. | inspection | | REQ-19 | Quality | Provide automated coverage for async transcription workflows, relationship-role enforcement, document-type selection, and regression behavior. | test | +| REQ-20 | Data Constraint | Permit hidden immutable semantic keys only on protected built-in Document Types and Person Roles while retaining UUID as relationship identity. | test | +| REQ-21 | Reliability | Persist Document fields and their complete Linked People set atomically. | test | +| REQ-22 | Interface | Provide safe browser-native Facsimile and Text-only print views from persisted Document Detail. | demonstration | +| REQ-23 | Security | Escape stored print text and serve Source images through record-validated application routes without disclosing local paths. | test | +| REQ-24 | Functional | Print current human-preferred Source text, semantic Author metadata, deterministic Source order, and oldest-to-newest Job metadata. | test | ## Clarifying Constraints -1. `DocumentType.id` is its sole identity; labels are unique ignoring case and surrounding whitespace. -2. `PersonRole.code` is a stable machine identifier; `PersonRole.label` may evolve. +1. `DocumentType.id` and `PersonRole.id` are their public and relationship identities; labels are unique ignoring case and surrounding whitespace. +2. Nullable `semantic_key` values identify protected application built-ins, remain internal, and never change. 3. Relationship-write policy and conflict handling must be consistent across UI, API, services, and persistence. -4. Many-per-role behavior is required for document-person links. -5. Relationship conflicts must fail deterministically without partial mutation. +4. One Person may appear only once per Document and every link has exactly one role. +5. Relationship conflicts must fail deterministically without partial Document or link mutation. +6. Source page reordering and server-generated PDF files remain outside this revision. ## Element Satisfaction Mapping -- UI (NiceGUI): Satisfies REQ-0, REQ-1, REQ-5, REQ-9, REQ-12, REQ-13. -- API (FastAPI): Satisfies REQ-1, REQ-4, REQ-5, REQ-7, REQ-8, REQ-14. +- UI (NiceGUI): Satisfies REQ-0, REQ-1, REQ-5, REQ-9, REQ-12, REQ-13, REQ-22, REQ-24. +- API (FastAPI): Satisfies REQ-1, REQ-4, REQ-5, REQ-7, REQ-8, REQ-14, REQ-23. - Worker (`asyncio`): Satisfies REQ-2, REQ-3, REQ-4. -- Persistence (SQLModel / SQLAlchemy): Satisfies REQ-3, REQ-9, REQ-10, REQ-11, REQ-15, REQ-16, REQ-17. +- Persistence (SQLModel / SQLAlchemy): Satisfies REQ-3, REQ-7, REQ-9, REQ-10, REQ-11, REQ-15, REQ-16, REQ-17, REQ-20, REQ-21. - Test Suite: Verifies all test-marked requirements and satisfies REQ-19. ## Related Local References diff --git a/docs/ver4/schema_v4.md b/docs/ver4/schema_v4.md index aaf0bc4..6471dd0 100644 --- a/docs/ver4/schema_v4.md +++ b/docs/ver4/schema_v4.md @@ -8,6 +8,7 @@ This document defines the relational schema for the document transcription syste erDiagram DOCUMENT_TYPE { UUID id PK +TEXT semantic_key UK TEXT label TEXT normalized_label BOOLEAN is_active @@ -17,8 +18,9 @@ TIMESTAMPTZ updated_at PERSON_ROLE { UUID id PK -TEXT code +TEXT semantic_key UK TEXT label +TEXT normalized_label BOOLEAN is_active TIMESTAMPTZ created_at TIMESTAMPTZ updated_at @@ -196,17 +198,29 @@ EXECUTION_ATTEMPT ||--o{ PROCESSING_ARTIFACT : produces - `SOURCE.raw_transcription` remains immutable machine output. - `SOURCE.revised_text` stores human edits and is the preferred display value when present. +### Semantic Registry Governance + +- `DOCUMENT_TYPE.id` and `PERSON_ROLE.id` are the only relationship and public API identities. +- Nullable unique `semantic_key` values identify application-defined built-ins and are immutable after creation. +- Semantic keys are internal and are never accepted from Settings or public relationship APIs. +- A non-null semantic key marks a protected built-in; built-ins may be relabeled or disabled but not deleted. +- Custom entries have null semantic keys and may be deleted only when unreferenced. +- Labels are mutable display text and are unique after trimming and case normalization. +- Inactive entries remain valid for historical rows but are excluded from new-assignment selectors. + ### Document-Person Role Governance -- Documents support zero, one, or many people per relationship role. +- Documents support zero or one relationship for each Person. - Relationship roles are defined by `PERSON_ROLE` rather than hardcoded columns. -- `DOCUMENT_PERSON` must be unique for `(document_id, person_id, role_id)`. -- Relationship writes must be deterministic and use explicit add/remove link intent. +- `DOCUMENT_PERSON.role_id` is required. +- `DOCUMENT_PERSON` must be unique for `(document_id, person_id)`. +- Complete link sets and Document fields are validated and persisted in one atomic transaction. +- Existing inactive roles may remain unchanged; new or changed assignments require active roles. ### Document Type Governance - Every document type is defined by `DOCUMENT_TYPE`. -- `DOCUMENT_TYPE.id` is the sole machine identity. +- `DOCUMENT_TYPE.id` is the relationship identity; hidden semantic keys identify protected built-in meaning. - `DOCUMENT_TYPE.label` is mutable display text and is unique after trimming and case normalization. - `DOCUMENT_TYPE.normalized_label` stores the normalized uniqueness key. - Inactive types remain valid for historical rows but should be excluded from default selection UIs. @@ -214,8 +228,10 @@ EXECUTION_ATTEMPT ||--o{ PROCESSING_ARTIFACT : produces ## Constraint Summary - `DOCUMENT_TYPE.normalized_label` is unique. -- `PERSON_ROLE.code` is unique. -- `DOCUMENT_PERSON(document_id, person_id, role_id)` is unique. +- `DOCUMENT_TYPE.semantic_key` is nullable and unique. +- `PERSON_ROLE.normalized_label` is unique. +- `PERSON_ROLE.semantic_key` is nullable and unique. +- `DOCUMENT_PERSON(document_id, person_id)` is unique. ## Indexing Guidance diff --git a/src/transcription/api/v4_documents.py b/src/transcription/api/v4_documents.py index 081a79d..a03fdb4 100644 --- a/src/transcription/api/v4_documents.py +++ b/src/transcription/api/v4_documents.py @@ -12,7 +12,6 @@ from fastapi import Response from pydantic import BaseModel from pydantic import ConfigDict from pydantic import Field -from pydantic import model_validator from transcription.db.models import Document from transcription.db.models import DocumentPerson @@ -28,27 +27,6 @@ class ApiModel(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True) -class SelectorRequest(ApiModel): - @model_validator(mode="after") - def require_exactly_one_selector(self): - values = (self.selector_id, self.selector_code) - if sum(value is not None for value in values) != 1: - raise ValueError(f"Provide exactly one of {self.selector_names[0]} or {self.selector_names[1]}") - return self - - @property - def selector_id(self) -> UUID | None: - raise NotImplementedError - - @property - def selector_code(self) -> str | None: - raise NotImplementedError - - @property - def selector_names(self) -> tuple[str, str]: - raise NotImplementedError - - class DocumentTypeRead(ApiModel): id: UUID label: str @@ -57,7 +35,6 @@ class DocumentTypeRead(ApiModel): class PersonRoleRead(ApiModel): id: UUID - code: str label: str is_active: bool @@ -73,39 +50,19 @@ class DocumentTypeWriteResponse(ApiModel): class DocumentPersonWriteRequest(ApiModel): person_id: UUID - role_id: UUID | None = None - role_code: str | None = Field(default=None, min_length=1, pattern=r"^[a-z0-9_]+$") - - @model_validator(mode="after") - def reject_conflicting_role_selectors(self): - if self.role_id is not None and self.role_code is not None: - raise ValueError("Provide role_id or role_code, not both") - return self + role_id: UUID -class DocumentPersonRoleUpdateRequest(SelectorRequest): - role_id: UUID | None = None - role_code: str | None = Field(default=None, min_length=1, pattern=r"^[a-z0-9_]+$") - - @property - def selector_id(self) -> UUID | None: - return self.role_id - - @property - def selector_code(self) -> str | None: - return self.role_code - - @property - def selector_names(self) -> tuple[str, str]: - return "role_id", "role_code" +class DocumentPersonRoleUpdateRequest(ApiModel): + role_id: UUID class DocumentPersonRead(ApiModel): id: UUID document_id: UUID person_id: UUID - role_id: UUID | None - role_code: str + role_id: UUID + role_label: str | None = None person_name: str | None = None @@ -125,22 +82,19 @@ def _document_type_to_read(item: DocumentType) -> DocumentTypeRead: def _person_role_to_read(item: PersonRole) -> PersonRoleRead: return PersonRoleRead( id=item.id, - code=item.code, label=item.label, is_active=item.is_active, ) def _document_person_to_read(item: DocumentPerson) -> DocumentPersonRead: - role_code = item.role_ref.code if item.role_ref is not None else str(item.role) - person_name = item.person.full_name if item.person is not None else None return DocumentPersonRead( id=item.id, document_id=item.document_id, person_id=item.person_id, role_id=item.role_id, - role_code=role_code, + role_label=item.role_ref.label if item.role_ref is not None else None, person_name=person_name, ) @@ -224,7 +178,6 @@ async def add_document_person_link( document_id=document_id, person_id=payload.person_id, role_id=payload.role_id, - role_code=payload.role_code, ) return _document_person_to_read(link) @@ -238,7 +191,6 @@ async def set_document_person_role( link = await service.set_document_person_role( document_person_id=document_person_id, role_id=payload.role_id, - role_code=payload.role_code, ) return _document_person_to_read(link) diff --git a/src/transcription/api/v4_print.py b/src/transcription/api/v4_print.py new file mode 100644 index 0000000..4b4e3cd --- /dev/null +++ b/src/transcription/api/v4_print.py @@ -0,0 +1,54 @@ +"""Safe media route for V4.4 Document print previews.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter +from fastapi import Depends +from fastapi import HTTPException +from fastapi import Request +from fastapi.responses import FileResponse + +from transcription.services.sources import SOURCE_MIME_TYPES +from transcription.services.sources import SourceService + +router = APIRouter(prefix="/api/v4", tags=["v4-print"]) + + +def get_source_service(request: Request) -> SourceService: + services = getattr(request.app.state, "services", None) + if services is not None: + return services.sources + return SourceService() + + +SourceServiceDependency = Annotated[SourceService, Depends(get_source_service)] + + +@router.get("/documents/{document_id}/sources/{source_id}/media", response_class=FileResponse) +async def read_document_source_media( + document_id: UUID, + source_id: UUID, + service: SourceServiceDependency, +) -> FileResponse: + """Serve one validated Source through record identifiers, never a supplied path.""" + source = await service.read_source(source_id) + if source.document_id != document_id: + raise HTTPException(status_code=404, detail="Source not found for Document") + + path = Path(source.file_path).resolve() + upload_root = service.settings.upload_dir.resolve() + try: + path.relative_to(upload_root) + except ValueError as exc: + raise HTTPException(status_code=404, detail="Source media is outside managed storage") from exc + if not path.is_file(): + raise HTTPException(status_code=404, detail="Source media is unavailable") + + media_type = SOURCE_MIME_TYPES.get(path.suffix.lower()) + if media_type is None: + raise HTTPException(status_code=415, detail="Unsupported Source media type") + return FileResponse(path, media_type=media_type, filename=source.upload_name) diff --git a/src/transcription/app.py b/src/transcription/app.py index 32af016..cd4cc31 100644 --- a/src/transcription/app.py +++ b/src/transcription/app.py @@ -17,6 +17,7 @@ from fastapi.staticfiles import StaticFiles from .api.errors import register_error_handlers from .api.health import router as health_router from .api.v4_documents import router as v4_documents_router +from .api.v4_print import router as v4_print_router from .config import Settings from .config import configure_logging from .config import get_settings @@ -105,5 +106,6 @@ def create_app(settings: Settings | None = None) -> FastAPI: register_error_handlers(app) app.include_router(health_router) app.include_router(v4_documents_router) + app.include_router(v4_print_router) register_pages(app) return app diff --git a/src/transcription/db/models.py b/src/transcription/db/models.py index bec8088..dff25c3 100644 --- a/src/transcription/db/models.py +++ b/src/transcription/db/models.py @@ -44,12 +44,6 @@ class JobStatus(StrEnum): FAILED = "failed" -class DocumentPersonRole(StrEnum): - AUTHOR = "author" - RECIPIENT = "recipient" - MENTIONED = "mentioned" - - class JobSourceStatus(StrEnum): PENDING = "pending" TRANSCRIBED = "transcribed" @@ -62,6 +56,7 @@ class DocumentType(SQLModel, table=True): __tablename__ = "document_type" id: UUID = Field(default_factory=uuid4, primary_key=True) + semantic_key: str | None = Field(default=None, index=True, unique=True) label: str normalized_label: str = Field(index=True, unique=True) is_active: bool = True @@ -79,8 +74,9 @@ class PersonRole(SQLModel, table=True): __tablename__ = "person_role" id: UUID = Field(default_factory=uuid4, primary_key=True) - code: str = Field(index=True, unique=True) + semantic_key: str | None = Field(default=None, index=True, unique=True) label: str + normalized_label: str = Field(index=True, unique=True) is_active: bool = True created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) @@ -150,15 +146,11 @@ class DocumentPerson(SQLModel, table=True): id: UUID = Field(default_factory=uuid4, primary_key=True) document_id: UUID = Field(foreign_key="document.id") person_id: UUID = Field(foreign_key="person.id") - role_id: UUID | None = Field(default=None, foreign_key="person_role.id") - role: str = Field(default=DocumentPersonRole.AUTHOR.value, nullable=False) + role_id: UUID = Field(foreign_key="person_role.id") created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) - __table_args__ = ( - UniqueConstraint("document_id", "person_id", "role_id", name="uq_document_person_role_id"), - UniqueConstraint("document_id", "person_id", "role", name="uq_document_person_role"), - ) + __table_args__ = (UniqueConstraint("document_id", "person_id", name="uq_document_person"),) document: Optional["Document"] = Relationship( back_populates="document_people", sa_relationship_kwargs={"lazy": "selectin"} diff --git a/src/transcription/db/operations.py b/src/transcription/db/operations.py index 79e028d..dd63c7e 100644 --- a/src/transcription/db/operations.py +++ b/src/transcription/db/operations.py @@ -16,26 +16,12 @@ from .models import DocumentType from .models import Job from .models import JobStatus from .models import PersonRole +from .registries import BUILT_IN_DOCUMENT_TYPES +from .registries import BUILT_IN_PERSON_ROLES logger = logging.getLogger(__name__) -DEFAULT_PERSON_ROLES: tuple[tuple[str, str], ...] = ( - ("author", "Author"), - ("recipient", "Recipient"), - ("mentioned", "Mentioned"), -) - -DEFAULT_DOCUMENT_TYPES: tuple[str, ...] = ( - "Letter", - "Record", - "Memo", - "Postcard", - "Journal", - "Note", -) - - async def create_all(*, engine: AsyncEngine | None = None) -> None: """Create any missing tables on the selected engine.""" # Import models so SQLModel metadata is fully registered before bootstrap. @@ -44,7 +30,6 @@ async def create_all(*, engine: AsyncEngine | None = None) -> None: active_engine = engine or resolve_engine() async with active_engine.begin() as connection: await connection.run_sync(SQLModel.metadata.create_all) - await _upgrade_document_type_uuid_identity(connection) await _upgrade_person_family_search_id(connection) await _upgrade_v42_evidence_tables(connection) await seed_registry_defaults(engine=active_engine) @@ -55,7 +40,6 @@ async def upgrade_schema(*, engine: AsyncEngine | None = None) -> None: """Apply non-destructive additive upgrades to an existing schema.""" active_engine = engine or resolve_engine() async with active_engine.begin() as connection: - await _upgrade_document_type_uuid_identity(connection) await _upgrade_person_family_search_id(connection) await _upgrade_v42_evidence_tables(connection) @@ -70,73 +54,6 @@ async def _upgrade_v42_evidence_tables(connection: AsyncConnection) -> None: await connection.run_sync(create_tables) -async def _upgrade_document_type_uuid_identity(connection: AsyncConnection) -> None: - """Backfill UUID references and retire legacy Document Type code/order columns.""" - - def inspect_schema(sync_connection) -> tuple[set[str], set[str]]: - database = inspect(sync_connection) - tables = set(database.get_table_names()) - type_columns = ( - {column["name"] for column in database.get_columns("document_type")} if "document_type" in tables else set() - ) - document_columns = ( - {column["name"] for column in database.get_columns("document")} if "document" in tables else set() - ) - return type_columns, document_columns - - type_columns, document_columns = await connection.run_sync(inspect_schema) - if not type_columns: - return - - if "normalized_label" not in type_columns: - await connection.execute(text("ALTER TABLE document_type ADD COLUMN normalized_label VARCHAR")) - await connection.execute( - text("UPDATE document_type SET normalized_label = lower(trim(label)) WHERE normalized_label IS NULL") - ) - - duplicates = ( - await connection.execute( - text("SELECT normalized_label FROM document_type GROUP BY normalized_label HAVING count(*) > 1") - ) - ).first() - if duplicates is not None: - raise RuntimeError( - "Document Type migration requires unique labels ignoring case and whitespace; " - f"duplicate normalized label: {duplicates[0]!r}" - ) - - if "code" in type_columns and {"document_type", "document_type_id"}.issubset(document_columns): - await connection.execute( - text( - "UPDATE document SET document_type_id = (" - "SELECT id FROM document_type WHERE " - "lower(trim(document_type.code)) = lower(trim(document.document_type))" - ") WHERE document_type_id IS NULL AND document_type IS NOT NULL" - ) - ) - - if connection.dialect.name == "postgresql": - await connection.execute(text("ALTER TABLE document_type ALTER COLUMN normalized_label SET NOT NULL")) - if "code" in type_columns: - await connection.execute(text("ALTER TABLE document_type DROP COLUMN code CASCADE")) - if "sort_order" in type_columns: - await connection.execute(text("ALTER TABLE document_type DROP COLUMN sort_order")) - if "document_type" in document_columns: - await connection.execute(text("ALTER TABLE document DROP COLUMN document_type")) - elif connection.dialect.name == "sqlite": - await connection.execute(text("DROP INDEX IF EXISTS ix_document_type_code")) - if "code" in type_columns: - await connection.execute(text("ALTER TABLE document_type DROP COLUMN code")) - if "sort_order" in type_columns: - await connection.execute(text("ALTER TABLE document_type DROP COLUMN sort_order")) - if "document_type" in document_columns: - await connection.execute(text("ALTER TABLE document DROP COLUMN document_type")) - - await connection.execute( - text("CREATE UNIQUE INDEX IF NOT EXISTS ix_document_type_normalized_label ON document_type (normalized_label)") - ) - - async def _upgrade_person_family_search_id(connection: AsyncConnection) -> None: """Add the nullable V4.1 FamilySearch field to an existing database.""" @@ -169,16 +86,27 @@ async def seed_registry_defaults(*, engine: AsyncEngine | None = None) -> None: session_factory = async_sessionmaker(active_engine, class_=AsyncSession, expire_on_commit=False) async with session_factory() as session: - role_codes = set((await session.exec(select(PersonRole.code))).all()) - for code, label in DEFAULT_PERSON_ROLES: - if code not in role_codes: - session.add(PersonRole(code=code, label=label)) + role_keys = set((await session.exec(select(PersonRole.semantic_key))).all()) + for semantic_key, label in BUILT_IN_PERSON_ROLES: + if semantic_key not in role_keys: + session.add( + PersonRole( + semantic_key=semantic_key, + label=label, + normalized_label=label.casefold(), + ) + ) - type_labels = set((await session.exec(select(DocumentType.normalized_label))).all()) - for label in DEFAULT_DOCUMENT_TYPES: - normalized_label = label.casefold() - if normalized_label not in type_labels: - session.add(DocumentType(label=label, normalized_label=normalized_label)) + type_keys = set((await session.exec(select(DocumentType.semantic_key))).all()) + for semantic_key, label in BUILT_IN_DOCUMENT_TYPES: + if semantic_key not in type_keys: + session.add( + DocumentType( + semantic_key=semantic_key, + label=label, + normalized_label=label.casefold(), + ) + ) await session.commit() diff --git a/src/transcription/db/registries.py b/src/transcription/db/registries.py new file mode 100644 index 0000000..992dc43 --- /dev/null +++ b/src/transcription/db/registries.py @@ -0,0 +1,20 @@ +"""Application-defined semantic registry entries.""" + +from __future__ import annotations + +BUILT_IN_DOCUMENT_TYPES: tuple[tuple[str, str], ...] = ( + ("book", "Book"), + ("letter", "Letter"), + ("postcard", "Postcard"), + ("photo", "Photo"), + ("journal", "Journal"), + ("form", "Form"), +) + +BUILT_IN_PERSON_ROLES: tuple[tuple[str, str], ...] = ( + ("author", "Author"), + ("recipient", "Recipient"), + ("mentioned", "Mentioned"), +) + +AUTHOR_ROLE_SEMANTIC_KEY = "author" diff --git a/src/transcription/services/documents.py b/src/transcription/services/documents.py index b29a419..a52db38 100644 --- a/src/transcription/services/documents.py +++ b/src/transcription/services/documents.py @@ -3,6 +3,7 @@ import shutil from collections.abc import Sequence from dataclasses import dataclass from datetime import UTC +from datetime import date from datetime import datetime from uuid import UUID @@ -16,9 +17,11 @@ from sqlmodel.ext.asyncio.session import AsyncSession from ..db.models import Document from ..db.models import DocumentPerson from ..db.models import DocumentType +from ..db.registries import AUTHOR_ROLE_SEMANTIC_KEY from ..errors import AppError from ..errors import ErrorCategory from .base import ServiceBase +from .sources import source_mime_type logger = logging.getLogger(__name__) @@ -65,9 +68,43 @@ class DocumentTypeSummary: id: UUID label: str is_active: bool + is_built_in: bool document_count: int +@dataclass(frozen=True, slots=True) +class DocumentPrintSource: + id: UUID + page_number: int + media_type: str + current_text: str | None + + +@dataclass(frozen=True, slots=True) +class DocumentPrintJob: + id: UUID + date_created: datetime + provider: str | None + model: str | None + prompt_name: str | None + retry_count: int + status: str + + +@dataclass(frozen=True, slots=True) +class DocumentPrintProjection: + id: UUID + title: str + authors: tuple[str, ...] + document_date: date | None + document_date_raw: str | None + location_created: str | None + archive_identifier: str | None + notes: str | None + sources: tuple[DocumentPrintSource, ...] + jobs: tuple[DocumentPrintJob, ...] + + class DocumentService(ServiceBase): """Thin service class for managing documents in the database.""" @@ -256,6 +293,58 @@ class DocumentService(ServiceBase): ) return document + async def read_document_print_projection( + self, + document_id: UUID, + *, + session: AsyncSession | None = None, + ) -> DocumentPrintProjection: + """Build the safe, deterministic read model used by print previews.""" + document = await self.read_document_detail(document_id, session=session) + authors = sorted( + ( + link.person.full_name + for link in document.document_people + if link.person is not None + and link.role_ref is not None + and link.role_ref.semantic_key == AUTHOR_ROLE_SEMANTIC_KEY + ), + key=str.casefold, + ) + sources = tuple( + DocumentPrintSource( + id=source.id, + page_number=source.page_number, + media_type=source_mime_type(source.filename), + current_text=_current_print_text(source.revised_text, source.raw_transcription), + ) + for source in sorted(document.sources, key=lambda item: (item.page_number, item.id)) + ) + jobs = tuple( + DocumentPrintJob( + id=job.id, + date_created=job.date_created, + provider=job.provider, + model=job.model, + prompt_name=job.prompt_name, + retry_count=job.retry_count, + status=getattr(job.status, "value", str(job.status)), + ) + for job in sorted(document.jobs, key=lambda item: (item.date_created, item.id)) + ) + return DocumentPrintProjection( + id=document.id, + title=document.name, + authors=tuple(authors), + document_date=document.document_date, + document_date_raw=document.document_date_raw, + location_created=document.location_created, + archive_identifier=document.archive_identifier, + notes=document.notes, + sources=sources, + jobs=jobs, + ) + async def list_document_types( self, *, @@ -290,6 +379,7 @@ class DocumentService(ServiceBase): id=document_type.id, label=document_type.label, is_active=document_type.is_active, + is_built_in=document_type.semantic_key is not None, document_count=int(document_count), ) for document_type, document_count in rows @@ -383,6 +473,12 @@ class DocumentService(ServiceBase): category=ErrorCategory.NOT_FOUND, suggestion="Refresh Settings and select an available Document Type.", ) + if document_type.semantic_key is not None: + raise DocumentTypeError( + f"Built-in Document Type {document_type.label!r} cannot be deleted", + category=ErrorCategory.CONFLICT, + suggestion="Deactivate the type instead; its built-in meaning must remain available.", + ) if await self._document_type_is_referenced(session=_session, document_type=document_type): raise DocumentTypeError( f"Document Type {document_type.label!r} is referenced and cannot be deleted", @@ -438,3 +534,8 @@ class DocumentService(ServiceBase): document.updated_at = datetime.now(UTC) await self._finalize(session=_session, caller_session=session, refresh=(document,)) return document + + +def _current_print_text(revised_text: str | None, raw_transcription: str | None) -> str | None: + selected = revised_text if revised_text is not None else raw_transcription + return selected if selected is not None and selected.strip() else None diff --git a/src/transcription/services/people.py b/src/transcription/services/people.py index 3dc62e5..c28847b 100644 --- a/src/transcription/services/people.py +++ b/src/transcription/services/people.py @@ -5,12 +5,14 @@ from __future__ import annotations import logging import re 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 from uuid import uuid4 +from sqlalchemy import func from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import selectinload from sqlmodel import select @@ -20,7 +22,6 @@ from ..config import Settings from ..config import get_settings from ..db.models import Document from ..db.models import DocumentPerson -from ..db.models import DocumentPersonRole from ..db.models import Person from ..db.models import PersonRole from ..errors import AppError @@ -45,20 +46,6 @@ class PersonRoleError(PeopleError): """Raised when Person Role maintenance fails.""" -REGISTRY_CODE_PATTERN = re.compile(r"^[a-z0-9_]+$") - - -def _normalize_role_code(code: str) -> str: - normalized = code.strip().lower() - if not normalized or not REGISTRY_CODE_PATTERN.fullmatch(normalized): - raise PersonRoleError( - "Person Role code must contain only lowercase letters, numbers, and underscores", - category=ErrorCategory.VALIDATION, - suggestion="Enter a stable code such as witness or record_keeper.", - ) - return normalized - - def _normalize_role_label(label: str) -> str: normalized = label.strip() if not normalized: @@ -84,6 +71,29 @@ def normalize_family_search_id(value: str | None) -> str | None: return normalized +def _person_role_label_key(label: str) -> str: + return _normalize_role_label(label).casefold() + + +@dataclass(frozen=True, slots=True) +class PersonRoleSummary: + """Settings read model for a Person Role and its usage count.""" + + id: UUID + label: str + is_active: bool + is_built_in: bool + link_count: int + + +@dataclass(frozen=True, slots=True) +class DocumentPersonInput: + """Complete desired relationship for one Person on a Document.""" + + person_id: UUID + role_id: UUID + + class PeopleService(ServiceBase): """Manage People, relationship roles, and document-person links.""" @@ -136,7 +146,7 @@ class PeopleService(ServiceBase): session: AsyncSession | None = None, ) -> DocumentPerson: async with self._session_scope(session) as _session: - await self._sync_role_fields(session=_session, link=document_person) + await self._validate_role(session=_session, role_id=document_person.role_id, require_active=True) _session.add(document_person) return await self._finalize_link(session=_session, caller_session=session, link=document_person) @@ -159,7 +169,14 @@ class PeopleService(ServiceBase): session: AsyncSession | None = None, ) -> DocumentPerson: async with self._session_scope(session) as _session: - await self._sync_role_fields(session=_session, link=document_person) + existing = await _session.get(DocumentPerson, document_person.id) + if existing is None: + raise self._not_found(f"DocumentPerson with id {document_person.id} not found") + await self._validate_role( + session=_session, + role_id=document_person.role_id, + require_active=existing.role_id != document_person.role_id, + ) document_person.updated_at = datetime.now(UTC) merged = await _session.merge(document_person) return await self._finalize_link(session=_session, caller_session=session, link=merged) @@ -204,20 +221,44 @@ class PeopleService(ServiceBase): query = select(PersonRole) if active_only: query = query.where(PersonRole.is_active.is_(True)) - return (await _session.exec(query.order_by(PersonRole.label, PersonRole.code))).all() + return (await _session.exec(query.order_by(PersonRole.normalized_label, PersonRole.id))).all() + + async def list_person_role_summaries( + self, + *, + session: AsyncSession | None = None, + ) -> Sequence[PersonRoleSummary]: + """List Person Roles alphabetically with current link counts.""" + async with self._session_scope(session) as _session: + query = ( + select(PersonRole, func.count(DocumentPerson.id)) + .outerjoin(DocumentPerson, DocumentPerson.role_id == PersonRole.id) + .group_by(PersonRole.id) + .order_by(PersonRole.normalized_label, PersonRole.id) + ) + rows = (await _session.exec(query)).all() + return [ + PersonRoleSummary( + id=role.id, + label=role.label, + is_active=role.is_active, + is_built_in=role.semantic_key is not None, + link_count=int(link_count), + ) + for role, link_count in rows + ] async def create_person_role( self, *, - code: str, label: str, is_active: bool = True, session: AsyncSession | None = None, ) -> PersonRole: - """Create a Person Role with an immutable normalized code.""" + """Create a custom Person Role with a unique label.""" role = PersonRole( - code=_normalize_role_code(code), label=_normalize_role_label(label), + normalized_label=_person_role_label_key(label), is_active=is_active, ) async with self._session_scope(session) as _session: @@ -226,9 +267,9 @@ class PeopleService(ServiceBase): await self._finalize(session=_session, caller_session=session, refresh=(role,)) except IntegrityError as exc: raise PersonRoleError( - f"Person Role code {role.code!r} already exists", + f"Person Role label {role.label!r} already exists", category=ErrorCategory.CONFLICT, - suggestion="Choose a different stable code or edit the existing role.", + suggestion="Choose a different label or edit the existing role.", ) from exc return role @@ -257,7 +298,7 @@ class PeopleService(ServiceBase): is_active: bool, session: AsyncSession | None = None, ) -> PersonRole: - """Update mutable Person Role fields without changing its code.""" + """Update mutable Person Role fields without changing semantic identity.""" async with self._session_scope(session) as _session: role = await _session.get(PersonRole, person_role_id) if role is None: @@ -267,9 +308,17 @@ class PeopleService(ServiceBase): suggestion="Refresh Settings and select an available Person Role.", ) role.label = _normalize_role_label(label) + role.normalized_label = _person_role_label_key(label) role.is_active = is_active role.updated_at = datetime.now(UTC) - await self._finalize(session=_session, caller_session=session, refresh=(role,)) + try: + await self._finalize(session=_session, caller_session=session, refresh=(role,)) + except IntegrityError as exc: + raise PersonRoleError( + f"Person Role label {role.label!r} already exists", + category=ErrorCategory.CONFLICT, + suggestion="Choose a different label or edit the existing role.", + ) from exc return role async def delete_person_role( @@ -287,6 +336,12 @@ class PeopleService(ServiceBase): category=ErrorCategory.NOT_FOUND, suggestion="Refresh Settings and select an available Person Role.", ) + if role.semantic_key is not None: + raise PersonRoleError( + f"Built-in Person Role {role.label!r} cannot be deleted", + category=ErrorCategory.CONFLICT, + suggestion="Deactivate the role instead; its built-in meaning must remain available.", + ) if await self._person_role_is_referenced(session=_session, role=role): raise PersonRoleError( f"Person Role {role.label!r} is referenced and cannot be deleted", @@ -302,7 +357,7 @@ class PeopleService(ServiceBase): *, session: AsyncSession | None = None, ) -> bool: - """Return whether canonical or compatibility data references a Person Role.""" + """Return whether a document-person link references a Person Role.""" async with self._session_scope(session) as _session: role = await _session.get(PersonRole, person_role_id) if role is None: @@ -319,15 +374,26 @@ class PeopleService(ServiceBase): session: AsyncSession, role: PersonRole, ) -> bool: - reference = ( - await session.exec( - select(DocumentPerson.id).where( - (DocumentPerson.role_id == role.id) | (DocumentPerson.role == role.code) - ) - ) - ).first() + reference = (await session.exec(select(DocumentPerson.id).where(DocumentPerson.role_id == role.id))).first() return reference is not None + async def read_person_role_by_semantic_key( + self, + semantic_key: str, + *, + session: AsyncSession | None = None, + ) -> PersonRole: + """Resolve one application-defined built-in role.""" + async with self._session_scope(session) as _session: + role = (await _session.exec(select(PersonRole).where(PersonRole.semantic_key == semantic_key))).first() + if role is None: + raise PersonRoleError( + f"Built-in Person Role {semantic_key!r} is unavailable", + category=ErrorCategory.NOT_FOUND, + suggestion="Recreate the built-in registry rows and retry.", + ) + return role + async def list_document_people( self, *, @@ -352,8 +418,7 @@ class PeopleService(ServiceBase): *, document_id: UUID, person_id: UUID, - role_id: UUID | None = None, - role_code: str | None = None, + role_id: UUID, session: AsyncSession | None = None, ) -> DocumentPerson: async with self._session_scope(session) as _session: @@ -361,15 +426,8 @@ class PeopleService(ServiceBase): if await _session.get(Person, person_id) is None: raise self._not_found(f"Person with id {person_id} not found") - link = DocumentPerson( - document_id=document_id, - person_id=person_id, - role=DocumentPersonRole.AUTHOR, - role_id=role_id, - ) - if role_code and role_code.strip(): - link.role = self._legacy_role(role_code) - await self._sync_role_fields(session=_session, link=link) + await self._validate_role(session=_session, role_id=role_id, require_active=True) + link = DocumentPerson(document_id=document_id, person_id=person_id, role_id=role_id) _session.add(link) return await self._finalize_link(session=_session, caller_session=session, link=link) @@ -377,31 +435,19 @@ class PeopleService(ServiceBase): self, *, document_person_id: UUID, - role_id: UUID | None = None, - role_code: str | None = None, + role_id: UUID, session: AsyncSession | None = None, ) -> DocumentPerson: async with self._session_scope(session) as _session: link = await _session.get(DocumentPerson, document_person_id) if link is None: raise self._not_found(f"DocumentPerson with id {document_person_id} not found") - if role_id is None and not (role_code or "").strip(): - raise PeopleError( - "Either role_id or role_code is required", - category=ErrorCategory.VALIDATION, - suggestion="Provide a valid role id or code and retry.", - ) - if role_id is not None and (role_code or "").strip(): - raise PeopleError( - "Provide role_id or role_code, not both", - category=ErrorCategory.VALIDATION, - suggestion="Send only one relationship role selector and retry.", - ) - + await self._validate_role( + session=_session, + role_id=role_id, + require_active=link.role_id != role_id, + ) link.role_id = role_id - if role_code and role_code.strip(): - link.role = self._legacy_role(role_code) - await self._sync_role_fields(session=_session, link=link) link.updated_at = datetime.now(UTC) return await self._finalize_link(session=_session, caller_session=session, link=link) @@ -418,37 +464,97 @@ class PeopleService(ServiceBase): await _session.delete(link) await self._finalize(session=_session, caller_session=session) - async def _resolve_or_create_role( + async def sync_document_people( + self, + *, + document_id: UUID, + links: Sequence[DocumentPersonInput], + session: AsyncSession | None = None, + ) -> Sequence[DocumentPerson]: + """Synchronize one Document's complete Person link set.""" + person_ids = [link.person_id for link in links] + if len(person_ids) != len(set(person_ids)): + raise PeopleError( + "A Person can be linked to a Document only once", + category=ErrorCategory.CONFLICT, + suggestion="Edit the existing Linked People row instead of adding another.", + ) + + async with self._session_scope(session) as _session: + await self._require_document(session=_session, document_id=document_id) + existing_links = ( + await _session.exec(select(DocumentPerson).where(DocumentPerson.document_id == document_id)) + ).all() + existing_by_person = {link.person_id: link for link in existing_links} + desired_by_person = {link.person_id: link for link in links} + + roles: dict[UUID, PersonRole] = {} + for desired in links: + if await _session.get(Person, desired.person_id) is None: + raise self._not_found(f"Person with id {desired.person_id} not found") + role = roles.get(desired.role_id) + if role is None: + role = await self._validate_role(session=_session, role_id=desired.role_id) + roles[desired.role_id] = role + current = existing_by_person.get(desired.person_id) + if (current is None or current.role_id != desired.role_id) and not role.is_active: + raise PeopleError( + f"Inactive Person Role {role.label!r} cannot be assigned", + category=ErrorCategory.VALIDATION, + suggestion="Select an active Person Role and retry.", + ) + + for person_id, existing in existing_by_person.items(): + if person_id not in desired_by_person: + await _session.delete(existing) + + synchronized: list[DocumentPerson] = [] + for desired in links: + existing = existing_by_person.get(desired.person_id) + if existing is None: + existing = DocumentPerson( + document_id=document_id, + person_id=desired.person_id, + role_id=desired.role_id, + ) + _session.add(existing) + elif existing.role_id != desired.role_id: + existing.role_id = desired.role_id + existing.updated_at = datetime.now(UTC) + synchronized.append(existing) + + try: + await self._finalize(session=_session, caller_session=session, refresh=synchronized) + except IntegrityError as exc: + raise PeopleError( + "A Person can be linked to a Document only once", + category=ErrorCategory.CONFLICT, + suggestion="Edit the existing Linked People row instead of adding another.", + ) from exc + return synchronized + + async def _validate_role( self, *, session: AsyncSession, - role_id: UUID | None, - role_code: str, + role_id: UUID, + require_active: bool = False, ) -> PersonRole: - if role_id is not None: - role = await session.get(PersonRole, role_id) - if role is None: - raise PeopleError( - f"Person role with id {role_id} not found", - category=ErrorCategory.VALIDATION, - suggestion="Select a valid relationship role and retry.", - ) - return role - - normalized_code = role_code.strip().lower() or DocumentPersonRole.AUTHOR.value - role = (await session.exec(select(PersonRole).where(PersonRole.code == normalized_code))).first() + role = await session.get(PersonRole, role_id) if role is None: - role = PersonRole(code=normalized_code, label=normalized_code.replace("_", " ").title()) - session.add(role) - await session.flush() + raise PeopleError( + f"Person role with id {role_id} not found", + category=ErrorCategory.VALIDATION, + suggestion="Select a valid relationship role and retry.", + ) + if require_active and not role.is_active: + raise PeopleError( + f"Inactive Person Role {role.label!r} cannot be assigned", + category=ErrorCategory.VALIDATION, + suggestion="Select an active Person Role and retry.", + ) return role - async def _sync_role_fields(self, *, session: AsyncSession, link: DocumentPerson) -> None: - role_code = link.role.value if isinstance(link.role, DocumentPersonRole) else str(link.role) - role = await self._resolve_or_create_role(session=session, role_id=link.role_id, role_code=role_code) - link.role_id = role.id - link.role = self._legacy_role(role.code) - async def _finalize_link( self, *, @@ -460,9 +566,9 @@ class PeopleService(ServiceBase): await self._finalize(session=session, caller_session=caller_session, refresh=(link,)) except IntegrityError as exc: raise PeopleError( - "Duplicate relationship link for document/person/role", + "This Person is already linked to the Document", category=ErrorCategory.CONFLICT, - suggestion="Remove the existing relationship link or choose a different role.", + suggestion="Edit the existing relationship instead of adding another one.", ) from exc return link @@ -478,10 +584,6 @@ class PeopleService(ServiceBase): suggestion="Open the existing person record or enter a different FamilySearch ID.", ) - @staticmethod - def _legacy_role(role_code: str) -> str: - return _normalize_role_code(role_code) - @staticmethod def _not_found(message: str) -> PeopleError: return PeopleError( diff --git a/src/transcription/services/workflows.py b/src/transcription/services/workflows.py index 172973c..eb84439 100644 --- a/src/transcription/services/workflows.py +++ b/src/transcription/services/workflows.py @@ -9,10 +9,12 @@ from sqlmodel.ext.asyncio.session import AsyncSession from ..config import Settings from ..config import get_settings +from ..db.models import Document from ..db.models import Job from ..db.models import JobSourceStatus from ..db.models import JobStatus from ..db.models import Source +from ..db.session import transaction_scope from ..errors import AppError from ..errors import ErrorCategory from ..errors import classify_unexpected_error @@ -24,6 +26,9 @@ from ..providers import TranscriptionProvider from ..providers import TranscriptionResult from ..providers import TransportEvidence from . import ServiceBundle +from .documents import DocumentService +from .people import DocumentPersonInput +from .people import PeopleService from .sources import PromptExecution from .sources import build_prompt_execution from .sources import hash_prompt_text @@ -33,6 +38,34 @@ from .sources import transcribe_document_image logger = logging.getLogger(__name__) +async def create_document_with_people( + *, + document: Document, + links: list[DocumentPersonInput], + documents: DocumentService, + people: PeopleService, +) -> Document: + """Create a Document and its complete Linked People set atomically.""" + async with transaction_scope(session_factory=documents.session_factory) as session: + created = await documents.create_document(document, session=session) + await people.sync_document_people(document_id=created.id, links=links, session=session) + return created + + +async def update_document_with_people( + *, + document: Document, + links: list[DocumentPersonInput], + documents: DocumentService, + people: PeopleService, +) -> Document: + """Update a Document and its complete Linked People set atomically.""" + async with transaction_scope(session_factory=documents.session_factory) as session: + updated = await documents.update_document(document, session=session) + await people.sync_document_people(document_id=updated.id, links=links, session=session) + return updated + + @dataclass(frozen=True) class _SuccessfulPage: source: Source @@ -392,9 +425,7 @@ async def _persist_page_outcome_durably( session: AsyncSession | None, ) -> None: """Commit one completed provider call before processing the next source.""" - task = asyncio.create_task( - _persist_page_outcome(job=job, services=services, page=page, session=session) - ) + task = asyncio.create_task(_persist_page_outcome(job=job, services=services, page=page, session=session)) try: await asyncio.shield(task) except asyncio.CancelledError: diff --git a/src/transcription/ui/__init__.py b/src/transcription/ui/__init__.py index ad27e35..9e27752 100644 --- a/src/transcription/ui/__init__.py +++ b/src/transcription/ui/__init__.py @@ -10,6 +10,7 @@ from transcription.ui.pages.documents_page import register_page as register_docu from transcription.ui.pages.home_page import register_page as register_home_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.print_preview_page import register_page as register_print_preview_page from transcription.ui.pages.settings_page import register_page as register_settings_page from transcription.ui.pages.sources_page import register_page as register_sources_page from transcription.ui.resources import read_css @@ -37,6 +38,7 @@ def register_pages(app: FastAPI) -> None: register_home_page() register_documents_page() register_people_page() + register_print_preview_page() register_sources_page() register_jobs_page() register_settings_page(settings=getattr(app.state, "settings", None) or get_settings()) diff --git a/src/transcription/ui/components/linked_people.py b/src/transcription/ui/components/linked_people.py new file mode 100644 index 0000000..c19591b --- /dev/null +++ b/src/transcription/ui/components/linked_people.py @@ -0,0 +1,180 @@ +"""Shared staged Linked People editor.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +from uuid import UUID + +from nicegui import ui + +from transcription.db.models import DocumentPerson +from transcription.db.models import Person +from transcription.db.models import PersonRole +from transcription.services.people import DocumentPersonInput +from transcription.ui.components.formatters import person_selector_label +from transcription.ui.components.primitives import destructive_button + + +@dataclass(frozen=True, slots=True) +class StagedLinkedPerson: + person_id: UUID + role_id: UUID + + +class LinkedPeopleEditor: + """Render and retain an unsaved one-role-per-Person link set.""" + + def __init__( + self, + *, + people: list[Person], + roles: list[PersonRole], + initial_links: list[DocumentPerson] | None = None, + staged_links: list[StagedLinkedPerson] | None = None, + ) -> None: + self.people = {person.id: person for person in people} + self.roles = {role.id: role for role in roles} + self.links = list(staged_links or self._from_existing(initial_links or [])) + self.mode: str | None = None + self.editing_person_id: UUID | None = None + self.table: Any = None + + @staticmethod + def _from_existing(links: list[DocumentPerson]) -> list[StagedLinkedPerson]: + return [StagedLinkedPerson(person_id=link.person_id, role_id=link.role_id) for link in links] + + def values(self) -> list[DocumentPersonInput]: + """Return the complete staged link set for persistence.""" + return [DocumentPersonInput(person_id=link.person_id, role_id=link.role_id) for link in self.links] + + @ui.refreshable + def render(self) -> None: + ui.label("Linked People").classes("text-sm font-semibold ui-text-primary mt-2") + ui.button("Create new person", on_click=lambda: ui.navigate.to("/people/new"), icon="person_add").props( + "flat dense" + ).classes("self-start") + + rows = [ + { + "person_id": str(link.person_id), + "person": self._person_label(link.person_id), + "role": self._role_label(link.role_id), + } + for link in sorted(self.links, key=lambda item: self._person_label(item.person_id).casefold()) + ] + self.table = ui.table( + columns=[ + {"name": "person", "label": "Person", "field": "person", "align": "left", "sortable": True}, + {"name": "role", "label": "Role", "field": "role", "align": "left", "sortable": True}, + ], + rows=rows, + row_key="person_id", + selection="multiple", + pagination={"rowsPerPage": 0, "sortBy": "person"}, + ).classes("w-full ui-table") + + with ui.row().classes("w-full items-center gap-2"): + ui.button("Add", icon="add", on_click=self._begin_add).classes("ui-btn-primary") + ui.button("Edit", icon="edit", on_click=self._begin_edit).props("flat") + destructive_button("Delete", icon="delete", on_click=self._delete_selected) + + if self.mode is not None: + self._render_inline_editor() + + def _render_inline_editor(self) -> None: + current = self._editing_link() + person_options = { + str(person_id): person_selector_label(person) + for person_id, person in sorted( + self.people.items(), + key=lambda item: person_selector_label(item[1]).casefold(), + ) + if person_id == self.editing_person_id or all(link.person_id != person_id for link in self.links) + } + role_options = { + str(role_id): self._role_option_label(role) + for role_id, role in sorted(self.roles.items(), key=lambda item: item[1].normalized_label) + if role.is_active or (current is not None and role_id == current.role_id) + } + with ui.column().classes("w-full gap-2 p-3 ui-row-surface"): + ui.label("Add Linked Person" if self.mode == "add" else "Edit Linked Person").classes("font-medium") + person_input = ui.select(person_options, label="Person").props("outlined").classes("w-full") + role_input = ui.select(role_options, label="Person Role").props("outlined").classes("w-full") + if current is not None: + person_input.value = str(current.person_id) + role_input.value = str(current.role_id) + + def save() -> None: + person_id = self._parse_uuid(person_input.value) + role_id = self._parse_uuid(role_input.value) + if person_id is None or role_id is None: + ui.notify("Select both a Person and Person Role.", type="warning") + return + if any(link.person_id == person_id and link.person_id != self.editing_person_id for link in self.links): + ui.notify("That Person is already linked to this Document.", type="warning") + return + replacement = StagedLinkedPerson(person_id=person_id, role_id=role_id) + if self.mode == "edit": + self.links = [ + replacement if link.person_id == self.editing_person_id else link for link in self.links + ] + else: + self.links.append(replacement) + self._close_editor() + + with ui.row().classes("w-full justify-end gap-2"): + ui.button("Cancel", on_click=self._close_editor).props("flat") + ui.button("Save", icon="save", on_click=save).classes("ui-btn-primary") + + def _begin_add(self) -> None: + self.mode = "add" + self.editing_person_id = None + self.render.refresh() + + def _begin_edit(self) -> None: + selected = self.table.selected or [] + if len(selected) != 1: + ui.notify("Select one Linked Person to edit.", type="warning") + return + self.mode = "edit" + self.editing_person_id = UUID(str(selected[0]["person_id"])) + self.render.refresh() + + def _delete_selected(self) -> None: + selected = self.table.selected or [] + if not selected: + ui.notify("Select one or more Linked People to delete.", type="warning") + return + selected_ids = {UUID(str(row["person_id"])) for row in selected} + self.links = [link for link in self.links if link.person_id not in selected_ids] + self._close_editor() + + def _close_editor(self) -> None: + self.mode = None + self.editing_person_id = None + self.render.refresh() + + def _editing_link(self) -> StagedLinkedPerson | None: + if self.editing_person_id is None: + return None + return next((link for link in self.links if link.person_id == self.editing_person_id), None) + + def _person_label(self, person_id: UUID) -> str: + person = self.people.get(person_id) + return person_selector_label(person) if person is not None else "Unknown person" + + def _role_label(self, role_id: UUID) -> str: + role = self.roles.get(role_id) + return role.label if role is not None else "Unknown role" + + @staticmethod + def _role_option_label(role: PersonRole) -> str: + return role.label if role.is_active else f"{role.label} (inactive)" + + @staticmethod + def _parse_uuid(value: Any) -> UUID | None: + try: + return UUID(str(value)) + except (TypeError, ValueError): + return None diff --git a/src/transcription/ui/pages/documents_page.py b/src/transcription/ui/pages/documents_page.py index 0c577b6..6e60fd2 100644 --- a/src/transcription/ui/pages/documents_page.py +++ b/src/transcription/ui/pages/documents_page.py @@ -11,18 +11,22 @@ from fastapi.responses import RedirectResponse from nicegui import ui from transcription.db.models import Document +from transcription.db.registries import AUTHOR_ROLE_SEMANTIC_KEY 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.services.people import PeopleService +from transcription.services.workflows import create_document_with_people +from transcription.services.workflows import update_document_with_people from transcription.ui.components.app_shell import render_navigation_header from transcription.ui.components.cards import archival_card from transcription.ui.components.data_display import archival_badge from transcription.ui.components.data_display import metadata_row from transcription.ui.components.error_presenter import show_error from transcription.ui.components.formatters import compact_date -from transcription.ui.components.formatters import person_selector_label +from transcription.ui.components.linked_people import LinkedPeopleEditor +from transcription.ui.components.linked_people import StagedLinkedPerson from transcription.ui.components.primitives import destructive_button from transcription.ui.components.primitives import render_empty_state from transcription.ui.components.primitives import section_header_row @@ -47,20 +51,32 @@ def register_page() -> None: # noqa: PLR0915 page_header("Create Document", subtitle="Document name and type are required.") people = sorted(await people_service.list_people(), key=lambda item: item.full_name.casefold()) - role_catalog = await people_service.list_person_roles() + role_catalog = list(await people_service.list_person_roles(active_only=False)) type_catalog = await document_service.list_document_types() requested_person_id = _parse_uuid(request.query_params.get("person_id")) - selected_people_by_role: dict[str, list[UUID]] = {} + staged_links: list[StagedLinkedPerson] = [] if requested_person_id is not None and any(person.id == requested_person_id for person in people): - selected_people_by_role["author"] = [requested_person_id] + try: + author_role = await people_service.read_person_role_by_semantic_key(AUTHOR_ROLE_SEMANTIC_KEY) + if author_role.is_active: + staged_links.append(StagedLinkedPerson(person_id=requested_person_id, role_id=author_role.id)) + else: + ui.notify( + "The Author role is inactive, so the Person could not be preselected.", + type="warning", + ) + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Author role unavailable", operation="documents.create.preselect") elif request.query_params.get("person_id"): ui.notify("The requested person could not be preselected.", type="warning") - form = _render_document_form_fields( + linked_people = LinkedPeopleEditor( people=people, - role_codes=[role.code for role in role_catalog], - role_labels={role.code: role.label for role in role_catalog}, + roles=role_catalog, + staged_links=staged_links, + ) + form = _render_document_form_fields( type_options={str(doc_type.id): doc_type.label for doc_type in type_catalog}, - selected_people_by_role=selected_people_by_role, + linked_people=linked_people, ) return_to = request.query_params.get("return_to") @@ -91,25 +107,16 @@ def register_page() -> None: # noqa: PLR0915 ) try: - created = await document_service.create_document(candidate) + created = await create_document_with_people( + document=candidate, + links=linked_people.values(), + documents=document_service, + people=people_service, + ) except Exception as exc: # noqa: BLE001 show_error(exc, title="Create failed", operation="documents.create") return - desired_links = _collect_role_link_candidates( - form["role_people"], role_codes=[role.code for role in role_catalog] - ) - try: - for role_code, person_id in sorted(desired_links): - await people_service.add_document_person_link( - document_id=created.id, - person_id=person_id, - role_code=role_code, - ) - except Exception as exc: # noqa: BLE001 - show_error(exc, title="Relationship link failed", operation="documents.create.link_people") - return - ui.notify("Document created", type="positive") if return_to == "jobs_new": ui.navigate.to(f"/jobs/new?document_id={created.id}") @@ -149,7 +156,7 @@ def register_page() -> None: # noqa: PLR0915 id=doc.id, name=doc.name, document_type=(doc.document_type_ref.label if doc.document_type_ref is not None else ""), - authors=", ".join(_group_people_labels_by_role(doc).get("author", [])), + authors=", ".join(_author_names(doc)), document_date=compact_date(doc.document_date, doc.document_date_raw), archive_identifier=doc.archive_identifier or "", ) @@ -182,6 +189,11 @@ def register_page() -> None: # noqa: PLR0915 page_header(document.name, subtitle=f"Type: {type_display} | ID: {document.id}") with ui.row().classes("items-center gap-2"): + ui.button( + "Print", + on_click=lambda: ui.navigate.to(f"/documents/{document.id}/print"), + icon="print", + ).props("flat").classes("text-xs") ui.button( "Edit Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"), @@ -278,16 +290,17 @@ def register_page() -> None: # noqa: PLR0915 page_header("Edit Document Record", subtitle="Document name and document type are required.") people = sorted(await people_service.list_people(), key=lambda item: item.full_name.casefold()) - role_catalog = await people_service.list_person_roles() + role_catalog = list(await people_service.list_person_roles(active_only=False)) type_catalog = await document_service.list_document_types(active_only=False) - existing_by_role = _existing_people_by_role(document) + linked_people = LinkedPeopleEditor( + people=people, + roles=role_catalog, + initial_links=list(document.document_people), + ) form = _render_document_form_fields( document=document, - people=people, - role_codes=[role.code for role in role_catalog], - role_labels={role.code: role.label for role in role_catalog}, type_options={str(doc_type.id): doc_type.label for doc_type in type_catalog}, - selected_people_by_role=existing_by_role, + linked_people=linked_people, ) async def submit_edit() -> None: @@ -319,34 +332,16 @@ def register_page() -> None: # noqa: PLR0915 ) try: - await document_service.update_document(candidate) + await update_document_with_people( + document=candidate, + links=linked_people.values(), + documents=document_service, + people=people_service, + ) except Exception as exc: # noqa: BLE001 show_error(exc, title="Save failed", operation="documents.edit.save") return - existing_links = { - (_resolve_link_role_code(link), link.person_id): link - for link in document.document_people - if _resolve_link_role_code(link) is not None - } - desired_links = _collect_role_link_candidates( - form["role_people"], role_codes=[role.code for role in role_catalog] - ) - try: - for role_code, person_id in sorted(desired_links - set(existing_links.keys())): - await people_service.add_document_person_link( - document_id=document.id, - person_id=person_id, - role_code=role_code, - ) - - for stale_key in sorted(set(existing_links.keys()) - desired_links): - stale_link = existing_links[stale_key] - await people_service.remove_document_person_link(document_person_id=stale_link.id) - except Exception as exc: # noqa: BLE001 - show_error(exc, title="Relationship update failed", operation="documents.edit.link_people") - return - ui.notify("Document updated", type="positive") ui.navigate.to(f"/documents/{document.id}") @@ -444,11 +439,8 @@ def register_page() -> None: # noqa: PLR0915 def _render_document_form_fields( *, document: Document | None = None, - people: list[Any], - role_codes: list[str], - role_labels: dict[str, str], type_options: dict[str, str], - selected_people_by_role: dict[str, list[UUID]] | None = None, + linked_people: LinkedPeopleEditor, ) -> dict[str, Any]: with archival_card(extra_classes="gap-3"): name_input = ( @@ -510,27 +502,7 @@ def _render_document_form_fields( .classes("w-full ui-form-surface") ) - ui.label("Linked People by Role").classes("text-sm font-semibold ui-text-primary mt-2") - ui.button("Create new person", on_click=lambda: ui.navigate.to("/people/new"), icon="person_add").props( - "flat dense" - ).classes("self-start") - people_options = {str(p.id): person_selector_label(p) for p in people} - existing = selected_people_by_role or {} - role_people_inputs: dict[str, Any] = {} - for role_code in role_codes: - label = role_labels.get(role_code, role_code.replace("_", " ").title()) - current_people = [str(person_id) for person_id in existing.get(role_code, [])] - role_people_inputs[role_code] = ( - ui.select( - people_options, - label=f"{label} people", - multiple=True, - ) - .props("outlined use-chips") - .classes("w-full ui-form-surface") - ) - if current_people: - role_people_inputs[role_code].value = current_people + linked_people.render() return { "name": name_input, @@ -541,7 +513,6 @@ def _render_document_form_fields( "location": location_input, "archive": archive_input, "notes": notes_input, - "role_people": role_people_inputs, } @@ -552,8 +523,7 @@ def _render_bento_viewer_zone(document: Document) -> None: def _render_bento_metadata_zone(document: Document) -> None: - people_by_role = _group_people_labels_by_role(document) - author_names = people_by_role.get("author", []) + author_names = _author_names(document) with ui.column().classes("col-span-12 lg:col-span-4 gap-4"): with archival_card(title="Archival Metadata"): @@ -585,10 +555,10 @@ def _render_related_people_card(document: Document) -> None: grouped = _group_people_by_role(document) with ui.column().classes("w-full gap-2"): - for role_code in sorted(grouped.keys()): + for role_label in sorted(grouped.keys(), key=str.casefold): with ui.column().classes("w-full ui-row-surface p-2 gap-1"): - archival_badge(role_code) - for person in grouped[role_code]: + archival_badge(role_label) + for person in grouped[role_label]: ui.button( person.full_name, on_click=lambda _=None, person_id=person.id: ui.navigate.to(f"/people/{person_id}"), @@ -641,58 +611,24 @@ def _resolve_selected_document_type_id(selected_value: Any, type_options: dict[s return _parse_uuid(selected_id) -def _resolve_link_role_code(link: Any) -> str | None: - if getattr(link, "role_ref", None) is not None and getattr(link.role_ref, "code", None): - return str(link.role_ref.code) - role = getattr(link, "role", None) - if role is None: - return None - value = getattr(role, "value", role) - return str(value) - - -def _existing_people_by_role(document: Document) -> dict[str, list[UUID]]: - people_by_role: dict[str, list[UUID]] = {} - for link in document.document_people: - role_code = _resolve_link_role_code(link) - if role_code is None: - continue - people_by_role.setdefault(role_code, []).append(link.person_id) - return people_by_role - - -def _collect_role_link_candidates( - role_people_inputs: dict[str, Any], *, role_codes: list[str] -) -> set[tuple[str, UUID]]: - desired: set[tuple[str, UUID]] = set() - for role_code in role_codes: - selected = role_people_inputs[role_code].value or [] - selected_ids = [selected] if isinstance(selected, str) else list(selected) - - for selected_id in selected_ids: - parsed = _parse_uuid(selected_id) - if parsed is not None: - desired.add((role_code, parsed)) - return desired - - -def _group_people_labels_by_role(document: Document) -> dict[str, list[str]]: - grouped: dict[str, list[str]] = {} - for link in document.document_people: - role_code = _resolve_link_role_code(link) - if role_code is None: - continue - person_label = link.person.full_name if link.person is not None else "Unknown person" - grouped.setdefault(role_code, []).append(person_label) - return grouped - - def _group_people_by_role(document: Document) -> dict[str, list[Any]]: grouped: dict[str, list[Any]] = {} for link in document.document_people: - role_code = _resolve_link_role_code(link) - if role_code is not None and link.person is not None: - grouped.setdefault(role_code, []).append(link.person) + if link.role_ref is not None and link.person is not None: + grouped.setdefault(link.role_ref.label, []).append(link.person) for people in grouped.values(): people.sort(key=lambda person: person.full_name.casefold()) return grouped + + +def _author_names(document: Document) -> list[str]: + return sorted( + ( + link.person.full_name + for link in document.document_people + if link.person is not None + and link.role_ref is not None + and link.role_ref.semantic_key == AUTHOR_ROLE_SEMANTIC_KEY + ), + key=str.casefold, + ) diff --git a/src/transcription/ui/pages/people_page.py b/src/transcription/ui/pages/people_page.py index cd6e614..2444da1 100644 --- a/src/transcription/ui/pages/people_page.py +++ b/src/transcription/ui/pages/people_page.py @@ -473,11 +473,11 @@ def _render_linked_documents(person: Person) -> None: doc = link.document if doc is None: continue - role_code = link.role_ref.code if link.role_ref is not None else str(link.role) + role_label = link.role_ref.label if link.role_ref is not None else "Unknown role" with ui.row().classes("w-full justify-between items-center ui-row-surface p-2"): with ui.column().classes("gap-0"): ui.label(doc.name).classes("text-xs font-semibold ui-text-primary") - ui.label(f"Role: {role_code}").classes("text-[10px] ui-text-muted") + ui.label(f"Role: {role_label}").classes("text-[10px] ui-text-muted") ui.button( "Open", on_click=lambda _=None, doc_id=doc.id: ui.navigate.to(f"/documents/{doc_id}"), diff --git a/src/transcription/ui/pages/print_preview_page.py b/src/transcription/ui/pages/print_preview_page.py new file mode 100644 index 0000000..17cffcf --- /dev/null +++ b/src/transcription/ui/pages/print_preview_page.py @@ -0,0 +1,165 @@ +"""Browser-native Document print preview.""" + +from __future__ import annotations + +import re +from uuid import UUID + +from nicegui import ui + +from transcription.services.documents import DocumentError +from transcription.services.documents import DocumentPrintJob +from transcription.services.documents import DocumentPrintProjection +from transcription.services.documents import DocumentPrintSource +from transcription.services.documents import DocumentService +from transcription.ui.components.error_presenter import show_error +from transcription.ui.components.formatters import compact_date +from transcription.ui.theme import page_header + +from ...db.session import SessionFactoryDep + +PRINT_UNAVAILABLE = "Transcription unavailable" + + +def register_page() -> None: + """Register the persisted Document print-preview route.""" + + @ui.page("/documents/{document_id}/print") + async def document_print_preview_page(document_id: str, session_factory: SessionFactoryDep) -> None: + try: + parsed_id = UUID(document_id) + except ValueError: + ui.label("Invalid document id").classes("text-h6 ui-text-danger p-4") + return + + service = DocumentService(session_factory=session_factory) + try: + projection = await service.read_document_print_projection(parsed_id) + except DocumentError: + ui.label("Document not found").classes("text-h6 ui-text-danger p-4") + return + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Print preview unavailable", operation="documents.print.read") + return + + mode = {"value": "facsimile"} + with ui.column().classes("print-preview w-full max-w-7xl mx-auto p-4 gap-4"): + with ui.row().classes("no-print w-full items-center justify-between gap-3"): + page_header("Print Document", subtitle=projection.title) + with ui.row().classes("items-center gap-2"): + format_input = ui.toggle( + {"facsimile": "Facsimile", "text": "Text only"}, + value=mode["value"], + ) + ui.button("Print", icon="print", on_click=lambda: ui.run_javascript("window.print()")).classes( + "ui-btn-primary" + ) + ui.button( + "Back", + icon="arrow_back", + on_click=lambda: ui.navigate.to(f"/documents/{projection.id}"), + ).props("flat") + + @ui.refreshable + def render_preview() -> None: + _render_print_document(projection, mode=str(format_input.value or "facsimile")) + + format_input.on_value_change(lambda event: (mode.update(value=event.value), render_preview.refresh())) + render_preview() + + +def _render_print_document(projection: DocumentPrintProjection, *, mode: str) -> None: + with ui.column().classes("print-document w-full gap-5"): + ui.label(projection.title).classes("print-title text-3xl font-bold") + ui.label("Archival Metadata").classes("print-section-title text-xl font-semibold") + _render_metadata_table(projection) + + ui.label("Notes").classes("print-section-title text-xl font-semibold") + ui.label(projection.notes or "No notes recorded").classes("print-notes whitespace-pre-wrap") + + ui.label("Document").classes("print-section-title text-xl font-semibold") + if not projection.sources: + ui.label("No Source pages are linked to this Document.").classes("ui-text-muted") + for source in projection.sources: + classes = "print-source w-full gap-3" + if mode == "facsimile": + classes += " print-page-break" + with ui.column().classes(classes): + ui.label(f"Page {source.page_number}").classes("text-lg font-semibold") + text = source.current_text or PRINT_UNAVAILABLE + if mode == "facsimile": + _render_facsimile_source(document_id=projection.id, source=source, text=text) + else: + for paragraph in reflow_transcription(text): + ui.label(paragraph).classes("print-transcription") + + ui.label("Transcription Job Metadata").classes("print-section-title text-xl font-semibold") + _render_job_table(projection.jobs) + + +def _render_facsimile_source(*, document_id: UUID, source: DocumentPrintSource, text: str) -> None: + with ui.row().classes("print-facsimile-row w-full items-start gap-4"): + media_url = f"/api/v4/documents/{document_id}/sources/{source.id}/media" + if source.media_type == "application/pdf": + ui.html( + f'' + ) + else: + ui.image(media_url).classes("print-source-image") + ui.label(text).classes("print-transcription print-preserve-lines") + + +def _render_metadata_table(projection: DocumentPrintProjection) -> None: + rows = [ + {"field": "Author", "value": ", ".join(projection.authors) or "Not set"}, + { + "field": "Date", + "value": compact_date(projection.document_date, projection.document_date_raw) or "Not set", + }, + {"field": "Location Created", "value": projection.location_created or "Not set"}, + {"field": "Archival Identifier", "value": projection.archive_identifier or "Not set"}, + ] + ui.table( + columns=[ + {"name": "field", "label": "", "field": "field", "align": "left"}, + {"name": "value", "label": "", "field": "value", "align": "left"}, + ], + rows=rows, + row_key="field", + pagination={"rowsPerPage": 0}, + ).props("flat hide-header").classes("print-metadata-table w-full") + + +def _render_job_table(jobs: tuple[DocumentPrintJob, ...]) -> None: + columns = [{"name": "field", "label": "", "field": "field", "align": "left"}] + for index in range(1, len(jobs) + 1): + columns.append({"name": f"job_{index}", "label": f"Job {index}", "field": f"job_{index}", "align": "left"}) + fields = ( + ("Job ID", lambda job: str(job.id)), + ("Date", lambda job: job.date_created.isoformat()), + ("Provider", lambda job: job.provider or "Not set"), + ("Model", lambda job: job.model or "Not set"), + ("Prompt", lambda job: job.prompt_name or "Not set"), + ("Retry Count", lambda job: str(job.retry_count)), + ("Status", lambda job: job.status), + ) + rows = [ + { + "field": field, + **{f"job_{index}": value(job) for index, job in enumerate(jobs, start=1)}, + } + for field, value in fields + ] + ui.table( + columns=columns, + rows=rows, + row_key="field", + pagination={"rowsPerPage": 0}, + ).props("flat hide-bottom").classes("print-job-table w-full") + + +def reflow_transcription(text: str) -> list[str]: + """Join single line breaks while preserving blank-line paragraph boundaries.""" + normalized = text.replace("\r\n", "\n").replace("\r", "\n") + paragraphs = re.split(r"\n[ \t]*\n+", normalized) + return [re.sub(r"[ \t]*\n[ \t]*", " ", paragraph).strip() for paragraph in paragraphs if paragraph.strip()] diff --git a/src/transcription/ui/pages/settings_page.py b/src/transcription/ui/pages/settings_page.py index 702ec64..de517ff 100644 --- a/src/transcription/ui/pages/settings_page.py +++ b/src/transcription/ui/pages/settings_page.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Callable from typing import Any from uuid import UUID @@ -59,6 +58,7 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915 "label": item.label, "document_count": item.document_count, "is_active": item.is_active, + "is_built_in": item.is_built_in, } for item in document_types ] @@ -84,6 +84,12 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915 "field": "is_active", "align": "center", }, + { + "name": "is_built_in", + "label": "Built-in", + "field": "is_built_in", + "align": "center", + }, ], rows=rows, row_key="id", @@ -98,6 +104,14 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915 """, ) + table.add_slot( + "body-cell-is_built_in", + """ + + {{ props.value ? 'Yes' : 'No' }} + + """, + ) async def save_type( *, @@ -177,45 +191,141 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915 destructive_button("Delete", icon="delete", on_click=delete_selected_type) @ui.refreshable - async def render_person_roles() -> None: + async def render_person_roles() -> None: # noqa: PLR0915 with archival_card("Person Roles"): ui.label( - "Codes are permanent. Roles are ordered by label then code; referenced roles cannot be deleted." + "Roles are listed alphabetically. Built-ins cannot be deleted; " + "referenced custom roles must be deactivated." ).classes("text-xs ui-text-muted mb-3") try: - roles = await people.list_person_roles(active_only=False) + roles = await people.list_person_role_summaries() except Exception as exc: # noqa: BLE001 show_error(exc, title="Person Roles unavailable", operation="settings.roles.list") return - - with ui.row().classes("w-full items-end gap-2"): - code = ui.input("Stable code").props("dense") - label = ui.input("Label").props("dense") - - async def create_role() -> None: - try: - await people.create_person_role( - code=str(code.value or ""), - label=str(label.value or ""), - ) - except Exception as exc: # noqa: BLE001 - show_error(exc, title="Person Role creation failed", operation="settings.roles.create") - return - ui.notify("Person Role created", type="positive") - render_person_roles.refresh() - - ui.button("Add role", icon="add", on_click=create_role).classes("ui-btn-primary") - if not roles: render_empty_state("No Person Roles are configured.", extra_classes="mt-3") - for role in roles: - is_referenced = await people.is_person_role_referenced(role.id) - _person_role_row( - role, - is_referenced=is_referenced, - on_save=_save_role(people, role.id, render_person_roles.refresh), - on_delete=_delete_role(people, role.id, render_person_roles.refresh), + rows = [ + { + "id": str(role.id), + "label": role.label, + "link_count": role.link_count, + "is_active": role.is_active, + "is_built_in": role.is_built_in, + } + for role in roles + ] + table = ui.table( + columns=[ + {"name": "label", "label": "Label", "field": "label", "align": "left", "sortable": True}, + { + "name": "link_count", + "label": "Links", + "field": "link_count", + "align": "right", + "sortable": True, + }, + {"name": "is_active", "label": "Active", "field": "is_active", "align": "center"}, + { + "name": "is_built_in", + "label": "Built-in", + "field": "is_built_in", + "align": "center", + }, + ], + rows=rows, + row_key="id", + selection="single", + pagination={"rowsPerPage": 0, "sortBy": "label"}, + ).classes("w-full ui-table") + table.add_slot( + "body-cell-is_active", + """ + + + + """, + ) + table.add_slot( + "body-cell-is_built_in", + """ + + {{ props.value ? 'Yes' : 'No' }} + + """, + ) + + async def save_role( + *, + item_id: UUID | None, + label: str, + is_active: bool, + ) -> bool: + try: + if item_id is None: + await people.create_person_role(label=label, is_active=is_active) + else: + await people.update_person_role(item_id, label=label, is_active=is_active) + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Person Role save failed", operation="settings.roles.save") + return False + ui.notify("Person Role saved", type="positive") + render_person_roles.refresh() + return True + + def open_role_editor(*, creating: bool) -> None: + selected = _selected_table_row(table) + if not creating and selected is None: + ui.notify("Select one Person Role to edit.", type="warning") + return + if creating: + item_id = None + current_label = "" + current_active = True + else: + assert selected is not None + item_id = UUID(str(selected["id"])) + current_label = str(selected["label"]) + current_active = bool(selected["is_active"]) + with ui.dialog() as dialog, ui.card().classes("w-full max-w-lg ui-card-surface"): + ui.label("Add Person Role" if creating else "Edit Person Role").classes( + "text-lg font-semibold" + ) + label_input = ui.input("Label", value=current_label).props("outlined").classes("w-full") + active_input = ui.checkbox("Active", value=current_active) + + async def submit() -> None: + saved = await save_role( + item_id=item_id, + label=str(label_input.value or ""), + is_active=bool(active_input.value), + ) + if saved: + dialog.close() + + with ui.row().classes("w-full justify-end gap-2"): + ui.button("Cancel", on_click=dialog.close).props("flat") + ui.button("Save", icon="save", on_click=submit).classes("ui-btn-primary") + dialog.open() + + async def delete_selected_role() -> None: + selected = _selected_table_row(table) + if selected is None: + ui.notify("Select one Person Role to delete.", type="warning") + return + try: + await people.delete_person_role(UUID(str(selected["id"]))) + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Person Role deletion failed", operation="settings.roles.delete") + return + ui.notify("Person Role deleted", type="positive") + render_person_roles.refresh() + + with ui.row().classes("w-full items-center gap-2 mt-3"): + ui.button("Add", icon="add", on_click=lambda: open_role_editor(creating=True)).classes( + "ui-btn-primary" ) + ui.button("Edit", icon="edit", on_click=lambda: open_role_editor(creating=False)).props("flat") + destructive_button("Delete", icon="delete", on_click=delete_selected_role) @ui.refreshable def render_prompts() -> None: @@ -297,57 +407,3 @@ def _selected_table_row(table: Any) -> dict[str, Any] | None: if len(selected) != 1: return None return selected[0] - - -def _person_role_row( - role: Any, - *, - is_referenced: bool, - on_save: Callable[..., Any], - on_delete: Callable[..., Any], -) -> None: - with ui.row().classes("w-full items-end gap-2 py-2 ui-header-divider"): - ui.input("Code", value=role.code).props("dense readonly").classes("min-w-44") - label = ui.input("Label", value=role.label).props("dense").classes("grow") - is_active = ui.switch("Active", value=role.is_active) - ui.button( - "Save", - icon="save", - on_click=lambda: on_save(label=str(label.value or ""), is_active=bool(is_active.value)), - ).props("flat").classes("ui-link-primary") - if not is_referenced: - destructive_button("Delete", icon="delete", on_click=on_delete, extra_classes="text-xs") - - -def _save_role( - service: PeopleService, - item_id: UUID, - refresh: Callable[[], Any], -) -> Callable[..., Any]: - async def save(*, label: str, is_active: bool) -> None: - try: - await service.update_person_role(item_id, label=label, is_active=is_active) - except Exception as exc: # noqa: BLE001 - show_error(exc, title="Person Role update failed", operation="settings.roles.update") - return - ui.notify("Person Role updated", type="positive") - refresh() - - return save - - -def _delete_role( - service: PeopleService, - item_id: UUID, - refresh: Callable[[], Any], -) -> Callable[[], Any]: - async def delete() -> None: - try: - await service.delete_person_role(item_id) - except Exception as exc: # noqa: BLE001 - show_error(exc, title="Person Role deletion failed", operation="settings.roles.delete") - return - ui.notify("Person Role deleted", type="positive") - refresh() - - return delete diff --git a/src/transcription/ui/static/theme.css b/src/transcription/ui/static/theme.css index f0b45df..ed32954 100644 --- a/src/transcription/ui/static/theme.css +++ b/src/transcription/ui/static/theme.css @@ -54,6 +54,69 @@ input:focus-visible, outline-offset: 2px; } +.print-source-image, +.print-source-pdf { + width: 40%; + max-height: 9.5in; +} + +.print-source-image { + object-fit: contain; +} + +.print-source-pdf { + height: 9.5in; + border: 0; +} + +.print-transcription { + flex: 1; + line-height: 1.5; +} + +.print-preserve-lines { + white-space: pre-wrap; +} + +@media print { + @page { + margin: 0.6in; + } + + body, + .q-layout, + .q-page-container { + background: white !important; + color: black !important; + } + + .no-print, + header, + nav { + display: none !important; + } + + .print-preview { + max-width: none !important; + padding: 0 !important; + } + + .print-page-break { + break-before: page; + page-break-before: always; + } + + .print-facsimile-row { + flex-wrap: nowrap !important; + } + + .print-source, + .print-metadata-table, + .print-job-table { + break-inside: avoid; + } +} + /* Semantic utility classes */ .ui-text-primary { color: var(--theme-text); diff --git a/tests/api/test_v4_documents.py b/tests/api/test_v4_documents.py index c9d99c7..09ea919 100644 --- a/tests/api/test_v4_documents.py +++ b/tests/api/test_v4_documents.py @@ -24,6 +24,7 @@ from transcription.db.engine import get_engine from transcription.db.models import Document from transcription.db.models import DocumentType from transcription.db.models import Person +from transcription.db.models import PersonRole from transcription.db.session import dispose_session_factory from transcription.db.session import session_scope from transcription.services.documents import DocumentService @@ -53,6 +54,12 @@ async def _document_type_id(*, db_url: str, label: str) -> UUID: return document_type.id +async def _person_role_id(*, db_url: str, semantic_key: str) -> UUID: + async with session_scope(database_url=db_url) as session: + role = (await session.exec(select(PersonRole).where(PersonRole.semantic_key == semantic_key))).one() + return role.id + + @contextmanager def _v4_api_client(tmp_path: Path, *, db_filename: str) -> Generator[tuple[TestClient, str]]: settings = Settings( @@ -97,7 +104,7 @@ def test_list_document_types_returns_seeded_registry(tmp_path): assert response.status_code == 200 payload = response.json() labels = {item["label"] for item in payload} - assert {"Letter", "Record", "Memo"}.issubset(labels) + assert {"Book", "Letter", "Postcard", "Photo", "Journal", "Form"}.issubset(labels) def test_list_person_roles_returns_seeded_registry(tmp_path): @@ -106,14 +113,15 @@ def test_list_person_roles_returns_seeded_registry(tmp_path): assert response.status_code == 200 payload = response.json() - codes = {item["code"] for item in payload} - assert {"author", "recipient", "mentioned"}.issubset(codes) + labels = {item["label"] for item in payload} + assert {"Author", "Recipient", "Mentioned"}.issubset(labels) + assert all("code" not in item and "semantic_key" not in item for item in payload) def test_set_document_type_by_id_updates_canonical_field(tmp_path): with _v4_api_client(tmp_path, db_filename="api-doc-type.db") as (client, db_url): document_id, _ = _seed_document_and_person(db_url=db_url) - type_id = asyncio.run(_document_type_id(db_url=db_url, label="Record")) + type_id = asyncio.run(_document_type_id(db_url=db_url, label="Form")) response = client.put( f"/api/v4/documents/{document_id}/type", json={"document_type_id": str(type_id)}, @@ -147,32 +155,35 @@ def test_document_type_payload_requires_uuid_only(tmp_path): def test_document_people_role_aware_write_read_and_delete(tmp_path): with _v4_api_client(tmp_path, db_filename="api-links.db") as (client, db_url): document_id, person_id = _seed_document_and_person(db_url=db_url) + author_id = asyncio.run(_person_role_id(db_url=db_url, semantic_key="author")) + recipient_id = asyncio.run(_person_role_id(db_url=db_url, semantic_key="recipient")) create_response = client.post( f"/api/v4/documents/{document_id}/people", - json={"person_id": str(person_id), "role_code": "author"}, + json={"person_id": str(person_id), "role_id": str(author_id)}, ) assert create_response.status_code == 200 created = create_response.json() assert created["document_id"] == str(document_id) assert created["person_id"] == str(person_id) - assert created["role_code"] == "author" - assert created["role_id"] is not None + assert created["role_id"] == str(author_id) + assert created["role_label"] == "Author" link_id = created["id"] update_response = client.patch( f"/api/v4/document-people/{link_id}", - json={"role_code": "recipient"}, + json={"role_id": str(recipient_id)}, ) assert update_response.status_code == 200 updated = update_response.json() - assert updated["role_code"] == "recipient" + assert updated["role_id"] == str(recipient_id) + assert updated["role_label"] == "Recipient" list_response = client.get(f"/api/v4/documents/{document_id}/people") assert list_response.status_code == 200 links = list_response.json()["links"] assert len(links) == 1 - assert links[0]["role_code"] == "recipient" + assert links[0]["role_id"] == str(recipient_id) delete_response = client.delete(f"/api/v4/document-people/{link_id}") assert delete_response.status_code == 204 @@ -182,7 +193,7 @@ def test_document_people_role_aware_write_read_and_delete(tmp_path): assert list_after_delete.json()["links"] == [] -def test_document_person_link_defaults_to_author_when_role_is_omitted(tmp_path): +def test_document_person_link_requires_role_id(tmp_path): with _v4_api_client(tmp_path, db_filename="api-default-role.db") as (client, db_url): document_id, person_id = _seed_document_and_person(db_url=db_url) @@ -191,23 +202,24 @@ def test_document_person_link_defaults_to_author_when_role_is_omitted(tmp_path): json={"person_id": str(person_id)}, ) - assert response.status_code == 200 - assert response.json()["role_code"] == "author" + assert response.status_code == 422 def test_duplicate_document_person_link_returns_conflict_envelope(tmp_path): with _v4_api_client(tmp_path, db_filename="api-dup.db") as (client, db_url): document_id, person_id = _seed_document_and_person(db_url=db_url) + author_id = asyncio.run(_person_role_id(db_url=db_url, semantic_key="author")) + recipient_id = asyncio.run(_person_role_id(db_url=db_url, semantic_key="recipient")) first = client.post( f"/api/v4/documents/{document_id}/people", - json={"person_id": str(person_id), "role_code": "author"}, + json={"person_id": str(person_id), "role_id": str(author_id)}, ) assert first.status_code == 200 second = client.post( f"/api/v4/documents/{document_id}/people", - json={"person_id": str(person_id), "role_code": "author"}, + json={"person_id": str(person_id), "role_id": str(recipient_id)}, ) assert second.status_code == 409 diff --git a/tests/services/test_document_service.py b/tests/services/test_document_service.py index 5caf10f..267e8c1 100644 --- a/tests/services/test_document_service.py +++ b/tests/services/test_document_service.py @@ -5,15 +5,12 @@ from datetime import datetime 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 DocumentPerson -from transcription.db.models import DocumentPersonRole from transcription.db.models import Job from transcription.db.models import Person -from transcription.db.models import PersonRole from transcription.db.models import Source from transcription.services.documents import DocumentDeleteBlockedError from transcription.services.documents import DocumentError @@ -132,11 +129,12 @@ async def test_delete_document_removes_person_links(default_session_factory, tmp ) ) person = await people_service.create_person(Person(full_name="Linked Person")) + author_role = await people_service.create_person_role(label="Author") await people_service.create_document_person( DocumentPerson( document_id=document.id, person_id=person.id, - role=DocumentPersonRole.AUTHOR, + role_id=author_role.id, ) ) @@ -144,7 +142,7 @@ async def test_delete_document_removes_person_links(default_session_factory, tmp assert len(links_before_delete) == 1 assert links_before_delete[0].role_id is not None assert links_before_delete[0].role_ref is not None - assert links_before_delete[0].role_ref.code == "author" + assert links_before_delete[0].role_ref.label == "Author" document_dir = service.settings.upload_dir / "documents" / str(document.id) document_dir.mkdir(parents=True, exist_ok=True) @@ -196,11 +194,12 @@ async def test_read_person_detail_loads_document_links(default_session_factory): ) ) person = await people_service.create_person(Person(full_name="Linked Person")) + author_role = await people_service.create_person_role(label="Author") await people_service.create_document_person( DocumentPerson( document_id=document.id, person_id=person.id, - role=DocumentPersonRole.AUTHOR, + role_id=author_role.id, ) ) @@ -243,11 +242,12 @@ async def test_delete_person_removes_links_when_linked_documents_exist(default_s ) ) person = await service.create_person(Person(full_name="Blocked Person")) + author_role = await service.create_person_role(label="Author") await service.create_document_person( DocumentPerson( document_id=document.id, person_id=person.id, - role=DocumentPersonRole.AUTHOR, + role_id=author_role.id, ) ) @@ -285,19 +285,19 @@ async def test_create_document_uses_existing_document_type_registry(default_sess @pytest.mark.asyncio -async def test_update_document_person_sets_role_id_from_legacy_role(default_session_factory): +async def test_update_document_person_changes_role_id(default_session_factory): documents_service = DocumentService(session_factory=default_session_factory) service = PeopleService(session_factory=default_session_factory) - document = await documents_service.create_document( - Document(id=uuid4(), name="role-sync-doc", document_type="letter") - ) + document = await documents_service.create_document(Document(id=uuid4(), name="role-sync-doc")) person = await service.create_person(Person(full_name="Role Sync Person")) + author_role = await service.create_person_role(label="Author") + recipient_role = await service.create_person_role(label="Recipient") link = await service.create_document_person( DocumentPerson( document_id=document.id, person_id=person.id, - role=DocumentPersonRole.AUTHOR, + role_id=author_role.id, ) ) @@ -306,15 +306,8 @@ async def test_update_document_person_sets_role_id_from_legacy_role(default_sess id=link.id, document_id=document.id, person_id=person.id, - role=DocumentPersonRole.RECIPIENT, - role_id=None, + role_id=recipient_role.id, ) ) - assert updated.role == DocumentPersonRole.RECIPIENT - assert updated.role_id is not None - - async with service._session_scope() as session: - recipient_role = (await session.exec(select(PersonRole).where(PersonRole.code == "recipient"))).first() - assert recipient_role is not None assert updated.role_id == recipient_role.id diff --git a/tests/services/test_settings_services.py b/tests/services/test_settings_services.py index 2204bca..57203bf 100644 --- a/tests/services/test_settings_services.py +++ b/tests/services/test_settings_services.py @@ -6,8 +6,8 @@ import pytest from transcription.db.models import Document from transcription.db.models import DocumentPerson -from transcription.db.models import DocumentPersonRole from transcription.db.models import Person +from transcription.db.models import PersonRole from transcription.errors import ErrorCategory from transcription.services.documents import DocumentService from transcription.services.documents import DocumentTypeError @@ -73,24 +73,27 @@ async def test_document_type_delete_allows_unreferenced_and_blocks_referenced(de @pytest.mark.asyncio -async def test_person_role_maintenance_orders_by_label_then_code(default_session_factory): +async def test_person_role_maintenance_orders_by_normalized_label(default_session_factory): service = PeopleService(session_factory=default_session_factory) - second = await service.create_person_role(code="witness", label="Witness") - first = await service.create_person_role(code="author", label="Author") + second = await service.create_person_role(label="Witness") + first = await service.create_person_role(label="Archivist") updated = await service.update_person_role(second.id, label="Attestor", is_active=False) - assert updated.code == "witness" - assert [item.id for item in await service.list_person_roles(active_only=False)] == [second.id, first.id] + assert updated.semantic_key is None + assert [item.id for item in await service.list_person_roles(active_only=False)] == [first.id, second.id] assert [item.id for item in await service.list_person_roles()] == [first.id] + summaries = {item.id: item for item in await service.list_person_role_summaries()} + assert summaries[second.id].link_count == 0 + assert summaries[second.id].is_built_in is False @pytest.mark.asyncio async def test_person_role_delete_allows_unreferenced_and_blocks_referenced(default_session_factory): documents = DocumentService(session_factory=default_session_factory) people = PeopleService(session_factory=default_session_factory) - unused = await people.create_person_role(code="witness", label="Witness") - referenced = await people.create_person_role(code="author", label="Author") + unused = await people.create_person_role(label="Witness") + referenced = await people.create_person_role(label="Creator") document = await documents.create_document(Document(name="Role document")) person = await people.create_person(Person(full_name="Role Person")) await people.create_document_person( @@ -98,7 +101,6 @@ async def test_person_role_delete_allows_unreferenced_and_blocks_referenced(defa document_id=document.id, person_id=person.id, role_id=referenced.id, - role=DocumentPersonRole.AUTHOR, ) ) @@ -113,12 +115,12 @@ async def test_person_role_delete_allows_unreferenced_and_blocks_referenced(defa @pytest.mark.asyncio -async def test_person_role_duplicate_code_is_conflict(default_session_factory): +async def test_person_role_duplicate_normalized_label_is_conflict(default_session_factory): service = PeopleService(session_factory=default_session_factory) - await service.create_person_role(code="author", label="Author") + await service.create_person_role(label="Witness") with pytest.raises(PersonRoleError) as caught: - await service.create_person_role(code=" AUTHOR ", label="Duplicate") + await service.create_person_role(label=" witness ") assert caught.value.category == ErrorCategory.CONFLICT @@ -127,7 +129,7 @@ async def test_person_role_duplicate_code_is_conflict(default_session_factory): async def test_custom_person_role_can_be_used_for_document_link(default_session_factory): documents = DocumentService(session_factory=default_session_factory) people = PeopleService(session_factory=default_session_factory) - role = await people.create_person_role(code="witness", label="Witness") + role = await people.create_person_role(label="Witness") document = await documents.create_document(Document(name="Witnessed document")) person = await people.create_person(Person(full_name="Archive Witness")) @@ -138,30 +140,24 @@ async def test_custom_person_role_can_be_used_for_document_link(default_session_ ) loaded = await people.list_document_people(document_id=document.id) - assert link.role == "witness" + assert link.role_id == role.id assert loaded[0].role_ref is not None - assert loaded[0].role_ref.code == "witness" + assert loaded[0].role_ref.label == "Witness" @pytest.mark.asyncio -async def test_custom_person_role_delete_blocks_legacy_only_reference(default_session_factory): - documents = DocumentService(session_factory=default_session_factory) +async def test_built_in_person_role_cannot_be_deleted(default_session_factory): people = PeopleService(session_factory=default_session_factory) - role = await people.create_person_role(code="witness", label="Witness") - document = await documents.create_document(Document(name="Legacy role document")) - person = await people.create_person(Person(full_name="Legacy Witness")) async with people._session_scope() as session: - session.add( - DocumentPerson( - document_id=document.id, - person_id=person.id, - role="witness", - role_id=None, - ) + role = PersonRole( + semantic_key="author", + label="Author", + normalized_label="author", ) + session.add(role) await session.commit() + await session.refresh(role) - assert await people.is_person_role_referenced(role.id) is True with pytest.raises(PersonRoleError) as caught: await people.delete_person_role(role.id) diff --git a/tests/services/test_v2_crud.py b/tests/services/test_v2_crud.py index a1d056a..fa87967 100644 --- a/tests/services/test_v2_crud.py +++ b/tests/services/test_v2_crud.py @@ -4,7 +4,6 @@ import pytest 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 JobSource from transcription.db.models import JobSourceStatus @@ -27,23 +26,23 @@ async def test_people_service_handles_person_and_document_person_crud(default_se document = await documents.create_document(Document(id=uuid4(), name="person-doc")) person = await people_service.create_person(Person(full_name="Ada Lovelace")) + author_role = await people_service.create_person_role(label="Author") + recipient_role = await people_service.create_person_role(label="Recipient") assert document.document_type_id is None link = await people_service.create_document_person( - DocumentPerson(document_id=document.id, person_id=person.id, role=DocumentPersonRole.AUTHOR) + DocumentPerson(document_id=document.id, person_id=person.id, role_id=author_role.id) ) fetched = await people_service.read_document_person(link.id) assert fetched.id == link.id - assert fetched.role == DocumentPersonRole.AUTHOR - assert fetched.role_id is not None + assert fetched.role_id == author_role.id updated_link = await people_service.update_document_person( - DocumentPerson(id=link.id, document_id=document.id, person_id=person.id, role=DocumentPersonRole.RECIPIENT) + DocumentPerson(id=link.id, document_id=document.id, person_id=person.id, role_id=recipient_role.id) ) - assert updated_link.role == DocumentPersonRole.RECIPIENT - assert updated_link.role_id is not None + assert updated_link.role_id == recipient_role.id listed = await people_service.list_document_people(document_id=document.id) assert len(listed) == 1 @@ -59,21 +58,15 @@ async def test_people_service_handles_person_and_document_person_crud(default_se async def test_people_service_normalizes_and_rejects_duplicate_family_search_ids(default_session_factory): people_service = PeopleService(session_factory=default_session_factory) - created = await people_service.create_person( - Person(full_name="Hig Higgins", family_search_id=" g8t4-mdq ") - ) + created = await people_service.create_person(Person(full_name="Hig Higgins", family_search_id=" g8t4-mdq ")) assert created.family_search_id == "G8T4-MDQ" with pytest.raises(PeopleError) as duplicate: - await people_service.create_person( - Person(full_name="Duplicate Hig", family_search_id="G8T4-MDQ") - ) + await people_service.create_person(Person(full_name="Duplicate Hig", family_search_id="G8T4-MDQ")) assert duplicate.value.category == ErrorCategory.CONFLICT with pytest.raises(PeopleError) as malformed: - await people_service.create_person( - Person(full_name="Malformed", family_search_id="not-an-id") - ) + await people_service.create_person(Person(full_name="Malformed", family_search_id="not-an-id")) assert malformed.value.category == ErrorCategory.VALIDATION @@ -202,11 +195,12 @@ async def test_document_detail_loads_linked_person_relationship(default_session_ document = await documents.create_document(Document(id=uuid4(), name="detail-person-doc")) person = await people_service.create_person(Person(full_name="Grace Hopper")) + author_role = await people_service.create_person_role(label="Author") await people_service.create_document_person( DocumentPerson( document_id=document.id, person_id=person.id, - role=DocumentPersonRole.AUTHOR, + role_id=author_role.id, ) ) @@ -216,7 +210,7 @@ async def test_document_detail_loads_linked_person_relationship(default_session_ link = detail.document_people[0] assert link.person is not None assert link.person.full_name == "Grace Hopper" - assert link.role == DocumentPersonRole.AUTHOR + assert link.role_id == author_role.id @pytest.mark.asyncio diff --git a/tests/services/test_v44_workflows.py b/tests/services/test_v44_workflows.py new file mode 100644 index 0000000..c739409 --- /dev/null +++ b/tests/services/test_v44_workflows.py @@ -0,0 +1,166 @@ +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 DocumentPerson +from transcription.db.models import Job +from transcription.db.models import JobStatus +from transcription.db.models import Person +from transcription.db.models import PersonRole +from transcription.db.models import Source +from transcription.services.documents import DocumentService +from transcription.services.jobs import JobService +from transcription.services.people import DocumentPersonInput +from transcription.services.people import PeopleError +from transcription.services.people import PeopleService +from transcription.services.sources import SourceService +from transcription.services.workflows import create_document_with_people +from transcription.services.workflows import update_document_with_people + + +@pytest.mark.asyncio +async def test_create_document_with_people_rolls_back_on_invalid_person(default_session_factory): + documents = DocumentService(session_factory=default_session_factory) + people = PeopleService(session_factory=default_session_factory) + role = await people.create_person_role(label="Witness") + + with pytest.raises(PeopleError): + await create_document_with_people( + document=Document(name="Must roll back"), + links=[DocumentPersonInput(person_id=uuid4(), role_id=role.id)], + documents=documents, + people=people, + ) + + assert await documents.query_documents(name="Must roll back") == [] + + +@pytest.mark.asyncio +async def test_update_document_with_people_rolls_back_document_and_links(default_session_factory): + documents = DocumentService(session_factory=default_session_factory) + people = PeopleService(session_factory=default_session_factory) + role = await people.create_person_role(label="Witness") + inactive = await people.create_person_role(label="Former Witness", is_active=False) + person = await people.create_person(Person(full_name="Archive Witness")) + document = await create_document_with_people( + document=Document(name="Original name"), + links=[DocumentPersonInput(person_id=person.id, role_id=role.id)], + documents=documents, + people=people, + ) + assert [item.name for item in await documents.list_documents()] == ["Original name"] + + candidate = Document( + id=document.id, + name="Changed name", + created_at=document.created_at, + updated_at=document.updated_at, + ) + with pytest.raises(PeopleError): + await update_document_with_people( + document=candidate, + links=[DocumentPersonInput(person_id=person.id, role_id=inactive.id)], + documents=documents, + people=people, + ) + + persisted_documents = await documents.list_documents() + links = await people.list_document_people(document_id=document.id) + assert [item.name for item in persisted_documents] == ["Original name"] + assert len(links) == 1 + assert links[0].role_id == role.id + + +@pytest.mark.asyncio +async def test_direct_link_writes_reject_new_inactive_role_assignments(default_session_factory): + documents = DocumentService(session_factory=default_session_factory) + people = PeopleService(session_factory=default_session_factory) + active = await people.create_person_role(label="Witness") + inactive = await people.create_person_role(label="Former Witness", is_active=False) + person = await people.create_person(Person(full_name="Archive Witness")) + document = await documents.create_document(Document(name="Role rules")) + link = await people.add_document_person_link( + document_id=document.id, + person_id=person.id, + role_id=active.id, + ) + + with pytest.raises(PeopleError, match="Inactive Person Role"): + await people.set_document_person_role( + document_person_id=link.id, + role_id=inactive.id, + ) + + unchanged = await people.set_document_person_role( + document_person_id=link.id, + role_id=active.id, + ) + assert unchanged.role_id == active.id + + +@pytest.mark.asyncio +async def test_document_print_projection_uses_semantic_author_and_current_text(default_session_factory): + documents = DocumentService(session_factory=default_session_factory) + people = PeopleService(session_factory=default_session_factory) + sources = SourceService(session_factory=default_session_factory) + jobs = JobService(session_factory=default_session_factory) + document = await documents.create_document(Document(name="Print Me", notes="Archive note")) + person = await people.create_person(Person(full_name="Historic Author")) + + async with people._session_scope() as session: + author = PersonRole( + semantic_key="author", + label="Creator", + normalized_label="creator", + ) + session.add(author) + await session.flush() + session.add(DocumentPerson(document_id=document.id, person_id=person.id, role_id=author.id)) + await session.commit() + + await sources.create_source( + Source( + document_id=document.id, + page_number=2, + upload_name="page-2.png", + filename="page-2.png", + file_path="managed/page-2.png", + file_hash="2" * 64, + file_size_bytes=2, + raw_transcription="raw second", + revised_text="revised second", + ) + ) + await sources.create_source( + Source( + document_id=document.id, + page_number=1, + upload_name="page-1.png", + filename="page-1.png", + file_path="managed/page-1.png", + file_hash="1" * 64, + file_size_bytes=1, + raw_transcription="raw first", + ) + ) + await jobs.create_job( + Job( + document_id=document.id, + status=JobStatus.COMPLETED, + provider="openrouter", + model="model-a", + prompt_name="transcribe_document.md", + date_created=datetime(2026, 1, 1, tzinfo=UTC), + ) + ) + + projection = await documents.read_document_print_projection(document.id) + + assert projection.authors == ("Historic Author",) + assert [source.page_number for source in projection.sources] == [1, 2] + assert [source.current_text for source in projection.sources] == ["raw first", "revised second"] + assert [source.media_type for source in projection.sources] == ["image/png", "image/png"] + assert projection.jobs[0].status == "completed" diff --git a/tests/test_db.py b/tests/test_db.py index 2409ba7..ca8036a 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1,7 +1,5 @@ """Tests for the database runtime and V2 schema bootstrap behavior.""" -from uuid import uuid4 - import pytest from sqlalchemy import inspect from sqlalchemy import text @@ -98,11 +96,11 @@ async def test_create_all_seeds_default_registry_rows(tmp_path): try: await create_all(engine=runtime.engine) async with AsyncSession(runtime.engine, expire_on_commit=False) as session: - role_codes = set((await session.exec(select(PersonRole.code))).all()) - type_labels = set((await session.exec(select(DocumentType.label))).all()) + role_keys = set((await session.exec(select(PersonRole.semantic_key))).all()) + type_keys = set((await session.exec(select(DocumentType.semantic_key))).all()) - assert {"author", "recipient", "mentioned"}.issubset(role_codes) - assert {"Letter", "Record", "Memo"}.issubset(type_labels) + assert {"author", "recipient", "mentioned"}.issubset(role_keys) + assert {"book", "letter", "postcard", "photo", "journal", "form"}.issubset(type_keys) finally: await dispose_database_runtime() @@ -137,93 +135,6 @@ async def test_create_all_upgrades_existing_person_table_for_family_search(tmp_p await dispose_database_runtime() -@pytest.mark.asyncio -async def test_upgrade_migrates_document_types_to_uuid_only_identity(tmp_path): - settings = Settings( - openrouter_api_key="test-key", - database=SqliteSettings(path=str(tmp_path / "type-upgrade.db")), - environment="test", - ) - runtime = initialize_database_runtime(settings=settings) - type_id = uuid4().hex - document_id = uuid4().hex - - try: - async with runtime.engine.begin() as connection: - await connection.execute( - text( - "CREATE TABLE document_type (" - "id CHAR(32) PRIMARY KEY NOT NULL, " - "code VARCHAR NOT NULL, " - "label VARCHAR NOT NULL, " - "is_active BOOLEAN NOT NULL, " - "sort_order INTEGER NOT NULL, " - "created_at DATETIME NOT NULL, " - "updated_at DATETIME NOT NULL" - ")" - ) - ) - await connection.execute(text("CREATE UNIQUE INDEX ix_document_type_code ON document_type (code)")) - await connection.execute( - text( - "CREATE TABLE document (" - "id CHAR(32) PRIMARY KEY NOT NULL, " - "name VARCHAR NOT NULL, " - "document_type_id CHAR(32), " - "document_type VARCHAR, " - "document_date DATE, " - "document_date_raw VARCHAR, " - "location_created VARCHAR, " - "notes VARCHAR, " - "archive_identifier VARCHAR, " - "created_at DATETIME NOT NULL, " - "updated_at DATETIME NOT NULL" - ")" - ) - ) - await connection.execute( - text( - "INSERT INTO document_type " - "(id, code, label, is_active, sort_order, created_at, updated_at) " - "VALUES (:id, 'letter', 'Letter', 1, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)" - ), - {"id": type_id}, - ) - await connection.execute( - text( - "INSERT INTO document " - "(id, name, document_type_id, document_type, created_at, updated_at) " - "VALUES (:id, 'Legacy Letter', NULL, 'letter', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)" - ), - {"id": document_id}, - ) - - await upgrade_schema(engine=runtime.engine) - - async with runtime.engine.connect() as connection: - type_columns, document_columns, migrated_type_id, normalized_label = await connection.run_sync( - lambda sync_connection: ( - {column["name"] for column in inspect(sync_connection).get_columns("document_type")}, - {column["name"] for column in inspect(sync_connection).get_columns("document")}, - sync_connection.execute( - text("SELECT document_type_id FROM document WHERE id = :id"), - {"id": document_id}, - ).scalar_one(), - sync_connection.execute( - text("SELECT normalized_label FROM document_type WHERE id = :id"), - {"id": type_id}, - ).scalar_one(), - ) - ) - - assert {"code", "sort_order"}.isdisjoint(type_columns) - assert "document_type" not in document_columns - assert migrated_type_id == type_id - assert normalized_label == "letter" - finally: - await dispose_database_runtime() - - @pytest.mark.asyncio async def test_v42_upgrade_adds_evidence_tables_without_rewriting_legacy_snapshot(tmp_path): settings = Settings( diff --git a/tests/test_models.py b/tests/test_models.py index 6fdc89f..0ce9146 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -7,7 +7,6 @@ from sqlalchemy.exc import IntegrityError from transcription.db.models import Document from transcription.db.models import DocumentPerson -from transcription.db.models import DocumentPersonRole from transcription.db.models import DocumentType from transcription.db.models import Job from transcription.db.models import JobSource @@ -35,8 +34,8 @@ def _persist_document_type(session, *, label: str = "Letter") -> DocumentType: return document_type -def _persist_person_role(session, *, code: str = "author", label: str = "Author") -> PersonRole: - role = PersonRole(code=code, label=label) +def _persist_person_role(session, *, label: str = "Author") -> PersonRole: + role = PersonRole(label=label, normalized_label=label.strip().casefold()) session.add(role) session.commit() session.refresh(role) @@ -195,7 +194,6 @@ class TestPersonAndDocumentPersonModel: first = DocumentPerson( document_id=document.id, person_id=person.id, - role=DocumentPersonRole.AUTHOR, role_id=person_role.id, ) session.add(first) @@ -204,7 +202,6 @@ class TestPersonAndDocumentPersonModel: duplicate = DocumentPerson( document_id=document.id, person_id=person.id, - role=DocumentPersonRole.AUTHOR, role_id=person_role.id, ) session.add(duplicate) @@ -244,7 +241,6 @@ class TestRelationships: link = DocumentPerson( document_id=document.id, person_id=person.id, - role=DocumentPersonRole.AUTHOR, role_id=person_role.id, ) session.add(link) @@ -257,9 +253,9 @@ class TestRelationships: class TestRegistryModels: - def test_person_role_code_is_unique(self, session): - _persist_person_role(session, code="mentioned", label="Mentioned") - duplicate = PersonRole(code="mentioned", label="Mentioned Again") + def test_person_role_normalized_label_is_unique(self, session): + _persist_person_role(session, label="Mentioned") + duplicate = PersonRole(label=" mentioned ", normalized_label="mentioned") session.add(duplicate) with pytest.raises(IntegrityError): session.commit() diff --git a/tests/ui/test_documents_page.py b/tests/ui/test_documents_page.py index 69f8d81..58d52e5 100644 --- a/tests/ui/test_documents_page.py +++ b/tests/ui/test_documents_page.py @@ -9,10 +9,10 @@ from sqlmodel import select 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 DocumentType from transcription.db.models import Job from transcription.db.models import Person +from transcription.db.models import PersonRole from transcription.db.models import Source # --- Helper Fixtures --- @@ -23,6 +23,7 @@ async def seed_person_and_document(): """Seed a Person and Document linked by DocumentPerson role.""" async with session_scope() as session: letter_type = (await session.exec(select(DocumentType).where(DocumentType.label == "Letter"))).one() + author_role = (await session.exec(select(PersonRole).where(PersonRole.semantic_key == "author"))).one() person = Person(full_name="Zenna Cochran") session.add(person) await session.flush() @@ -38,7 +39,7 @@ async def seed_person_and_document(): link = DocumentPerson( document_id=doc.id, person_id=person.id, - role=DocumentPersonRole.AUTHOR, + role_id=author_role.id, ) session.add(link) await session.commit() @@ -92,7 +93,7 @@ class TestDocumentsPageRendering: assert response.status_code == 200 assert "Create Document" in response.text assert "Document name" in response.text - assert "Linked People by Role" in response.text + assert "Linked People" in response.text assert "Document type" in response.text @pytest.mark.asyncio @@ -132,7 +133,7 @@ class TestDocumentsPageRendering: _, client = app_client async with session_scope() as session: - doc = Document(name="Doc With Job", document_type="letter") + doc = Document(name="Doc With Job") session.add(doc) await session.flush() @@ -165,7 +166,7 @@ class TestDocumentsPageRendering: _, client = app_client async with session_scope() as session: - doc = Document(name="Doc With Source", document_type="letter") + doc = Document(name="Doc With Source") session.add(doc) await session.flush() @@ -194,7 +195,7 @@ class TestDocumentsPageRendering: _, client = app_client async with session_scope() as session: - doc = Document(name="Orphan Document", document_type="note") + doc = Document(name="Orphan Document") session.add(doc) await session.commit() doc_id = str(doc.id) diff --git a/tests/ui/test_jobs_page.py b/tests/ui/test_jobs_page.py index 858dd96..fa6dfd3 100644 --- a/tests/ui/test_jobs_page.py +++ b/tests/ui/test_jobs_page.py @@ -15,7 +15,7 @@ from transcription.db.models import JobStatus async def seed_document_with_unlinked_job(): """Seed a document and a queued job for testing route actions.""" async with session_scope() as session: - document = Document(name="Test Archival Letter", document_type="letter") + document = Document(name="Test Archival Letter") session.add(document) await session.flush() @@ -72,7 +72,7 @@ class TestJobsPageRendering: _, client = app_client async with session_scope() as session: - doc = Document(name="Preselected Journal Entry", document_type="journal") + doc = Document(name="Preselected Journal Entry") session.add(doc) await session.commit() doc_id = str(doc.id) @@ -133,7 +133,7 @@ class TestJobsPageRendering: _, client = app_client async with session_scope() as session: - doc = Document(name="Processing Doc", document_type="letter") + doc = Document(name="Processing Doc") session.add(doc) await session.flush() job = Job(document_id=doc.id, status=JobStatus.PROCESSING) diff --git a/tests/ui/test_people_page.py b/tests/ui/test_people_page.py index 16e37b4..6eebf76 100644 --- a/tests/ui/test_people_page.py +++ b/tests/ui/test_people_page.py @@ -4,12 +4,13 @@ from datetime import date from uuid import uuid4 import pytest +from sqlmodel import select 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 +from transcription.db.models import PersonRole @pytest.mark.integration @@ -122,8 +123,9 @@ class TestPeoplePageRendering: _, client = app_client async with session_scope() as session: + author_role = (await session.exec(select(PersonRole).where(PersonRole.semantic_key == "author"))).one() person = Person(full_name="Linked Person") - document = Document(name="Linked Document", document_type="letter") + document = Document(name="Linked Document") session.add_all([person, document]) await session.flush() @@ -131,7 +133,7 @@ class TestPeoplePageRendering: DocumentPerson( document_id=document.id, person_id=person.id, - role=DocumentPersonRole.AUTHOR, + role_id=author_role.id, ) ) await session.commit() @@ -141,7 +143,7 @@ class TestPeoplePageRendering: assert response.status_code == 200 assert "Linked Document" in response.text - assert "Role: author" in response.text + assert "Role: Author" in response.text def test_person_detail_page_handles_invalid_id(self, app_client): _, client = app_client diff --git a/tests/ui/test_print_preview_page.py b/tests/ui/test_print_preview_page.py new file mode 100644 index 0000000..7255aa6 --- /dev/null +++ b/tests/ui/test_print_preview_page.py @@ -0,0 +1,103 @@ +from pathlib import Path + +import pytest + +from transcription.db import session_scope +from transcription.db.models import Document +from transcription.db.models import Source +from transcription.ui.pages.print_preview_page import reflow_transcription + + +def test_reflow_transcription_preserves_paragraph_boundaries(): + assert reflow_transcription("first line\nsecond line\n\nnext paragraph") == [ + "first line second line", + "next paragraph", + ] + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_document_print_preview_and_safe_media_route(app_client): + app, client = app_client + media_path = app.state.settings.upload_dir / "documents" / "print-page.png" + pdf_path = app.state.settings.upload_dir / "documents" / "print-page.pdf" + media_path.parent.mkdir(parents=True, exist_ok=True) + media_path.write_bytes(b"\x89PNG\r\n\x1a\n") + pdf_path.write_bytes(b"%PDF-1.4\n%%EOF") + + async with session_scope() as session: + document = Document(name="", notes="") + session.add(document) + await session.flush() + source = Source( + document_id=document.id, + page_number=1, + upload_name="print-page.png", + filename="print-page.png", + file_path=str(media_path), + file_hash="a" * 64, + file_size_bytes=media_path.stat().st_size, + raw_transcription="line one\nline two", + ) + session.add(source) + session.add( + Source( + document_id=document.id, + page_number=2, + upload_name="print-page.pdf", + filename="print-page.pdf", + file_path=str(pdf_path), + file_hash="c" * 64, + file_size_bytes=pdf_path.stat().st_size, + raw_transcription="PDF source", + ) + ) + await session.commit() + document_id = document.id + source_id = source.id + + response = client.get(f"/ui/documents/{document_id}/print") + assert response.status_code == 200 + assert "Print & Preserve" in response.text + assert "<script>unsafe()</script>" in response.text + assert "Facsimile" in response.text + assert "Text only" in response.text + assert "print-page-break" in response.text + assert "print-source-pdf" in response.text + assert str(media_path) not in response.text + assert str(pdf_path) not in response.text + + media_response = client.get(f"/api/v4/documents/{document_id}/sources/{source_id}/media") + assert media_response.status_code == 200 + assert media_response.headers["content-type"] == "image/png" + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_document_source_media_rejects_cross_document_access(app_client): + app, client = app_client + media_path = Path(app.state.settings.upload_dir) / "documents" / "other.png" + media_path.parent.mkdir(parents=True, exist_ok=True) + media_path.write_bytes(b"\x89PNG\r\n\x1a\n") + + async with session_scope() as session: + owner = Document(name="Owner") + other = Document(name="Other") + session.add_all([owner, other]) + await session.flush() + source = Source( + document_id=owner.id, + page_number=1, + upload_name="other.png", + filename="other.png", + file_path=str(media_path), + file_hash="b" * 64, + file_size_bytes=media_path.stat().st_size, + ) + session.add(source) + await session.commit() + other_id = other.id + source_id = source.id + + response = client.get(f"/api/v4/documents/{other_id}/sources/{source_id}/media") + assert response.status_code == 404 diff --git a/tests/ui/test_sources_page.py b/tests/ui/test_sources_page.py index b6d76c6..40925c8 100644 --- a/tests/ui/test_sources_page.py +++ b/tests/ui/test_sources_page.py @@ -80,7 +80,7 @@ class TestSourcesPageRendering: _, client = app_client async with session_scope() as session: - document = Document(name="Source Document", document_type="letter") + document = Document(name="Source Document") session.add(document) await session.flush() session.add( @@ -108,8 +108,8 @@ class TestSourcesPageRendering: _, client = app_client async with session_scope() as session: - target = Document(name="Target", document_type="letter") - other = Document(name="Other", document_type="record") + target = Document(name="Target") + other = Document(name="Other") session.add_all([target, other]) await session.flush() @@ -274,7 +274,7 @@ class TestSourcesPageRendering: _, client = app_client async with session_scope() as session: - document = Document(name="Unlinked Source Doc", document_type="memo") + document = Document(name="Unlinked Source Doc") session.add(document) await session.flush() source = Source(