generated from john/python-template
V4.4 Complete
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(role,))
|
try:
|
||||||
|
await self._finalize(session=_session, caller_session=session, refresh=(role,))
|
||||||
|
except IntegrityError as exc:
|
||||||
|
raise PersonRoleError(
|
||||||
|
f"Person Role label {role.label!r} already exists",
|
||||||
|
category=ErrorCategory.CONFLICT,
|
||||||
|
suggestion="Choose a different label or edit the existing role.",
|
||||||
|
) from exc
|
||||||
return role
|
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,37 +464,97 @@ 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:
|
|
||||||
raise PeopleError(
|
|
||||||
f"Person role with id {role_id} not found",
|
|
||||||
category=ErrorCategory.VALIDATION,
|
|
||||||
suggestion="Select a valid relationship role and retry.",
|
|
||||||
)
|
|
||||||
return role
|
|
||||||
|
|
||||||
normalized_code = role_code.strip().lower() or DocumentPersonRole.AUTHOR.value
|
|
||||||
role = (await session.exec(select(PersonRole).where(PersonRole.code == normalized_code))).first()
|
|
||||||
if role is None:
|
if role is None:
|
||||||
role = PersonRole(code=normalized_code, label=normalized_code.replace("_", " ").title())
|
raise PeopleError(
|
||||||
session.add(role)
|
f"Person role with id {role_id} not found",
|
||||||
await session.flush()
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Select a valid relationship role and retry.",
|
||||||
|
)
|
||||||
|
if require_active and not role.is_active:
|
||||||
|
raise PeopleError(
|
||||||
|
f"Inactive Person Role {role.label!r} cannot be assigned",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Select an active Person Role and retry.",
|
||||||
|
)
|
||||||
return role
|
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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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()]
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 & Preserve" in response.text
|
||||||
|
assert "<script>unsafe()</script>" in response.text
|
||||||
|
assert "Facsimile" in response.text
|
||||||
|
assert "Text only" in response.text
|
||||||
|
assert "print-page-break" in response.text
|
||||||
|
assert "print-source-pdf" in response.text
|
||||||
|
assert str(media_path) not in response.text
|
||||||
|
assert str(pdf_path) not in response.text
|
||||||
|
|
||||||
|
media_response = client.get(f"/api/v4/documents/{document_id}/sources/{source_id}/media")
|
||||||
|
assert media_response.status_code == 200
|
||||||
|
assert media_response.headers["content-type"] == "image/png"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_document_source_media_rejects_cross_document_access(app_client):
|
||||||
|
app, client = app_client
|
||||||
|
media_path = Path(app.state.settings.upload_dir) / "documents" / "other.png"
|
||||||
|
media_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
media_path.write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||||
|
|
||||||
|
async with session_scope() as session:
|
||||||
|
owner = Document(name="Owner")
|
||||||
|
other = Document(name="Other")
|
||||||
|
session.add_all([owner, other])
|
||||||
|
await session.flush()
|
||||||
|
source = Source(
|
||||||
|
document_id=owner.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="other.png",
|
||||||
|
filename="other.png",
|
||||||
|
file_path=str(media_path),
|
||||||
|
file_hash="b" * 64,
|
||||||
|
file_size_bytes=media_path.stat().st_size,
|
||||||
|
)
|
||||||
|
session.add(source)
|
||||||
|
await session.commit()
|
||||||
|
other_id = other.id
|
||||||
|
source_id = source.id
|
||||||
|
|
||||||
|
response = client.get(f"/api/v4/documents/{other_id}/sources/{source_id}/media")
|
||||||
|
assert response.status_code == 404
|
||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user