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(