V4.4 Complete

This commit is contained in:
Jim Lancaster
2026-08-15 14:30:33 -05:00
parent 63373bf24d
commit 7db4df1729
32 changed files with 1529 additions and 716 deletions
+29 -6
View File
@@ -11,10 +11,11 @@ Documents manages the archival record for each historical artifact independently
| `/documents` | Searchable archival Document list. | | `/documents` | Searchable archival Document list. |
| `/documents/new` | Create a Document. | | `/documents/new` | Create a Document. |
| `/documents/{document_id}` | View one Document and its related records. | | `/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}/delete` | Confirm or block deletion. |
| `/documents/{document_id}/jobs` | Show Jobs belonging to the Document. | | `/documents/{document_id}/jobs` | Show Jobs belonging to the Document. |
| `/documents/{document_id}/sources` | Redirect to the Document-filtered Sources list. | | `/documents/{document_id}/sources` | Redirect to the Document-filtered Sources list. |
| `/documents/{document_id}/print` | Preview and browser-print the persisted Document. |
## List Behavior ## List Behavior
@@ -42,7 +43,7 @@ Optional:
- Document location. - Document location.
- Archive identifier. - Archive identifier.
- Notes. - Notes.
- Multiple people for every configured Person Role. - Linked People, with exactly one Person Role per linked Person.
Rules: Rules:
@@ -53,6 +54,11 @@ Rules:
- An invalid requested Person produces a warning rather than a broken form. - 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. - `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. - 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. - Save success returns to Document Detail.
## Detail Behavior ## Detail Behavior
@@ -63,9 +69,20 @@ Rules:
- System Logistics shows created and updated timestamps. - System Logistics shows created and updated timestamps.
- Related People are grouped by role and link to Person Detail. - 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. - **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. - 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 ## Document Jobs Behavior
- The page lists the Document's Jobs newest first with status and Job ID. - 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. - List columns, alignment, search, sorting, date fallback, and row navigation match this contract.
- Create/edit enforce name, registered type, and valid exact-date input. - 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. - Person-first Document creation preselects the requested Person as author.
- Detail links people, Sources, and Jobs to the correct records. - Detail links people, Sources, and Jobs to the correct records.
- Delete never removes a Document with Source or Job dependencies. - 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. - Service failures use the shared error presenter and never report false success.
## Implementation Anchors ## Implementation Anchors
@@ -96,10 +115,14 @@ Rules:
- `src/transcription/ui/components/table/documents.py` - `src/transcription/ui/components/table/documents.py`
- `src/transcription/services/documents.py` - `src/transcription/services/documents.py`
- `src/transcription/services/people.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/ui/test_documents_page.py`
- `tests/services/test_document_service.py` - `tests/services/test_document_service.py`
## Known Limitations and Deferred Work ## 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 page ordering remains read-only in V4.4.
- Source ordering controls are deferred to the [draft V4.3 scope](../../ver4.3/scope_boundary_v4_3.md). - Printing other entities, batch printing, and server-side export formats are deferred.
+27 -17
View File
@@ -10,8 +10,8 @@ This document describes the production architecture of the document transcriptio
- Execute page transcription concurrently with bounded `asyncio` workers. - Execute page transcription concurrently with bounded `asyncio` workers.
- Maintain relational portability across SQLite and PostgreSQL. - Maintain relational portability across SQLite and PostgreSQL.
- Keep operator workflows cross-platform and Python-driven. - Keep operator workflows cross-platform and Python-driven.
- Support many-to-many document-person relationships with extensible roles. - Support one role-bearing link per Person and Document through an extensible role registry.
- Support registry-driven document type classification. - Support registry-driven document classification with protected semantic built-ins.
## Core Capabilities ## 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. - 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`. - 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`. - 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. - Organize historical `Person` records through UUID-identified Document links and extensible roles.
- Classify Documents through a UUID-identified registry with unique labels. - Classify Documents through a UUID-identified registry with hidden semantic built-ins and unique labels.
- Maintain human revision separately from machine-generated text. - Maintain human revision separately from machine-generated text.
- Isolate page failures so multi-page jobs can complete with partial success. - Isolate page failures so multi-page jobs can complete with partial success.
- Operate across supported platforms through Python-based application and maintenance tooling. - Operate across supported platforms through Python-based application and maintenance tooling.
@@ -110,8 +110,8 @@ Responsibilities:
- Jobs own job lifecycle state and transitions. - Jobs own job lifecycle state and transitions.
- People own person records, relationship roles, document-person links, and portrait media. - People own person records, relationship roles, document-person links, and portrait media.
- Apply deterministic conflict handling for relationship-role writes. - Apply deterministic conflict handling for relationship-role writes.
- Use set-based synchronization for many-to-many relationship updates. - Synchronize each Document's complete Person link set in the same transaction as Document fields.
- Resolve and validate registry-backed document types by UUID. - Resolve and validate registry records by UUID; use hidden semantic keys only for application-owned built-in behavior.
### Source Media Policy ### Source Media Policy
@@ -146,11 +146,11 @@ Responsibilities:
### 2. Document-Person Relationship Management ### 2. Document-Person Relationship Management
1. User opens a document or person edit flow. 1. User opens Document Create or Edit.
2. UI loads existing links grouped by role. 2. UI loads one Linked People table containing Person and Role.
3. User adds or removes people within one or more roles. 3. Add, Edit, and Delete operations change staged UI state only.
4. Service computes add/remove deltas rather than replacing all links blindly. 4. Service validates the complete desired set and computes deterministic add, update, and remove deltas.
5. Conflict checks enforce uniqueness and deterministic write semantics before persistence commits. 5. Document fields and links commit once in one transaction; any failure leaves both unchanged.
### 3. Document Type Management ### 3. Document Type Management
@@ -159,6 +159,14 @@ Responsibilities:
3. Persistence stores the `document_type_id` reference. 3. Persistence stores the `document_type_id` reference.
4. Inactive types remain valid for historical rows but are excluded from default selectors. 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 ## V4 Domain Rules
- `JobSource.raw_transcription` preserves page output for its Job execution. - `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. - 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. - 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. - 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)`. - `DocumentPerson` links are unique for `(document_id, person_id)` and require one `role_id`.
- Relationship mutations are deterministic and set-based. - Relationship mutations are deterministic, set-based, and atomic with Document writes.
- `DocumentType.id` is canonical identity; its unique label may evolve. - `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 ## Data Model Summary
- `Document` has one `DocumentType`, many `Source` pages, many `Job` runs, and many `Person` records through `DocumentPerson`. - `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. - `Source` belongs to one `Document` and may participate in many `JobSource` executions.
- `Job` has many `JobSource` rows. - `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 ## Test Strategy
- Unit tests for models, validation, hashing, and registry resolution. - 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. - 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 ## Related Local References
+16 -10
View File
@@ -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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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 ## Clarifying Constraints
1. `DocumentType.id` is its sole identity; labels are unique ignoring case and surrounding whitespace. 1. `DocumentType.id` and `PersonRole.id` are their public and relationship identities; labels are unique ignoring case and surrounding whitespace.
2. `PersonRole.code` is a stable machine identifier; `PersonRole.label` may evolve. 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. 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. 4. One Person may appear only once per Document and every link has exactly one role.
5. Relationship conflicts must fail deterministically without partial mutation. 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 ## Element Satisfaction Mapping
- UI (NiceGUI): Satisfies REQ-0, REQ-1, REQ-5, REQ-9, REQ-12, REQ-13. - 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. - 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. - 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. - Test Suite: Verifies all test-marked requirements and satisfies REQ-19.
## Related Local References ## Related Local References
+23 -7
View File
@@ -8,6 +8,7 @@ This document defines the relational schema for the document transcription syste
erDiagram erDiagram
DOCUMENT_TYPE { DOCUMENT_TYPE {
UUID id PK UUID id PK
TEXT semantic_key UK
TEXT label TEXT label
TEXT normalized_label TEXT normalized_label
BOOLEAN is_active BOOLEAN is_active
@@ -17,8 +18,9 @@ TIMESTAMPTZ updated_at
PERSON_ROLE { PERSON_ROLE {
UUID id PK UUID id PK
TEXT code TEXT semantic_key UK
TEXT label TEXT label
TEXT normalized_label
BOOLEAN is_active BOOLEAN is_active
TIMESTAMPTZ created_at TIMESTAMPTZ created_at
TIMESTAMPTZ updated_at TIMESTAMPTZ updated_at
@@ -196,17 +198,29 @@ EXECUTION_ATTEMPT ||--o{ PROCESSING_ARTIFACT : produces
- `SOURCE.raw_transcription` remains immutable machine output. - `SOURCE.raw_transcription` remains immutable machine output.
- `SOURCE.revised_text` stores human edits and is the preferred display value when present. - `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 ### 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. - Relationship roles are defined by `PERSON_ROLE` rather than hardcoded columns.
- `DOCUMENT_PERSON` must be unique for `(document_id, person_id, role_id)`. - `DOCUMENT_PERSON.role_id` is required.
- Relationship writes must be deterministic and use explicit add/remove link intent. - `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 ### Document Type Governance
- Every document type is defined by `DOCUMENT_TYPE`. - 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.label` is mutable display text and is unique after trimming and case normalization.
- `DOCUMENT_TYPE.normalized_label` stores the normalized uniqueness key. - `DOCUMENT_TYPE.normalized_label` stores the normalized uniqueness key.
- Inactive types remain valid for historical rows but should be excluded from default selection UIs. - 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 ## Constraint Summary
- `DOCUMENT_TYPE.normalized_label` is unique. - `DOCUMENT_TYPE.normalized_label` is unique.
- `PERSON_ROLE.code` is unique. - `DOCUMENT_TYPE.semantic_key` is nullable and unique.
- `DOCUMENT_PERSON(document_id, person_id, role_id)` is 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 ## Indexing Guidance
+6 -54
View File
@@ -12,7 +12,6 @@ from fastapi import Response
from pydantic import BaseModel from pydantic import BaseModel
from pydantic import ConfigDict from pydantic import ConfigDict
from pydantic import Field from pydantic import Field
from pydantic import model_validator
from transcription.db.models import Document from transcription.db.models import Document
from transcription.db.models import DocumentPerson from transcription.db.models import DocumentPerson
@@ -28,27 +27,6 @@ class ApiModel(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True) 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): class DocumentTypeRead(ApiModel):
id: UUID id: UUID
label: str label: str
@@ -57,7 +35,6 @@ class DocumentTypeRead(ApiModel):
class PersonRoleRead(ApiModel): class PersonRoleRead(ApiModel):
id: UUID id: UUID
code: str
label: str label: str
is_active: bool is_active: bool
@@ -73,39 +50,19 @@ class DocumentTypeWriteResponse(ApiModel):
class DocumentPersonWriteRequest(ApiModel): class DocumentPersonWriteRequest(ApiModel):
person_id: UUID person_id: UUID
role_id: UUID | None = None role_id: UUID
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
class DocumentPersonRoleUpdateRequest(SelectorRequest): class DocumentPersonRoleUpdateRequest(ApiModel):
role_id: UUID | None = None role_id: UUID
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 DocumentPersonRead(ApiModel): class DocumentPersonRead(ApiModel):
id: UUID id: UUID
document_id: UUID document_id: UUID
person_id: UUID person_id: UUID
role_id: UUID | None role_id: UUID
role_code: str role_label: str | None = None
person_name: 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: def _person_role_to_read(item: PersonRole) -> PersonRoleRead:
return PersonRoleRead( return PersonRoleRead(
id=item.id, id=item.id,
code=item.code,
label=item.label, label=item.label,
is_active=item.is_active, is_active=item.is_active,
) )
def _document_person_to_read(item: DocumentPerson) -> DocumentPersonRead: 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 person_name = item.person.full_name if item.person is not None else None
return DocumentPersonRead( return DocumentPersonRead(
id=item.id, id=item.id,
document_id=item.document_id, document_id=item.document_id,
person_id=item.person_id, person_id=item.person_id,
role_id=item.role_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, person_name=person_name,
) )
@@ -224,7 +178,6 @@ async def add_document_person_link(
document_id=document_id, document_id=document_id,
person_id=payload.person_id, person_id=payload.person_id,
role_id=payload.role_id, role_id=payload.role_id,
role_code=payload.role_code,
) )
return _document_person_to_read(link) return _document_person_to_read(link)
@@ -238,7 +191,6 @@ async def set_document_person_role(
link = await service.set_document_person_role( link = await service.set_document_person_role(
document_person_id=document_person_id, document_person_id=document_person_id,
role_id=payload.role_id, role_id=payload.role_id,
role_code=payload.role_code,
) )
return _document_person_to_read(link) return _document_person_to_read(link)
+54
View File
@@ -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)
+2
View File
@@ -17,6 +17,7 @@ from fastapi.staticfiles import StaticFiles
from .api.errors import register_error_handlers from .api.errors import register_error_handlers
from .api.health import router as health_router from .api.health import router as health_router
from .api.v4_documents import router as v4_documents_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 Settings
from .config import configure_logging from .config import configure_logging
from .config import get_settings from .config import get_settings
@@ -105,5 +106,6 @@ def create_app(settings: Settings | None = None) -> FastAPI:
register_error_handlers(app) register_error_handlers(app)
app.include_router(health_router) app.include_router(health_router)
app.include_router(v4_documents_router) app.include_router(v4_documents_router)
app.include_router(v4_print_router)
register_pages(app) register_pages(app)
return app return app
+5 -13
View File
@@ -44,12 +44,6 @@ class JobStatus(StrEnum):
FAILED = "failed" FAILED = "failed"
class DocumentPersonRole(StrEnum):
AUTHOR = "author"
RECIPIENT = "recipient"
MENTIONED = "mentioned"
class JobSourceStatus(StrEnum): class JobSourceStatus(StrEnum):
PENDING = "pending" PENDING = "pending"
TRANSCRIBED = "transcribed" TRANSCRIBED = "transcribed"
@@ -62,6 +56,7 @@ class DocumentType(SQLModel, table=True):
__tablename__ = "document_type" __tablename__ = "document_type"
id: UUID = Field(default_factory=uuid4, primary_key=True) id: UUID = Field(default_factory=uuid4, primary_key=True)
semantic_key: str | None = Field(default=None, index=True, unique=True)
label: str label: str
normalized_label: str = Field(index=True, unique=True) normalized_label: str = Field(index=True, unique=True)
is_active: bool = True is_active: bool = True
@@ -79,8 +74,9 @@ class PersonRole(SQLModel, table=True):
__tablename__ = "person_role" __tablename__ = "person_role"
id: UUID = Field(default_factory=uuid4, primary_key=True) 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 label: str
normalized_label: str = Field(index=True, unique=True)
is_active: bool = True is_active: bool = True
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_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) id: UUID = Field(default_factory=uuid4, primary_key=True)
document_id: UUID = Field(foreign_key="document.id") document_id: UUID = Field(foreign_key="document.id")
person_id: UUID = Field(foreign_key="person.id") person_id: UUID = Field(foreign_key="person.id")
role_id: UUID | None = Field(default=None, foreign_key="person_role.id") role_id: UUID = Field(foreign_key="person_role.id")
role: str = Field(default=DocumentPersonRole.AUTHOR.value, nullable=False)
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
__table_args__ = ( __table_args__ = (UniqueConstraint("document_id", "person_id", name="uq_document_person"),)
UniqueConstraint("document_id", "person_id", "role_id", name="uq_document_person_role_id"),
UniqueConstraint("document_id", "person_id", "role", name="uq_document_person_role"),
)
document: Optional["Document"] = Relationship( document: Optional["Document"] = Relationship(
back_populates="document_people", sa_relationship_kwargs={"lazy": "selectin"} back_populates="document_people", sa_relationship_kwargs={"lazy": "selectin"}
+22 -94
View File
@@ -16,26 +16,12 @@ from .models import DocumentType
from .models import Job from .models import Job
from .models import JobStatus from .models import JobStatus
from .models import PersonRole from .models import PersonRole
from .registries import BUILT_IN_DOCUMENT_TYPES
from .registries import BUILT_IN_PERSON_ROLES
logger = logging.getLogger(__name__) 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: async def create_all(*, engine: AsyncEngine | None = None) -> None:
"""Create any missing tables on the selected engine.""" """Create any missing tables on the selected engine."""
# Import models so SQLModel metadata is fully registered before bootstrap. # 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() active_engine = engine or resolve_engine()
async with active_engine.begin() as connection: async with active_engine.begin() as connection:
await connection.run_sync(SQLModel.metadata.create_all) await connection.run_sync(SQLModel.metadata.create_all)
await _upgrade_document_type_uuid_identity(connection)
await _upgrade_person_family_search_id(connection) await _upgrade_person_family_search_id(connection)
await _upgrade_v42_evidence_tables(connection) await _upgrade_v42_evidence_tables(connection)
await seed_registry_defaults(engine=active_engine) 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.""" """Apply non-destructive additive upgrades to an existing schema."""
active_engine = engine or resolve_engine() active_engine = engine or resolve_engine()
async with active_engine.begin() as connection: async with active_engine.begin() as connection:
await _upgrade_document_type_uuid_identity(connection)
await _upgrade_person_family_search_id(connection) await _upgrade_person_family_search_id(connection)
await _upgrade_v42_evidence_tables(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) 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: async def _upgrade_person_family_search_id(connection: AsyncConnection) -> None:
"""Add the nullable V4.1 FamilySearch field to an existing database.""" """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) session_factory = async_sessionmaker(active_engine, class_=AsyncSession, expire_on_commit=False)
async with session_factory() as session: async with session_factory() as session:
role_codes = set((await session.exec(select(PersonRole.code))).all()) role_keys = set((await session.exec(select(PersonRole.semantic_key))).all())
for code, label in DEFAULT_PERSON_ROLES: for semantic_key, label in BUILT_IN_PERSON_ROLES:
if code not in role_codes: if semantic_key not in role_keys:
session.add(PersonRole(code=code, label=label)) session.add(
PersonRole(
semantic_key=semantic_key,
label=label,
normalized_label=label.casefold(),
)
)
type_labels = set((await session.exec(select(DocumentType.normalized_label))).all()) type_keys = set((await session.exec(select(DocumentType.semantic_key))).all())
for label in DEFAULT_DOCUMENT_TYPES: for semantic_key, label in BUILT_IN_DOCUMENT_TYPES:
normalized_label = label.casefold() if semantic_key not in type_keys:
if normalized_label not in type_labels: session.add(
session.add(DocumentType(label=label, normalized_label=normalized_label)) DocumentType(
semantic_key=semantic_key,
label=label,
normalized_label=label.casefold(),
)
)
await session.commit() await session.commit()
+20
View File
@@ -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"
+101
View File
@@ -3,6 +3,7 @@ import shutil
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC from datetime import UTC
from datetime import date
from datetime import datetime from datetime import datetime
from uuid import UUID from uuid import UUID
@@ -16,9 +17,11 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from ..db.models import Document from ..db.models import Document
from ..db.models import DocumentPerson from ..db.models import DocumentPerson
from ..db.models import DocumentType from ..db.models import DocumentType
from ..db.registries import AUTHOR_ROLE_SEMANTIC_KEY
from ..errors import AppError from ..errors import AppError
from ..errors import ErrorCategory from ..errors import ErrorCategory
from .base import ServiceBase from .base import ServiceBase
from .sources import source_mime_type
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -65,9 +68,43 @@ class DocumentTypeSummary:
id: UUID id: UUID
label: str label: str
is_active: bool is_active: bool
is_built_in: bool
document_count: int 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): class DocumentService(ServiceBase):
"""Thin service class for managing documents in the database.""" """Thin service class for managing documents in the database."""
@@ -256,6 +293,58 @@ class DocumentService(ServiceBase):
) )
return document 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( async def list_document_types(
self, self,
*, *,
@@ -290,6 +379,7 @@ class DocumentService(ServiceBase):
id=document_type.id, id=document_type.id,
label=document_type.label, label=document_type.label,
is_active=document_type.is_active, is_active=document_type.is_active,
is_built_in=document_type.semantic_key is not None,
document_count=int(document_count), document_count=int(document_count),
) )
for document_type, document_count in rows for document_type, document_count in rows
@@ -383,6 +473,12 @@ class DocumentService(ServiceBase):
category=ErrorCategory.NOT_FOUND, category=ErrorCategory.NOT_FOUND,
suggestion="Refresh Settings and select an available Document Type.", 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): if await self._document_type_is_referenced(session=_session, document_type=document_type):
raise DocumentTypeError( raise DocumentTypeError(
f"Document Type {document_type.label!r} is referenced and cannot be deleted", 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) document.updated_at = datetime.now(UTC)
await self._finalize(session=_session, caller_session=session, refresh=(document,)) await self._finalize(session=_session, caller_session=session, refresh=(document,))
return 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
+186 -84
View File
@@ -5,12 +5,14 @@ from __future__ import annotations
import logging import logging
import re import re
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass
from datetime import UTC from datetime import UTC
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from uuid import UUID from uuid import UUID
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from sqlmodel import select from sqlmodel import select
@@ -20,7 +22,6 @@ from ..config import Settings
from ..config import get_settings from ..config import get_settings
from ..db.models import Document from ..db.models import Document
from ..db.models import DocumentPerson from ..db.models import DocumentPerson
from ..db.models import DocumentPersonRole
from ..db.models import Person from ..db.models import Person
from ..db.models import PersonRole from ..db.models import PersonRole
from ..errors import AppError from ..errors import AppError
@@ -45,20 +46,6 @@ class PersonRoleError(PeopleError):
"""Raised when Person Role maintenance fails.""" """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: def _normalize_role_label(label: str) -> str:
normalized = label.strip() normalized = label.strip()
if not normalized: if not normalized:
@@ -84,6 +71,29 @@ def normalize_family_search_id(value: str | None) -> str | None:
return normalized 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): class PeopleService(ServiceBase):
"""Manage People, relationship roles, and document-person links.""" """Manage People, relationship roles, and document-person links."""
@@ -136,7 +146,7 @@ class PeopleService(ServiceBase):
session: AsyncSession | None = None, session: AsyncSession | None = None,
) -> DocumentPerson: ) -> DocumentPerson:
async with self._session_scope(session) as _session: 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) _session.add(document_person)
return await self._finalize_link(session=_session, caller_session=session, link=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, session: AsyncSession | None = None,
) -> DocumentPerson: ) -> DocumentPerson:
async with self._session_scope(session) as _session: 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) document_person.updated_at = datetime.now(UTC)
merged = await _session.merge(document_person) merged = await _session.merge(document_person)
return await self._finalize_link(session=_session, caller_session=session, link=merged) return await self._finalize_link(session=_session, caller_session=session, link=merged)
@@ -204,20 +221,44 @@ class PeopleService(ServiceBase):
query = select(PersonRole) query = select(PersonRole)
if active_only: if active_only:
query = query.where(PersonRole.is_active.is_(True)) 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( async def create_person_role(
self, self,
*, *,
code: str,
label: str, label: str,
is_active: bool = True, is_active: bool = True,
session: AsyncSession | None = None, session: AsyncSession | None = None,
) -> PersonRole: ) -> PersonRole:
"""Create a Person Role with an immutable normalized code.""" """Create a custom Person Role with a unique label."""
role = PersonRole( role = PersonRole(
code=_normalize_role_code(code),
label=_normalize_role_label(label), label=_normalize_role_label(label),
normalized_label=_person_role_label_key(label),
is_active=is_active, is_active=is_active,
) )
async with self._session_scope(session) as _session: 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,)) await self._finalize(session=_session, caller_session=session, refresh=(role,))
except IntegrityError as exc: except IntegrityError as exc:
raise PersonRoleError( raise PersonRoleError(
f"Person Role code {role.code!r} already exists", f"Person Role label {role.label!r} already exists",
category=ErrorCategory.CONFLICT, 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 ) from exc
return role return role
@@ -257,7 +298,7 @@ class PeopleService(ServiceBase):
is_active: bool, is_active: bool,
session: AsyncSession | None = None, session: AsyncSession | None = None,
) -> PersonRole: ) -> 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: async with self._session_scope(session) as _session:
role = await _session.get(PersonRole, person_role_id) role = await _session.get(PersonRole, person_role_id)
if role is None: if role is None:
@@ -267,9 +308,17 @@ class PeopleService(ServiceBase):
suggestion="Refresh Settings and select an available Person Role.", suggestion="Refresh Settings and select an available Person Role.",
) )
role.label = _normalize_role_label(label) role.label = _normalize_role_label(label)
role.normalized_label = _person_role_label_key(label)
role.is_active = is_active role.is_active = is_active
role.updated_at = datetime.now(UTC) role.updated_at = datetime.now(UTC)
try:
await self._finalize(session=_session, caller_session=session, refresh=(role,)) 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 return role
async def delete_person_role( async def delete_person_role(
@@ -287,6 +336,12 @@ class PeopleService(ServiceBase):
category=ErrorCategory.NOT_FOUND, category=ErrorCategory.NOT_FOUND,
suggestion="Refresh Settings and select an available Person Role.", 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): if await self._person_role_is_referenced(session=_session, role=role):
raise PersonRoleError( raise PersonRoleError(
f"Person Role {role.label!r} is referenced and cannot be deleted", f"Person Role {role.label!r} is referenced and cannot be deleted",
@@ -302,7 +357,7 @@ class PeopleService(ServiceBase):
*, *,
session: AsyncSession | None = None, session: AsyncSession | None = None,
) -> bool: ) -> 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: async with self._session_scope(session) as _session:
role = await _session.get(PersonRole, person_role_id) role = await _session.get(PersonRole, person_role_id)
if role is None: if role is None:
@@ -319,15 +374,26 @@ class PeopleService(ServiceBase):
session: AsyncSession, session: AsyncSession,
role: PersonRole, role: PersonRole,
) -> bool: ) -> bool:
reference = ( reference = (await session.exec(select(DocumentPerson.id).where(DocumentPerson.role_id == role.id))).first()
await session.exec(
select(DocumentPerson.id).where(
(DocumentPerson.role_id == role.id) | (DocumentPerson.role == role.code)
)
)
).first()
return reference is not None 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( async def list_document_people(
self, self,
*, *,
@@ -352,8 +418,7 @@ class PeopleService(ServiceBase):
*, *,
document_id: UUID, document_id: UUID,
person_id: UUID, person_id: UUID,
role_id: UUID | None = None, role_id: UUID,
role_code: str | None = None,
session: AsyncSession | None = None, session: AsyncSession | None = None,
) -> DocumentPerson: ) -> DocumentPerson:
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
@@ -361,15 +426,8 @@ class PeopleService(ServiceBase):
if await _session.get(Person, person_id) is None: if await _session.get(Person, person_id) is None:
raise self._not_found(f"Person with id {person_id} not found") raise self._not_found(f"Person with id {person_id} not found")
link = DocumentPerson( await self._validate_role(session=_session, role_id=role_id, require_active=True)
document_id=document_id, link = DocumentPerson(document_id=document_id, person_id=person_id, role_id=role_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)
_session.add(link) _session.add(link)
return await self._finalize_link(session=_session, caller_session=session, link=link) return await self._finalize_link(session=_session, caller_session=session, link=link)
@@ -377,31 +435,19 @@ class PeopleService(ServiceBase):
self, self,
*, *,
document_person_id: UUID, document_person_id: UUID,
role_id: UUID | None = None, role_id: UUID,
role_code: str | None = None,
session: AsyncSession | None = None, session: AsyncSession | None = None,
) -> DocumentPerson: ) -> DocumentPerson:
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
link = await _session.get(DocumentPerson, document_person_id) link = await _session.get(DocumentPerson, document_person_id)
if link is None: if link is None:
raise self._not_found(f"DocumentPerson with id {document_person_id} not found") raise self._not_found(f"DocumentPerson with id {document_person_id} not found")
if role_id is None and not (role_code or "").strip(): await self._validate_role(
raise PeopleError( session=_session,
"Either role_id or role_code is required", role_id=role_id,
category=ErrorCategory.VALIDATION, require_active=link.role_id != role_id,
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.",
)
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) link.updated_at = datetime.now(UTC)
return await self._finalize_link(session=_session, caller_session=session, link=link) return await self._finalize_link(session=_session, caller_session=session, link=link)
@@ -418,14 +464,82 @@ class PeopleService(ServiceBase):
await _session.delete(link) await _session.delete(link)
await self._finalize(session=_session, caller_session=session) 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, self,
*, *,
session: AsyncSession, session: AsyncSession,
role_id: UUID | None, role_id: UUID,
role_code: str, require_active: bool = False,
) -> PersonRole: ) -> PersonRole:
if role_id is not None:
role = await session.get(PersonRole, role_id) role = await session.get(PersonRole, role_id)
if role is None: if role is None:
raise PeopleError( raise PeopleError(
@@ -433,22 +547,14 @@ class PeopleService(ServiceBase):
category=ErrorCategory.VALIDATION, category=ErrorCategory.VALIDATION,
suggestion="Select a valid relationship role and retry.", 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 return role
normalized_code = role_code.strip().lower() or DocumentPersonRole.AUTHOR.value
role = (await session.exec(select(PersonRole).where(PersonRole.code == normalized_code))).first()
if role is None:
role = PersonRole(code=normalized_code, label=normalized_code.replace("_", " ").title())
session.add(role)
await session.flush()
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( async def _finalize_link(
self, self,
*, *,
@@ -460,9 +566,9 @@ class PeopleService(ServiceBase):
await self._finalize(session=session, caller_session=caller_session, refresh=(link,)) await self._finalize(session=session, caller_session=caller_session, refresh=(link,))
except IntegrityError as exc: except IntegrityError as exc:
raise PeopleError( raise PeopleError(
"Duplicate relationship link for document/person/role", "This Person is already linked to the Document",
category=ErrorCategory.CONFLICT, 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 ) from exc
return link return link
@@ -478,10 +584,6 @@ class PeopleService(ServiceBase):
suggestion="Open the existing person record or enter a different FamilySearch ID.", 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 @staticmethod
def _not_found(message: str) -> PeopleError: def _not_found(message: str) -> PeopleError:
return PeopleError( return PeopleError(
+34 -3
View File
@@ -9,10 +9,12 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from ..config import Settings from ..config import Settings
from ..config import get_settings from ..config import get_settings
from ..db.models import Document
from ..db.models import Job from ..db.models import Job
from ..db.models import JobSourceStatus from ..db.models import JobSourceStatus
from ..db.models import JobStatus from ..db.models import JobStatus
from ..db.models import Source from ..db.models import Source
from ..db.session import transaction_scope
from ..errors import AppError from ..errors import AppError
from ..errors import ErrorCategory from ..errors import ErrorCategory
from ..errors import classify_unexpected_error from ..errors import classify_unexpected_error
@@ -24,6 +26,9 @@ from ..providers import TranscriptionProvider
from ..providers import TranscriptionResult from ..providers import TranscriptionResult
from ..providers import TransportEvidence from ..providers import TransportEvidence
from . import ServiceBundle from . import ServiceBundle
from .documents import DocumentService
from .people import DocumentPersonInput
from .people import PeopleService
from .sources import PromptExecution from .sources import PromptExecution
from .sources import build_prompt_execution from .sources import build_prompt_execution
from .sources import hash_prompt_text from .sources import hash_prompt_text
@@ -33,6 +38,34 @@ from .sources import transcribe_document_image
logger = logging.getLogger(__name__) 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) @dataclass(frozen=True)
class _SuccessfulPage: class _SuccessfulPage:
source: Source source: Source
@@ -392,9 +425,7 @@ async def _persist_page_outcome_durably(
session: AsyncSession | None, session: AsyncSession | None,
) -> None: ) -> None:
"""Commit one completed provider call before processing the next source.""" """Commit one completed provider call before processing the next source."""
task = asyncio.create_task( task = asyncio.create_task(_persist_page_outcome(job=job, services=services, page=page, session=session))
_persist_page_outcome(job=job, services=services, page=page, session=session)
)
try: try:
await asyncio.shield(task) await asyncio.shield(task)
except asyncio.CancelledError: except asyncio.CancelledError:
+2
View File
@@ -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.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.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.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.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.pages.sources_page import register_page as register_sources_page
from transcription.ui.resources import read_css from transcription.ui.resources import read_css
@@ -37,6 +38,7 @@ def register_pages(app: FastAPI) -> None:
register_home_page() register_home_page()
register_documents_page() register_documents_page()
register_people_page() register_people_page()
register_print_preview_page()
register_sources_page() register_sources_page()
register_jobs_page() register_jobs_page()
register_settings_page(settings=getattr(app.state, "settings", None) or get_settings()) register_settings_page(settings=getattr(app.state, "settings", None) or get_settings())
@@ -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
+70 -134
View File
@@ -11,18 +11,22 @@ from fastapi.responses import RedirectResponse
from nicegui import ui from nicegui import ui
from transcription.db.models import Document from transcription.db.models import Document
from transcription.db.registries import AUTHOR_ROLE_SEMANTIC_KEY
from transcription.errors import ErrorCategory from transcription.errors import ErrorCategory
from transcription.services.documents import DocumentDeleteBlockedError from transcription.services.documents import DocumentDeleteBlockedError
from transcription.services.documents import DocumentError from transcription.services.documents import DocumentError
from transcription.services.documents import DocumentService from transcription.services.documents import DocumentService
from transcription.services.people import PeopleService 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.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card from transcription.ui.components.cards import archival_card
from transcription.ui.components.data_display import archival_badge from transcription.ui.components.data_display import archival_badge
from transcription.ui.components.data_display import metadata_row from transcription.ui.components.data_display import metadata_row
from transcription.ui.components.error_presenter import show_error from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.formatters import compact_date 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 destructive_button
from transcription.ui.components.primitives import render_empty_state from transcription.ui.components.primitives import render_empty_state
from transcription.ui.components.primitives import section_header_row 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.") 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()) 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() type_catalog = await document_service.list_document_types()
requested_person_id = _parse_uuid(request.query_params.get("person_id")) 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): 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"): elif request.query_params.get("person_id"):
ui.notify("The requested person could not be preselected.", type="warning") ui.notify("The requested person could not be preselected.", type="warning")
form = _render_document_form_fields( linked_people = LinkedPeopleEditor(
people=people, people=people,
role_codes=[role.code for role in role_catalog], roles=role_catalog,
role_labels={role.code: role.label for role in 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}, 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") return_to = request.query_params.get("return_to")
@@ -91,25 +107,16 @@ def register_page() -> None: # noqa: PLR0915
) )
try: 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 except Exception as exc: # noqa: BLE001
show_error(exc, title="Create failed", operation="documents.create") show_error(exc, title="Create failed", operation="documents.create")
return 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") ui.notify("Document created", type="positive")
if return_to == "jobs_new": if return_to == "jobs_new":
ui.navigate.to(f"/jobs/new?document_id={created.id}") ui.navigate.to(f"/jobs/new?document_id={created.id}")
@@ -149,7 +156,7 @@ def register_page() -> None: # noqa: PLR0915
id=doc.id, id=doc.id,
name=doc.name, name=doc.name,
document_type=(doc.document_type_ref.label if doc.document_type_ref is not None else ""), 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), document_date=compact_date(doc.document_date, doc.document_date_raw),
archive_identifier=doc.archive_identifier or "", 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}") page_header(document.name, subtitle=f"Type: {type_display} | ID: {document.id}")
with ui.row().classes("items-center gap-2"): 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( ui.button(
"Edit Document", "Edit Document",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"), 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.") 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()) 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) 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( form = _render_document_form_fields(
document=document, 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}, 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: async def submit_edit() -> None:
@@ -319,34 +332,16 @@ def register_page() -> None: # noqa: PLR0915
) )
try: 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 except Exception as exc: # noqa: BLE001
show_error(exc, title="Save failed", operation="documents.edit.save") show_error(exc, title="Save failed", operation="documents.edit.save")
return 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.notify("Document updated", type="positive")
ui.navigate.to(f"/documents/{document.id}") ui.navigate.to(f"/documents/{document.id}")
@@ -444,11 +439,8 @@ def register_page() -> None: # noqa: PLR0915
def _render_document_form_fields( def _render_document_form_fields(
*, *,
document: Document | None = None, document: Document | None = None,
people: list[Any],
role_codes: list[str],
role_labels: dict[str, str],
type_options: dict[str, str], type_options: dict[str, str],
selected_people_by_role: dict[str, list[UUID]] | None = None, linked_people: LinkedPeopleEditor,
) -> dict[str, Any]: ) -> dict[str, Any]:
with archival_card(extra_classes="gap-3"): with archival_card(extra_classes="gap-3"):
name_input = ( name_input = (
@@ -510,27 +502,7 @@ def _render_document_form_fields(
.classes("w-full ui-form-surface") .classes("w-full ui-form-surface")
) )
ui.label("Linked People by Role").classes("text-sm font-semibold ui-text-primary mt-2") linked_people.render()
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
return { return {
"name": name_input, "name": name_input,
@@ -541,7 +513,6 @@ def _render_document_form_fields(
"location": location_input, "location": location_input,
"archive": archive_input, "archive": archive_input,
"notes": notes_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: def _render_bento_metadata_zone(document: Document) -> None:
people_by_role = _group_people_labels_by_role(document) author_names = _author_names(document)
author_names = people_by_role.get("author", [])
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"): with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Archival Metadata"): with archival_card(title="Archival Metadata"):
@@ -585,10 +555,10 @@ def _render_related_people_card(document: Document) -> None:
grouped = _group_people_by_role(document) grouped = _group_people_by_role(document)
with ui.column().classes("w-full gap-2"): 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"): with ui.column().classes("w-full ui-row-surface p-2 gap-1"):
archival_badge(role_code) archival_badge(role_label)
for person in grouped[role_code]: for person in grouped[role_label]:
ui.button( ui.button(
person.full_name, person.full_name,
on_click=lambda _=None, person_id=person.id: ui.navigate.to(f"/people/{person_id}"), 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) 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]]: def _group_people_by_role(document: Document) -> dict[str, list[Any]]:
grouped: dict[str, list[Any]] = {} grouped: dict[str, list[Any]] = {}
for link in document.document_people: for link in document.document_people:
role_code = _resolve_link_role_code(link) if link.role_ref is not None and link.person is not None:
if role_code is not None and link.person is not None: grouped.setdefault(link.role_ref.label, []).append(link.person)
grouped.setdefault(role_code, []).append(link.person)
for people in grouped.values(): for people in grouped.values():
people.sort(key=lambda person: person.full_name.casefold()) people.sort(key=lambda person: person.full_name.casefold())
return grouped 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,
)
+2 -2
View File
@@ -473,11 +473,11 @@ def _render_linked_documents(person: Person) -> None:
doc = link.document doc = link.document
if doc is None: if doc is None:
continue 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.row().classes("w-full justify-between items-center ui-row-surface p-2"):
with ui.column().classes("gap-0"): with ui.column().classes("gap-0"):
ui.label(doc.name).classes("text-xs font-semibold ui-text-primary") 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( ui.button(
"Open", "Open",
on_click=lambda _=None, doc_id=doc.id: ui.navigate.to(f"/documents/{doc_id}"), on_click=lambda _=None, doc_id=doc.id: ui.navigate.to(f"/documents/{doc_id}"),
@@ -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'<iframe class="print-source-pdf" src="{media_url}" title="Source page {source.page_number}"></iframe>'
)
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()]
+140 -84
View File
@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
@@ -59,6 +58,7 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
"label": item.label, "label": item.label,
"document_count": item.document_count, "document_count": item.document_count,
"is_active": item.is_active, "is_active": item.is_active,
"is_built_in": item.is_built_in,
} }
for item in document_types for item in document_types
] ]
@@ -84,6 +84,12 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
"field": "is_active", "field": "is_active",
"align": "center", "align": "center",
}, },
{
"name": "is_built_in",
"label": "Built-in",
"field": "is_built_in",
"align": "center",
},
], ],
rows=rows, rows=rows,
row_key="id", row_key="id",
@@ -98,6 +104,14 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
</q-td> </q-td>
""", """,
) )
table.add_slot(
"body-cell-is_built_in",
"""
<q-td :props="props" class="text-center">
{{ props.value ? 'Yes' : 'No' }}
</q-td>
""",
)
async def save_type( 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) destructive_button("Delete", icon="delete", on_click=delete_selected_type)
@ui.refreshable @ui.refreshable
async def render_person_roles() -> None: async def render_person_roles() -> None: # noqa: PLR0915
with archival_card("Person Roles"): with archival_card("Person Roles"):
ui.label( 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") ).classes("text-xs ui-text-muted mb-3")
try: try:
roles = await people.list_person_roles(active_only=False) roles = await people.list_person_role_summaries()
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
show_error(exc, title="Person Roles unavailable", operation="settings.roles.list") show_error(exc, title="Person Roles unavailable", operation="settings.roles.list")
return 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: if not roles:
render_empty_state("No Person Roles are configured.", extra_classes="mt-3") render_empty_state("No Person Roles are configured.", extra_classes="mt-3")
for role in roles: rows = [
is_referenced = await people.is_person_role_referenced(role.id) {
_person_role_row( "id": str(role.id),
role, "label": role.label,
is_referenced=is_referenced, "link_count": role.link_count,
on_save=_save_role(people, role.id, render_person_roles.refresh), "is_active": role.is_active,
on_delete=_delete_role(people, role.id, render_person_roles.refresh), "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",
"""
<q-td :props="props" class="text-center">
<q-checkbox :model-value="props.value" disable dense />
</q-td>
""",
) )
table.add_slot(
"body-cell-is_built_in",
"""
<q-td :props="props" class="text-center">
{{ props.value ? 'Yes' : 'No' }}
</q-td>
""",
)
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 @ui.refreshable
def render_prompts() -> None: def render_prompts() -> None:
@@ -297,57 +407,3 @@ def _selected_table_row(table: Any) -> dict[str, Any] | None:
if len(selected) != 1: if len(selected) != 1:
return None return None
return selected[0] 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
+63
View File
@@ -54,6 +54,69 @@ input:focus-visible,
outline-offset: 2px; 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 */ /* Semantic utility classes */
.ui-text-primary { .ui-text-primary {
color: var(--theme-text); color: var(--theme-text);
+27 -15
View File
@@ -24,6 +24,7 @@ from transcription.db.engine import get_engine
from transcription.db.models import Document from transcription.db.models import Document
from transcription.db.models import DocumentType from transcription.db.models import DocumentType
from transcription.db.models import Person 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 dispose_session_factory
from transcription.db.session import session_scope from transcription.db.session import session_scope
from transcription.services.documents import DocumentService 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 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 @contextmanager
def _v4_api_client(tmp_path: Path, *, db_filename: str) -> Generator[tuple[TestClient, str]]: def _v4_api_client(tmp_path: Path, *, db_filename: str) -> Generator[tuple[TestClient, str]]:
settings = Settings( settings = Settings(
@@ -97,7 +104,7 @@ def test_list_document_types_returns_seeded_registry(tmp_path):
assert response.status_code == 200 assert response.status_code == 200
payload = response.json() payload = response.json()
labels = {item["label"] for item in payload} 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): 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 assert response.status_code == 200
payload = response.json() payload = response.json()
codes = {item["code"] for item in payload} labels = {item["label"] for item in payload}
assert {"author", "recipient", "mentioned"}.issubset(codes) 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): 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): 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) 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( response = client.put(
f"/api/v4/documents/{document_id}/type", f"/api/v4/documents/{document_id}/type",
json={"document_type_id": str(type_id)}, 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): 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): 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) 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( create_response = client.post(
f"/api/v4/documents/{document_id}/people", 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 assert create_response.status_code == 200
created = create_response.json() created = create_response.json()
assert created["document_id"] == str(document_id) assert created["document_id"] == str(document_id)
assert created["person_id"] == str(person_id) assert created["person_id"] == str(person_id)
assert created["role_code"] == "author" assert created["role_id"] == str(author_id)
assert created["role_id"] is not None assert created["role_label"] == "Author"
link_id = created["id"] link_id = created["id"]
update_response = client.patch( update_response = client.patch(
f"/api/v4/document-people/{link_id}", f"/api/v4/document-people/{link_id}",
json={"role_code": "recipient"}, json={"role_id": str(recipient_id)},
) )
assert update_response.status_code == 200 assert update_response.status_code == 200
updated = update_response.json() 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") list_response = client.get(f"/api/v4/documents/{document_id}/people")
assert list_response.status_code == 200 assert list_response.status_code == 200
links = list_response.json()["links"] links = list_response.json()["links"]
assert len(links) == 1 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}") delete_response = client.delete(f"/api/v4/document-people/{link_id}")
assert delete_response.status_code == 204 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"] == [] 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): 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) 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)}, json={"person_id": str(person_id)},
) )
assert response.status_code == 200 assert response.status_code == 422
assert response.json()["role_code"] == "author"
def test_duplicate_document_person_link_returns_conflict_envelope(tmp_path): 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): 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) 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( first = client.post(
f"/api/v4/documents/{document_id}/people", 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 assert first.status_code == 200
second = client.post( second = client.post(
f"/api/v4/documents/{document_id}/people", 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 assert second.status_code == 409
+13 -20
View File
@@ -5,15 +5,12 @@ from datetime import datetime
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
from sqlmodel import select
from transcription.config import Settings from transcription.config import Settings
from transcription.db.models import Document from transcription.db.models import Document
from transcription.db.models import DocumentPerson from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import Job from transcription.db.models import Job
from transcription.db.models import Person from transcription.db.models import Person
from transcription.db.models import PersonRole
from transcription.db.models import Source from transcription.db.models import Source
from transcription.services.documents import DocumentDeleteBlockedError from transcription.services.documents import DocumentDeleteBlockedError
from transcription.services.documents import DocumentError 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")) 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( await people_service.create_document_person(
DocumentPerson( DocumentPerson(
document_id=document.id, document_id=document.id,
person_id=person.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 len(links_before_delete) == 1
assert links_before_delete[0].role_id is not None 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 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 = service.settings.upload_dir / "documents" / str(document.id)
document_dir.mkdir(parents=True, exist_ok=True) 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")) 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( await people_service.create_document_person(
DocumentPerson( DocumentPerson(
document_id=document.id, document_id=document.id,
person_id=person.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")) person = await service.create_person(Person(full_name="Blocked Person"))
author_role = await service.create_person_role(label="Author")
await service.create_document_person( await service.create_document_person(
DocumentPerson( DocumentPerson(
document_id=document.id, document_id=document.id,
person_id=person.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 @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) documents_service = DocumentService(session_factory=default_session_factory)
service = PeopleService(session_factory=default_session_factory) service = PeopleService(session_factory=default_session_factory)
document = await documents_service.create_document( document = await documents_service.create_document(Document(id=uuid4(), name="role-sync-doc"))
Document(id=uuid4(), name="role-sync-doc", document_type="letter")
)
person = await service.create_person(Person(full_name="Role Sync Person")) 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( link = await service.create_document_person(
DocumentPerson( DocumentPerson(
document_id=document.id, document_id=document.id,
person_id=person.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, id=link.id,
document_id=document.id, document_id=document.id,
person_id=person.id, person_id=person.id,
role=DocumentPersonRole.RECIPIENT, role_id=recipient_role.id,
role_id=None,
) )
) )
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 assert updated.role_id == recipient_role.id
+24 -28
View File
@@ -6,8 +6,8 @@ import pytest
from transcription.db.models import Document from transcription.db.models import Document
from transcription.db.models import DocumentPerson from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import Person from transcription.db.models import Person
from transcription.db.models import PersonRole
from transcription.errors import ErrorCategory from transcription.errors import ErrorCategory
from transcription.services.documents import DocumentService from transcription.services.documents import DocumentService
from transcription.services.documents import DocumentTypeError 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 @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) service = PeopleService(session_factory=default_session_factory)
second = await service.create_person_role(code="witness", label="Witness") second = await service.create_person_role(label="Witness")
first = await service.create_person_role(code="author", label="Author") first = await service.create_person_role(label="Archivist")
updated = await service.update_person_role(second.id, label="Attestor", is_active=False) updated = await service.update_person_role(second.id, label="Attestor", is_active=False)
assert updated.code == "witness" assert updated.semantic_key is None
assert [item.id for item in await service.list_person_roles(active_only=False)] == [second.id, first.id] 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] 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 @pytest.mark.asyncio
async def test_person_role_delete_allows_unreferenced_and_blocks_referenced(default_session_factory): async def test_person_role_delete_allows_unreferenced_and_blocks_referenced(default_session_factory):
documents = DocumentService(session_factory=default_session_factory) documents = DocumentService(session_factory=default_session_factory)
people = PeopleService(session_factory=default_session_factory) people = PeopleService(session_factory=default_session_factory)
unused = await people.create_person_role(code="witness", label="Witness") unused = await people.create_person_role(label="Witness")
referenced = await people.create_person_role(code="author", label="Author") referenced = await people.create_person_role(label="Creator")
document = await documents.create_document(Document(name="Role document")) document = await documents.create_document(Document(name="Role document"))
person = await people.create_person(Person(full_name="Role Person")) person = await people.create_person(Person(full_name="Role Person"))
await people.create_document_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, document_id=document.id,
person_id=person.id, person_id=person.id,
role_id=referenced.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 @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) 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: 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 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): async def test_custom_person_role_can_be_used_for_document_link(default_session_factory):
documents = DocumentService(session_factory=default_session_factory) documents = DocumentService(session_factory=default_session_factory)
people = PeopleService(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")) document = await documents.create_document(Document(name="Witnessed document"))
person = await people.create_person(Person(full_name="Archive Witness")) 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) 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 is not None
assert loaded[0].role_ref.code == "witness" assert loaded[0].role_ref.label == "Witness"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_custom_person_role_delete_blocks_legacy_only_reference(default_session_factory): async def test_built_in_person_role_cannot_be_deleted(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
people = PeopleService(session_factory=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: async with people._session_scope() as session:
session.add( role = PersonRole(
DocumentPerson( semantic_key="author",
document_id=document.id, label="Author",
person_id=person.id, normalized_label="author",
role="witness",
role_id=None,
)
) )
session.add(role)
await session.commit() await session.commit()
await session.refresh(role)
assert await people.is_person_role_referenced(role.id) is True
with pytest.raises(PersonRoleError) as caught: with pytest.raises(PersonRoleError) as caught:
await people.delete_person_role(role.id) await people.delete_person_role(role.id)
+12 -18
View File
@@ -4,7 +4,6 @@ import pytest
from transcription.db.models import Document from transcription.db.models import Document
from transcription.db.models import DocumentPerson from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import Job from transcription.db.models import Job
from transcription.db.models import JobSource from transcription.db.models import JobSource
from transcription.db.models import JobSourceStatus from transcription.db.models import JobSourceStatus
@@ -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")) document = await documents.create_document(Document(id=uuid4(), name="person-doc"))
person = await people_service.create_person(Person(full_name="Ada Lovelace")) 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 assert document.document_type_id is None
link = await people_service.create_document_person( 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) fetched = await people_service.read_document_person(link.id)
assert fetched.id == link.id assert fetched.id == link.id
assert fetched.role == DocumentPersonRole.AUTHOR assert fetched.role_id == author_role.id
assert fetched.role_id is not None
updated_link = await people_service.update_document_person( 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 == recipient_role.id
assert updated_link.role_id is not None
listed = await people_service.list_document_people(document_id=document.id) listed = await people_service.list_document_people(document_id=document.id)
assert len(listed) == 1 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): async def test_people_service_normalizes_and_rejects_duplicate_family_search_ids(default_session_factory):
people_service = PeopleService(session_factory=default_session_factory) people_service = PeopleService(session_factory=default_session_factory)
created = await people_service.create_person( created = await people_service.create_person(Person(full_name="Hig Higgins", family_search_id=" g8t4-mdq "))
Person(full_name="Hig Higgins", family_search_id=" g8t4-mdq ")
)
assert created.family_search_id == "G8T4-MDQ" assert created.family_search_id == "G8T4-MDQ"
with pytest.raises(PeopleError) as duplicate: with pytest.raises(PeopleError) as duplicate:
await people_service.create_person( await people_service.create_person(Person(full_name="Duplicate Hig", family_search_id="G8T4-MDQ"))
Person(full_name="Duplicate Hig", family_search_id="G8T4-MDQ")
)
assert duplicate.value.category == ErrorCategory.CONFLICT assert duplicate.value.category == ErrorCategory.CONFLICT
with pytest.raises(PeopleError) as malformed: with pytest.raises(PeopleError) as malformed:
await people_service.create_person( await people_service.create_person(Person(full_name="Malformed", family_search_id="not-an-id"))
Person(full_name="Malformed", family_search_id="not-an-id")
)
assert malformed.value.category == ErrorCategory.VALIDATION 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")) document = await documents.create_document(Document(id=uuid4(), name="detail-person-doc"))
person = await people_service.create_person(Person(full_name="Grace Hopper")) 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( await people_service.create_document_person(
DocumentPerson( DocumentPerson(
document_id=document.id, document_id=document.id,
person_id=person.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] link = detail.document_people[0]
assert link.person is not None assert link.person is not None
assert link.person.full_name == "Grace Hopper" assert link.person.full_name == "Grace Hopper"
assert link.role == DocumentPersonRole.AUTHOR assert link.role_id == author_role.id
@pytest.mark.asyncio @pytest.mark.asyncio
+166
View File
@@ -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"
+4 -93
View File
@@ -1,7 +1,5 @@
"""Tests for the database runtime and V2 schema bootstrap behavior.""" """Tests for the database runtime and V2 schema bootstrap behavior."""
from uuid import uuid4
import pytest import pytest
from sqlalchemy import inspect from sqlalchemy import inspect
from sqlalchemy import text from sqlalchemy import text
@@ -98,11 +96,11 @@ async def test_create_all_seeds_default_registry_rows(tmp_path):
try: try:
await create_all(engine=runtime.engine) await create_all(engine=runtime.engine)
async with AsyncSession(runtime.engine, expire_on_commit=False) as session: async with AsyncSession(runtime.engine, expire_on_commit=False) as session:
role_codes = set((await session.exec(select(PersonRole.code))).all()) role_keys = set((await session.exec(select(PersonRole.semantic_key))).all())
type_labels = set((await session.exec(select(DocumentType.label))).all()) type_keys = set((await session.exec(select(DocumentType.semantic_key))).all())
assert {"author", "recipient", "mentioned"}.issubset(role_codes) assert {"author", "recipient", "mentioned"}.issubset(role_keys)
assert {"Letter", "Record", "Memo"}.issubset(type_labels) assert {"book", "letter", "postcard", "photo", "journal", "form"}.issubset(type_keys)
finally: finally:
await dispose_database_runtime() 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() 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 @pytest.mark.asyncio
async def test_v42_upgrade_adds_evidence_tables_without_rewriting_legacy_snapshot(tmp_path): async def test_v42_upgrade_adds_evidence_tables_without_rewriting_legacy_snapshot(tmp_path):
settings = Settings( settings = Settings(
+5 -9
View File
@@ -7,7 +7,6 @@ from sqlalchemy.exc import IntegrityError
from transcription.db.models import Document from transcription.db.models import Document
from transcription.db.models import DocumentPerson from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import DocumentType from transcription.db.models import DocumentType
from transcription.db.models import Job from transcription.db.models import Job
from transcription.db.models import JobSource from transcription.db.models import JobSource
@@ -35,8 +34,8 @@ def _persist_document_type(session, *, label: str = "Letter") -> DocumentType:
return document_type return document_type
def _persist_person_role(session, *, code: str = "author", label: str = "Author") -> PersonRole: def _persist_person_role(session, *, label: str = "Author") -> PersonRole:
role = PersonRole(code=code, label=label) role = PersonRole(label=label, normalized_label=label.strip().casefold())
session.add(role) session.add(role)
session.commit() session.commit()
session.refresh(role) session.refresh(role)
@@ -195,7 +194,6 @@ class TestPersonAndDocumentPersonModel:
first = DocumentPerson( first = DocumentPerson(
document_id=document.id, document_id=document.id,
person_id=person.id, person_id=person.id,
role=DocumentPersonRole.AUTHOR,
role_id=person_role.id, role_id=person_role.id,
) )
session.add(first) session.add(first)
@@ -204,7 +202,6 @@ class TestPersonAndDocumentPersonModel:
duplicate = DocumentPerson( duplicate = DocumentPerson(
document_id=document.id, document_id=document.id,
person_id=person.id, person_id=person.id,
role=DocumentPersonRole.AUTHOR,
role_id=person_role.id, role_id=person_role.id,
) )
session.add(duplicate) session.add(duplicate)
@@ -244,7 +241,6 @@ class TestRelationships:
link = DocumentPerson( link = DocumentPerson(
document_id=document.id, document_id=document.id,
person_id=person.id, person_id=person.id,
role=DocumentPersonRole.AUTHOR,
role_id=person_role.id, role_id=person_role.id,
) )
session.add(link) session.add(link)
@@ -257,9 +253,9 @@ class TestRelationships:
class TestRegistryModels: class TestRegistryModels:
def test_person_role_code_is_unique(self, session): def test_person_role_normalized_label_is_unique(self, session):
_persist_person_role(session, code="mentioned", label="Mentioned") _persist_person_role(session, label="Mentioned")
duplicate = PersonRole(code="mentioned", label="Mentioned Again") duplicate = PersonRole(label=" mentioned ", normalized_label="mentioned")
session.add(duplicate) session.add(duplicate)
with pytest.raises(IntegrityError): with pytest.raises(IntegrityError):
session.commit() session.commit()
+7 -6
View File
@@ -9,10 +9,10 @@ from sqlmodel import select
from transcription.db import session_scope from transcription.db import session_scope
from transcription.db.models import Document from transcription.db.models import Document
from transcription.db.models import DocumentPerson from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import DocumentType from transcription.db.models import DocumentType
from transcription.db.models import Job from transcription.db.models import Job
from transcription.db.models import Person from transcription.db.models import Person
from transcription.db.models import PersonRole
from transcription.db.models import Source from transcription.db.models import Source
# --- Helper Fixtures --- # --- Helper Fixtures ---
@@ -23,6 +23,7 @@ async def seed_person_and_document():
"""Seed a Person and Document linked by DocumentPerson role.""" """Seed a Person and Document linked by DocumentPerson role."""
async with session_scope() as session: async with session_scope() as session:
letter_type = (await session.exec(select(DocumentType).where(DocumentType.label == "Letter"))).one() 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") person = Person(full_name="Zenna Cochran")
session.add(person) session.add(person)
await session.flush() await session.flush()
@@ -38,7 +39,7 @@ async def seed_person_and_document():
link = DocumentPerson( link = DocumentPerson(
document_id=doc.id, document_id=doc.id,
person_id=person.id, person_id=person.id,
role=DocumentPersonRole.AUTHOR, role_id=author_role.id,
) )
session.add(link) session.add(link)
await session.commit() await session.commit()
@@ -92,7 +93,7 @@ class TestDocumentsPageRendering:
assert response.status_code == 200 assert response.status_code == 200
assert "Create Document" in response.text assert "Create Document" in response.text
assert "Document name" 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 assert "Document type" in response.text
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -132,7 +133,7 @@ class TestDocumentsPageRendering:
_, client = app_client _, client = app_client
async with session_scope() as session: async with session_scope() as session:
doc = Document(name="Doc With Job", document_type="letter") doc = Document(name="Doc With Job")
session.add(doc) session.add(doc)
await session.flush() await session.flush()
@@ -165,7 +166,7 @@ class TestDocumentsPageRendering:
_, client = app_client _, client = app_client
async with session_scope() as session: async with session_scope() as session:
doc = Document(name="Doc With Source", document_type="letter") doc = Document(name="Doc With Source")
session.add(doc) session.add(doc)
await session.flush() await session.flush()
@@ -194,7 +195,7 @@ class TestDocumentsPageRendering:
_, client = app_client _, client = app_client
async with session_scope() as session: async with session_scope() as session:
doc = Document(name="Orphan Document", document_type="note") doc = Document(name="Orphan Document")
session.add(doc) session.add(doc)
await session.commit() await session.commit()
doc_id = str(doc.id) doc_id = str(doc.id)
+3 -3
View File
@@ -15,7 +15,7 @@ from transcription.db.models import JobStatus
async def seed_document_with_unlinked_job(): async def seed_document_with_unlinked_job():
"""Seed a document and a queued job for testing route actions.""" """Seed a document and a queued job for testing route actions."""
async with session_scope() as session: async with session_scope() as session:
document = Document(name="Test Archival Letter", document_type="letter") document = Document(name="Test Archival Letter")
session.add(document) session.add(document)
await session.flush() await session.flush()
@@ -72,7 +72,7 @@ class TestJobsPageRendering:
_, client = app_client _, client = app_client
async with session_scope() as session: async with session_scope() as session:
doc = Document(name="Preselected Journal Entry", document_type="journal") doc = Document(name="Preselected Journal Entry")
session.add(doc) session.add(doc)
await session.commit() await session.commit()
doc_id = str(doc.id) doc_id = str(doc.id)
@@ -133,7 +133,7 @@ class TestJobsPageRendering:
_, client = app_client _, client = app_client
async with session_scope() as session: async with session_scope() as session:
doc = Document(name="Processing Doc", document_type="letter") doc = Document(name="Processing Doc")
session.add(doc) session.add(doc)
await session.flush() await session.flush()
job = Job(document_id=doc.id, status=JobStatus.PROCESSING) job = Job(document_id=doc.id, status=JobStatus.PROCESSING)
+6 -4
View File
@@ -4,12 +4,13 @@ from datetime import date
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
from sqlmodel import select
from transcription.db import session_scope from transcription.db import session_scope
from transcription.db.models import Document from transcription.db.models import Document
from transcription.db.models import DocumentPerson from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import Person from transcription.db.models import Person
from transcription.db.models import PersonRole
@pytest.mark.integration @pytest.mark.integration
@@ -122,8 +123,9 @@ class TestPeoplePageRendering:
_, client = app_client _, client = app_client
async with session_scope() as session: 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") person = Person(full_name="Linked Person")
document = Document(name="Linked Document", document_type="letter") document = Document(name="Linked Document")
session.add_all([person, document]) session.add_all([person, document])
await session.flush() await session.flush()
@@ -131,7 +133,7 @@ class TestPeoplePageRendering:
DocumentPerson( DocumentPerson(
document_id=document.id, document_id=document.id,
person_id=person.id, person_id=person.id,
role=DocumentPersonRole.AUTHOR, role_id=author_role.id,
) )
) )
await session.commit() await session.commit()
@@ -141,7 +143,7 @@ class TestPeoplePageRendering:
assert response.status_code == 200 assert response.status_code == 200
assert "Linked Document" in response.text 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): def test_person_detail_page_handles_invalid_id(self, app_client):
_, client = app_client _, client = app_client
+103
View File
@@ -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="<Print & Preserve>", notes="<script>unsafe()</script>")
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 &amp; Preserve" in response.text
assert "&lt;script&gt;unsafe()&lt;/script&gt;" 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
+4 -4
View File
@@ -80,7 +80,7 @@ class TestSourcesPageRendering:
_, client = app_client _, client = app_client
async with session_scope() as session: async with session_scope() as session:
document = Document(name="Source Document", document_type="letter") document = Document(name="Source Document")
session.add(document) session.add(document)
await session.flush() await session.flush()
session.add( session.add(
@@ -108,8 +108,8 @@ class TestSourcesPageRendering:
_, client = app_client _, client = app_client
async with session_scope() as session: async with session_scope() as session:
target = Document(name="Target", document_type="letter") target = Document(name="Target")
other = Document(name="Other", document_type="record") other = Document(name="Other")
session.add_all([target, other]) session.add_all([target, other])
await session.flush() await session.flush()
@@ -274,7 +274,7 @@ class TestSourcesPageRendering:
_, client = app_client _, client = app_client
async with session_scope() as session: async with session_scope() as session:
document = Document(name="Unlinked Source Doc", document_type="memo") document = Document(name="Unlinked Source Doc")
session.add(document) session.add(document)
await session.flush() await session.flush()
source = Source( source = Source(