UI updates, changes sync'd to UI docs

This commit is contained in:
Jim Lancaster
2026-08-02 18:20:38 -05:00
parent 9653060c2a
commit 0ab7ad50f2
22 changed files with 447 additions and 431 deletions
@@ -51,16 +51,16 @@ Entry points are indirect through user-facing entities:
### 4.2 Current Implementation
Current entry point: service-level creation via DocumentService.create_document_person()
Current user action: no dedicated DocumentPerson UI
Current backend path: Document and Person workflows call DocumentPerson CRUD in DocumentService
Current entry point: Document create/edit flows
Current user action: select an existing Person from the Document author dropdown
Current backend path: Document page submit callback -> `DocumentService.create_document_person()` or `delete_document_person()` as the author selection changes
| Field | Current Value at Create | Source | Visible to User | Evidence |
|---|---|---|---|---|
| id | Generated UUID | System | No | src/transcription/db/models.py |
| document_id | Caller-provided | Service/API caller | No | src/transcription/services/documents.py |
| person_id | Caller-provided | Service/API caller | No | src/transcription/services/documents.py |
| role | Caller-provided or default author | Service/model default | No | src/transcription/db/models.py, src/transcription/services/documents.py |
| document_id | Caller-provided | Document UI | Indirectly | src/transcription/ui/pages/documents_page.py |
| person_id | Caller-provided | Document UI | Indirectly | src/transcription/ui/pages/documents_page.py |
| role | Default author in current UI | Service/model default | No | src/transcription/db/models.py, src/transcription/services/documents.py |
| created_at | Current UTC timestamp | System | No | src/transcription/db/models.py |
### 4.3 Gap to Target
@@ -178,5 +178,5 @@ Related user-facing workflows:
- Every DocumentPerson schema field appears in the field inventory.
- Intended behavior is defined as supporting workflow behavior rather than standalone UI.
- Current behavior reflects service-level CRUD with no dedicated UI.
- Current behavior reflects UI-backed CRUD through Document create/edit flows and Person detail rendering, with no standalone DocumentPerson UI.
- Gaps between intended and current behavior are explicit.
@@ -36,12 +36,12 @@ This checklist does not cover:
4. Then the page shows that linked person
### RD-4 Sources section empty state
1. Given no related Source records
2. Then the page shows no sources added yet and an action to add sources
1. The page shows a Sources action for the current Document
2. The action routes to a document-scoped Sources view
### RD-5 Jobs section empty state
1. Given no related Job records
2. Then the page shows no jobs created yet and an action to start or create jobs
1. The page shows a Jobs action for the current Document
2. The action routes to a document-scoped Jobs view
### RD-6 Filtered navigation readiness
1. The detail page provides links or actions that can route to document-scoped Sources and Jobs views
@@ -56,11 +56,12 @@ This checklist does not cover:
### UP-1 Edit entry
1. Given a loaded Document detail page
2. When the user chooses Edit document
3. Then editable controls are shown for allowed fields only
3. Then editable controls are shown for allowed fields only, including the author relationship selector
### UP-2 Editable fields
1. Editable: name, document_type, document_date, document_date_raw, location_created, notes, archive_identifier
2. Not editable: id, created_at, updated_at
3. The edit flow may also change the associated author Person link
### UP-3 Required validation
1. name is required
+38 -41
View File
@@ -9,7 +9,7 @@ Acceptance criteria: acceptance-criteria.md
- Table: Document
- Primary key: `id` (UUID)
- Related entities: `Source`, `Job`, `DocumentPerson`
- Related entities: `Source`, `Job`, `DocumentPerson`, `Person`
- Canonical schema references:
- `src/transcription/db/models.py`
- `docs/schema_v2.md`
@@ -26,8 +26,8 @@ This document uses three lenses:
| Field | DB Type | Nullable | Default/Auto Value | Intended UI Treatment | Notes |
|---|---|---|---|---|---|
| id | UUID | No | `uuid4()` | Hidden, system-managed | Primary key |
| name | str | No | None | Shown, editable on create and future edit | Required |
| document_type | str | Yes | None | Shown, editable on create and future edit | Required by intended UX |
| name | str | No | None | Shown, editable on create and edit | Required |
| document_type | str | Yes | None | Shown, editable on create and edit | Required by intended UX |
| document_date | date | Yes | None | Shown, editable | Canonical exact date when present |
| document_date_raw | str | Yes | None | Shown, editable | Approximate or unknown date text |
| location_created | str | Yes | None | Shown, editable | Optional metadata |
@@ -64,38 +64,37 @@ Related records during intended create:
### 4.2 Current Implementation
Current entry point: upload page
Current user action: upload file via upload widget
Current backend path: upload page submit callback -> `create_upload_job()` -> `_create_upload_records()`
Current entry point: `/documents` page
Current user action: open create form, fill metadata, optionally select an existing Person
Current backend path: document page submit callback -> `DocumentService.create_document()` -> optional `DocumentService.create_document_person()`
| Field | Current Value at Create | Source | Visible to User | Evidence |
|---|---|---|---|---|
| id | Generated UUID | System | No | `Document` default factory in `src/transcription/db/models.py` |
| name | Basename of uploaded filename | User file name transformed by service | Indirectly | Set in `src/transcription/services/store.py` |
| document_type | `None` | Service default | No | Not set in `src/transcription/services/store.py` |
| document_date | `None` | Service default | No | Not set in `src/transcription/services/store.py` |
| document_date_raw | `None` | Service default | No | Not set in `src/transcription/services/store.py` |
| location_created | `None` | Service default | No | Not set in `src/transcription/services/store.py` |
| notes | `None` | Service default | No | Not set in `src/transcription/services/store.py` |
| archive_identifier | `None` | Service default | No | Not set in `src/transcription/services/store.py` |
| name | User-provided | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
| document_type | User-provided or None | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
| document_date | Parsed from date input or None | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
| document_date_raw | User-provided or None | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
| location_created | User-provided or None | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
| notes | User-provided or None | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
| archive_identifier | User-provided or None | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
| created_at | Current UTC timestamp | System | No | Default factory in `src/transcription/db/models.py` |
| updated_at | Current UTC timestamp | System | No | Default factory in `src/transcription/db/models.py` |
Current related-record behavior:
- `Job` is created automatically.
- `Source` is created automatically.
- `JobSource` is created automatically.
- No `Person` or `DocumentPerson` records are created.
- User may optionally select an existing `Person`.
- If selected, `DocumentPerson` is created with role `author`.
- `Job` is not created during Document create.
- `Source` is not created during Document create.
### 4.3 Gap to Target
To satisfy the intended Create flow, implementation must add:
To satisfy the intended Create flow, implementation now includes:
1. a Document page and dedicated create form
2. user-entered metadata fields for `document_type`, `document_date`, `document_date_raw`, `location_created`, `notes`, and `archive_identifier`
3. optional Person lookup and inline Person creation
3. optional Person lookup through a dropdown of existing people
4. optional `DocumentPerson` link creation when a person is chosen
5. post-submit routing to a Document detail page
6. removal of the assumption that Document creation always starts with file upload
## 5. READ Mapping
@@ -110,29 +109,27 @@ On the Document detail page, the user should be able to see:
### 5.2 Current Implementation
Current Document visibility in the UI is indirect.
Current Document visibility in the UI is direct.
| Field | Current Rendering | Visible to User | Notes | Evidence |
|---|---|---|---|---|
| name | Indirect filename context in jobs list and job detail | Yes, indirect | The UI shows source/job filename, not a dedicated Document page | `src/transcription/ui/pages/jobs_page.py`, `src/transcription/ui/components/table/jobs.py` |
| id | Not shown as Document id | No | Job id is shown instead | `src/transcription/ui/pages/jobs_page.py` |
| document_type | Not rendered | No | Hidden metadata | no current UI field |
| document_date | Not rendered | No | Hidden metadata | no current UI field |
| document_date_raw | Not rendered | No | Hidden metadata | no current UI field |
| location_created | Not rendered | No | Hidden metadata | no current UI field |
| notes | Not rendered | No | Hidden metadata | no current UI field |
| archive_identifier | Not rendered | No | Hidden metadata | no current UI field |
| created_at | Not rendered as Document timestamp | No | Job timestamps are shown instead | `src/transcription/ui/components/table/jobs.py` |
| updated_at | Not rendered as Document timestamp | No | Job metadata is shown instead | `src/transcription/ui/components/transcript.py` |
| name | Rendered as title and detail heading | Yes | Dedicated Document detail page | `src/transcription/ui/pages/documents_page.py` |
| id | Not shown as raw id | No | Internal identifier remains hidden | `src/transcription/ui/pages/documents_page.py` |
| document_type | Rendered | Yes | Shown on detail and editable on create/edit | `src/transcription/ui/pages/documents_page.py` |
| document_date | Rendered | Yes | Exact date shown when present | `src/transcription/ui/pages/documents_page.py` |
| document_date_raw | Rendered | Yes | Approximate date shown when present | `src/transcription/ui/pages/documents_page.py` |
| location_created | Rendered | Yes | Optional metadata shown | `src/transcription/ui/pages/documents_page.py` |
| notes | Rendered | Yes | Optional metadata shown | `src/transcription/ui/pages/documents_page.py` |
| archive_identifier | Rendered | Yes | Optional metadata shown | `src/transcription/ui/pages/documents_page.py` |
| created_at | Rendered read-only | Yes | System timestamp shown on detail | `src/transcription/ui/pages/documents_page.py` |
| updated_at | Rendered read-only | Yes | System timestamp shown on detail | `src/transcription/ui/pages/documents_page.py` |
### 5.3 Gap to Target
To satisfy the intended Read flow, implementation must add:
1. a Document detail page
2. metadata rendering for Document fields
3. linked people rendering
4. Sources and Jobs sections with empty states
5. filtered navigation from the detail page into document-specific Jobs and Sources views
To satisfy the intended Read flow, implementation now includes:
1. metadata rendering for Document fields
2. linked people rendering
3. document-scoped Sources and Jobs navigation views
## 6. UPDATE Mapping
@@ -171,10 +168,10 @@ Intended system-managed fields:
### 6.3 Gap to Target
Implementation must add:
Implementation now includes:
1. Document edit controls in the UI
2. validation and save behavior for Document metadata
3. a consistent `updated_at` update policy if metadata edits are introduced
3. author relationship controls through the edit flow
## 7. DELETE Mapping
@@ -191,11 +188,11 @@ Rules:
| Action | UI Exposed | Backend Capability | Notes |
|---|---|---|---|
| Delete Document | No | Yes | `DocumentService.delete_document()` exists, but no dedicated UI guard flow exists |
| Delete Document | Yes | Yes | `DocumentService.delete_document()` exists and the UI blocks dependent deletes |
### 7.3 Gap to Target
Implementation must add:
Implementation includes:
1. a Document delete control in the UI
2. pre-delete dependency checks for Jobs and Sources
3. user-facing messaging when deletion is blocked
+16 -17
View File
@@ -15,9 +15,9 @@ Creating a Document is a metadata-first workflow:
1. The user opens the Document page.
2. The user selects Create new document.
3. The user enters descriptive metadata about the document.
4. The user optionally links one related person.
4. The user optionally selects one related person from the existing Person list.
5. The system creates the Document.
6. If a person was selected or created, the system links that Person to the Document through DocumentPerson.
6. If a person was selected, the system links that Person to the Document through DocumentPerson with author role.
7. The user sees a success state and lands on the new Document detail page.
## 2. User Goal
@@ -46,9 +46,9 @@ The Document detail page is the page for one specific Document after it has been
It should show:
1. the Document metadata
2. related people linked to the Document
3. a Sources section
4. a Jobs section
5. empty states when no sources or jobs exist yet
3. a linked-author summary when available
4. document-scoped navigation links for Sources and Jobs
5. filtered views for sources and jobs linked to the current document
It should later support links to filtered views for:
1. jobs associated with the current document only
@@ -67,7 +67,8 @@ Expected UI affordance:
Preferred first implementation:
1. A dedicated Document create page or panel.
2. A simple form with explicit labels.
3. Text inputs are acceptable for first release, even where future versions may use dropdowns or richer selectors.
3. Existing Person records should be selectable through a dropdown.
4. Text inputs are acceptable for the remaining fields in first release.
## 5. Create Document Form
@@ -124,12 +125,13 @@ The Create Document flow may optionally link one related person during first rel
| UI Label | Schema Area | Input Type | Required | Notes |
|---|---|---|---|---|
| Related person | Person -> DocumentPerson | Search/select or create inline | No | Intended to support common author-like associations without making the field mandatory |
| Related person | Person -> DocumentPerson | Dropdown select | No | Selects an existing Person and links as author when saved |
First release behavior:
1. The user may save a Document without linking any person.
2. If a person is linked during create, only one person is supported in first release.
3. Additional people and recipient workflows are deferred to a future revision.
3. The selected person is linked as author.
4. Additional people and recipient workflows are deferred to a future revision.
### 5.6 Related Records Not Created Directly Here
@@ -144,7 +146,7 @@ First release behavior:
The user should be able to:
1. select an existing Person to associate with the Document
2. create a new Person if the person does not already exist
2. change the associated Person from the Document edit flow
3. save the Document even if no person is linked
### 6.2 Data Model Interpretation
@@ -162,16 +164,13 @@ This means:
### 6.3 Related Person UI Behavior
Minimum acceptable first implementation:
1. Searchable or scrollable list of existing Person records.
2. Option to create a new Person inline or in a small secondary flow.
3. Clear display of the selected related person before submit.
1. Dropdown of existing Person records.
2. Clear display of the selected related person before submit.
3. Ability to change or clear the selected person in the Document edit flow.
If the person does not exist:
1. User selects Create new person.
2. User enters the minimum required Person information.
3. System creates Person.
4. System returns to Document create flow.
5. System links the new Person if the user completes Document creation.
1. The user should create the Person first from the Person page.
2. The Document create flow only links existing Person records in first release.
## 7. Validation Rules
+13 -31
View File
@@ -76,12 +76,12 @@ This checklist does not cover:
1. Given a valid job id
2. When the user opens Job detail
3. Then job metadata for that record only is shown
4. Then source preview or source warning state is shown
4. Then document-scoped navigation links for Sources and Jobs are shown
### RD-4 Detail execution context visibility
1. provider, model, and prompt_name are displayed when known
2. status lifecycle value is visible
3. source-level transcription and revision context is visible
3. source-level transcription and revision context remains deferred in the current UI
### RD-5 Missing and invalid id states
1. Given an invalid job id format
@@ -92,25 +92,18 @@ This checklist does not cover:
## Update Acceptance Criteria
### UP-1 Revision edit entry
1. Given a job detail page with source context
2. When the user enters revision edit flow
3. Then revised_text input is available
1. Given a job detail page
2. When the user opens the page
3. Then document-scoped navigation links are available
### UP-2 Revision validation
1. revised_text cannot be saved as empty after trimming
2. Warning feedback is shown for invalid empty input
1. source revision editing remains deferred in the current UI
### UP-3 Successful revision save
1. Given valid revision text
2. When the user saves
3. Then revised_text persists
4. Then success feedback is shown
5. Then refreshed revision content is visible
1. deferred until the source revision UI is restored
### UP-4 Revision save failure
1. Given backend failure during revision save
2. Then clear error feedback is shown
3. Then the user-entered text remains available for retry where possible
1. deferred until the source revision UI is restored
### UP-5 Job lifecycle state update visibility
1. status changes from queued to processing to terminal states are reflected in UI
@@ -120,30 +113,19 @@ This checklist does not cover:
## Delete Acceptance Criteria
### DL-1 Delete entry and confirmation
1. Given a job detail context with delete affordance
2. When the user selects delete job
3. Then a permanent-action confirmation dialog appears
1. job deletion is deferred in the current UI
### DL-2 Dependency guardrails
1. If deletion policy requires retention handling for related JobSource history, delete is blocked
2. If deletion policy allows dependent cleanup path, delete can proceed
1. deferred until a job delete flow is reintroduced
### DL-3 Blocked delete behavior
1. When blocked
2. Then UI explains dependency constraints
3. Then UI provides cleanup guidance
1. deferred until a job delete flow is reintroduced
### DL-4 Successful delete
1. Given no blocking dependencies
2. When the user confirms deletion
3. Then job is removed
4. Then success feedback is shown
5. Then the user returns to Jobs list
1. deferred until a job delete flow is reintroduced
### DL-5 Delete failure
1. Given backend failure during delete
2. Then clear error feedback is shown
3. Then the user remains in Job context with retry path
1. deferred until a job delete flow is reintroduced
## Cross-Criteria Quality Gates
+20 -26
View File
@@ -36,11 +36,7 @@ This document uses three lenses:
| prompt_name | str | Yes | None | Visible when known; editable if create-time options are available | Prompt metadata |
Related execution fields rendered in Job detail via relationships:
- JobSource.status
- JobSource.error_detail
- JobSource.executed_at
- Source.upload_name, Source.filename, Source.page_number
- Source.raw_transcription, Source.revised_text
- none in the current simplified detail view beyond job metadata and document navigation links
## 4. CREATE Mapping
@@ -69,9 +65,9 @@ Create-related relationship rules:
### 4.2 Current Implementation
Current entry point: upload page through create_upload_job()
Current user action: upload one file through upload flow
Current backend path: upload submit -> create_upload_job -> _create_upload_records
Current entry point: Jobs page create flow
Current user action: select Document and upload one or more files or a folder through a single upload widget
Current backend path: job create submit -> create_job_for_document()
| Field | Current Value at Create | Source | Visible to User | Evidence |
|---|---|---|---|---|
@@ -81,14 +77,14 @@ Current backend path: upload submit -> create_upload_job -> _create_upload_recor
| retry_count | 0 | Model default | Yes | src/transcription/db/models.py, src/transcription/ui/pages/jobs_page.py |
| date_created | current UTC timestamp | System | Yes | src/transcription/db/models.py, src/transcription/ui/pages/jobs_page.py |
| date_updated | current UTC timestamp | System | Yes | src/transcription/db/models.py, src/transcription/ui/pages/jobs_page.py |
| provider | None at create, set after transcription update | Workflow/service | Partially | src/transcription/services/workflows.py |
| model | None at create, set after transcription update | Workflow/service | Partially | src/transcription/services/workflows.py |
| prompt_name | None at create, set by workflow updates | Workflow/service | Partially | src/transcription/services/workflows.py |
| provider | None at create, set after transcription update | Workflow/service | Yes | src/transcription/services/workflows.py |
| model | None at create, set after transcription update | Workflow/service | Yes | src/transcription/services/workflows.py |
| prompt_name | None at create, set by workflow updates | Workflow/service | Yes | src/transcription/services/workflows.py |
Current create constraints:
1. no dedicated Create job action in the Jobs page.
2. upload flow currently creates Document, Job, Source, and JobSource together.
3. current upload path accepts a single file per submission.
1. dedicated Create job action exists in the Jobs page.
2. job create flow requires a Document selection.
3. current upload path accepts one widget for files or folder selection.
### 4.3 Gap to Target
@@ -132,16 +128,14 @@ Source-related read behavior:
### 5.3 Gap to Target
To satisfy intended Read flow, implementation must add:
1. list-level create affordance and enhanced filtering/search UX.
2. explicit Document context rendering in list and detail.
3. explicit provider/model/prompt_name rendering in detail when known.
4. richer multi-source detail navigation when more than one source is linked.
1. richer per-source detail navigation if a later revision restores transcription review in Job detail.
2. richer filtering/search UX if needed.
## 6. UPDATE Mapping
### 6.1 Intended Update Behavior
Primary user updates in first release are revision edits in job detail source context.
Primary user updates in first release are source revision edits in job detail source context in the product plan, but the current UI no longer exposes that surface.
Intended editable scope (first release):
- Source.revised_text through Job detail review
@@ -162,7 +156,7 @@ Job metadata visibility policy:
| Field/Area | Updatable via UI | Updatable via Service | Notes |
|---|---|---|---|
| Source.revised_text from job detail | Yes | Yes | Saved via transcription service revision path |
| Source.revised_text from job detail | No | Yes | Saved via transcription service revision path, but the current UI does not expose the editor |
| status | No | Yes | Updated by workflow lifecycle services |
| retry_count | No | Yes | Incremented by workflow retry logic |
| provider/model/prompt_name | No | Yes | Set during transcription result finalization |
@@ -170,16 +164,16 @@ Job metadata visibility policy:
### 6.3 Gap to Target
Implementation should add:
1. explicit create-mode handling for provider/model/prompt visibility and optional selection.
2. richer detail display for provider/model/prompt and source-level execution outcomes.
3. optional future manual controls for retry and state transitions.
Implementation now includes:
1. create-mode handling for provider/model/prompt visibility and optional selection.
2. detail display for provider/model/prompt and document-scoped navigation links.
3. manual controls for retry and state transitions remain deferred.
## 7. DELETE Mapping
### 7.1 Intended Delete Behavior
Job deletion should include dependency-aware guardrails.
Job deletion is deferred in the current UI.
Rules:
1. deletion is allowed only when policy allows cleanup or retention handling for related JobSource records.
@@ -194,7 +188,7 @@ Rules:
### 7.3 Gap to Target
Implementation must add:
Implementation should add in a future revision:
1. delete control in Job detail.
2. dependency checks and blocked-delete messaging.
3. success navigation and confirmation UX.
+5 -4
View File
@@ -18,7 +18,7 @@ Managing a Job is run-first:
4. The user links a Document and uploads one or more source files.
5. The user submits for transcription.
6. The system creates and processes the Job.
7. The user reviews per-source output and saves revisions.
7. The user reviews job metadata and follows document-scoped links for Sources and Jobs.
## 2. User Goal
@@ -54,9 +54,9 @@ Create mode should include:
Detail mode should include:
1. job metadata and status
2. per-source processing state
3. original transcription output
4. revision editor and save actions
2. document-scoped navigation links for the current Document
3. provider/model/prompt visibility when known
4. no delete action in first release
## 4. Entry Points
@@ -67,6 +67,7 @@ Primary entry points:
Current implementation note:
1. current code path is upload-first and auto-creates Job records
2. intended UX is explicit Create job from the Jobs page
3. current detail view is link-oriented and does not show per-source transcription or revision editing controls
## 5. Create Job Flow
@@ -59,6 +59,7 @@ This checklist does not cover:
### RD-2 Metadata visibility
1. The page shows full_name and available optional person fields
2. created_at and updated_at are shown as system-managed, read-only values
3. portrait_path is rendered when available, including an image preview when possible
### RD-3 Linked documents section
1. Given no linked DocumentPerson rows
+7 -8
View File
@@ -117,7 +117,7 @@ Current Person visibility in the UI is not implemented as a dedicated page.
| Field | Current Rendering | Visible to User | Notes | Evidence |
|---|---|---|---|---|
| full_name | Not rendered in dedicated Person UI | No | No current Person page | no Person page in src/transcription/ui |
| full_name | Rendered in header and summary | Yes | Dedicated Person page exists | `src/transcription/ui/pages/people_page.py` |
| display_name | Not rendered | No | No current Person page | no Person page in src/transcription/ui |
| maiden_name | Not rendered | No | No current Person page | no Person page in src/transcription/ui |
| birth_date | Not rendered | No | No current Person page | no Person page in src/transcription/ui |
@@ -127,18 +127,17 @@ Current Person visibility in the UI is not implemented as a dedicated page.
| death_date_raw | Not rendered | No | No current Person page | no Person page in src/transcription/ui |
| death_place | Not rendered | No | No current Person page | no Person page in src/transcription/ui |
| biography | Not rendered | No | No current Person page | no Person page in src/transcription/ui |
| portrait_path | Not rendered | No | No current Person page | no Person page in src/transcription/ui |
| portrait_path | Rendered as text and image when available | Yes | Dedicated Person page exists | `src/transcription/ui/pages/people_page.py` |
| metadata_ | Not rendered | No | Hidden advanced field | no current UI field |
| created_at | Not rendered | No | No current Person page | no Person page in src/transcription/ui |
| updated_at | Not rendered | No | No current Person page | no Person page in src/transcription/ui |
### 5.3 Gap to Target
To satisfy the intended Read flow, implementation must add:
1. a Person detail page
2. metadata rendering for Person fields
3. linked Documents section with empty states
4. document-link navigation paths
To satisfy the intended Read flow, implementation now includes:
1. metadata rendering for Person fields
2. linked Documents section with empty states
3. document-link navigation paths
## 6. UPDATE Mapping
@@ -189,7 +188,7 @@ Hidden in first release:
### 6.3 Gap to Target
Implementation must add:
Implementation now includes:
1. Person edit controls in the UI
2. validation and save behavior for Person metadata
3. a consistent updated_at update policy for Person edits
+3 -2
View File
@@ -44,8 +44,9 @@ The Person detail page is the page for one specific Person after creation.
It should show:
1. core identity fields
2. biographical metadata
3. related Documents section
4. empty state when no linked documents exist yet
3. portrait image when available
4. related Documents section
5. empty state when no linked documents exist yet
## 4. Entry Point
+11 -11
View File
@@ -59,14 +59,14 @@ This checklist does not cover:
## Read Acceptance Criteria
### RD-1 Source detail retrieval
1. Given a valid Source id in source context, typically via job detail in first release
1. Given a valid Source id in source context, typically via a document-scoped source list in first release
2. When the user opens source detail or equivalent panel
3. Then source metadata and preview are displayed for that source only
3. Then source metadata and preview are displayed for that source only if the detail UI exists in a later revision
### RD-2 Transcription and revision visibility
1. Original transcription context is visible read-only
2. Revision state is visible
3. If revised_text is absent, no revision yet messaging is shown
1. Original transcription context is visible read-only when the revision UI exists
2. Revision state is visible when the revision UI exists
3. If revised_text is absent, no revision yet messaging is shown when the revision UI exists
### RD-3 Missing source state
1. Given a missing source
@@ -77,29 +77,29 @@ This checklist does not cover:
### UP-1 Revision editing entry
1. Given a source context
2. When the user enters revision edit flow
3. Then revised_text input is available
3. Then revised_text input is available in a later revision of the UI
### UP-2 Revision validation
1. revised_text cannot be saved as empty after trimming
2. Warning feedback is shown for invalid empty input
1. revised_text cannot be saved as empty after trimming when the revision UI exists
2. Warning feedback is shown for invalid empty input when the revision UI exists
### UP-3 Successful revision save
1. Given valid revision text
2. When the user saves
2. When the user saves in a later revision of the UI
3. Then revised_text persists
4. Then date_revised is updated
5. Then success feedback is shown
6. Then refreshed revision content is visible
### UP-4 Revision save failure
1. Given backend failure during save
1. Given backend failure during save in a later revision of the UI
2. Then clear error feedback is shown
3. Then the user-entered text remains available for retry where possible
## Delete Acceptance Criteria
### DL-1 Delete entry and confirmation
1. Given a source in source context, typically via job detail in first release
1. Given a source in source context, typically via a later source detail view
2. When the user selects delete source
3. Then a permanent-action confirmation dialog appears
+22 -23
View File
@@ -31,8 +31,8 @@ This document uses three lenses:
| upload_name | str | No | None | Shown read-only after upload | Original user-provided name |
| filename | str | No | None | Shown read-only | Stored filename |
| file_path | str | No | None | Usually hidden; preview uses path internally | Filesystem path |
| raw_transcription | str | Yes | None | Shown read-only | Immutable machine output context |
| revised_text | str | Yes | None | Shown editable | Human-authored correction |
| raw_transcription | str | Yes | None | Shown indirectly or hidden | Immutable machine output context |
| revised_text | str | Yes | None | Hidden in current simplified UI | Human-authored correction |
| date_uploaded | datetime | No | datetime.now(UTC) | Shown read-only | System-managed timestamp |
| date_revised | datetime | Yes | None | Shown read-only | Set when revision is saved |
@@ -59,9 +59,9 @@ Success destination: source preview or revision flow in job detail context
### 4.2 Current Implementation
Current entry point: upload page through create_upload_job()
Current user action: upload one file through upload flow
Current backend path: upload submit -> create_upload_job -> _create_upload_records
Current entry point: Jobs page create flow
Current user action: upload one or more files or a folder through a single upload widget
Current backend path: job create submit -> create_job_for_document()
| Field | Current Value at Create | Source | Visible to User | Evidence |
|---|---|---|---|---|
@@ -96,34 +96,33 @@ On Source detail/list surfaces, users should be able to see:
### 5.2 Current Implementation
Current Source reading is primarily embedded in job detail.
Current Source reading is primarily embedded in a document-scoped source list and job create/navigation flow.
| Field | Current Rendering | Visible to User | Notes | Evidence |
|---|---|---|---|---|
| upload_name | Shown in source-related UI | Yes | Displayed in job detail source context | src/transcription/ui/components/job_detail.py |
| filename | Indirectly shown in jobs list/detail | Yes | Via Job.filename and source metadata | src/transcription/ui/components/table/jobs.py, src/transcription/ui/pages/jobs_page.py |
| file_path | Used for preview rendering | Indirectly | Source preview in job detail page | src/transcription/ui/pages/jobs_page.py |
| page_number | Not explicitly rendered in current jobs page flow | Limited | Ordering implicit via selected source | jobs page flow |
| raw_transcription | Rendered from JobSource context | Yes | Original transcription card | src/transcription/ui/components/transcript.py |
| revised_text | Rendered/editable in revision editor | Yes | Revision editor in job detail page | src/transcription/ui/pages/jobs_page.py |
| date_uploaded | Limited visibility | Partial | Shown in some source metadata components | src/transcription/ui/components/job_detail.py |
| date_revised | Indirect via revision row timestamp | Partial | Used in revision caption | src/transcription/ui/components/transcript.py |
| upload_name | Shown in document-scoped source list | Yes | Displayed in source list context | src/transcription/ui/pages/documents_page.py |
| filename | Shown in document-scoped source list | Yes | Source metadata shown in list | src/transcription/ui/pages/documents_page.py |
| file_path | Hidden from current simplified UI | No | Operational path remains internal | current UI pages |
| page_number | Shown in document-scoped source list | Yes | Ordering visible in document source list | src/transcription/ui/pages/documents_page.py |
| raw_transcription | Not shown in current simplified UI | No | Source preview/revision UI deferred | current UI pages |
| revised_text | Not shown in current simplified UI | No | Source revision UI deferred | current UI pages |
| date_uploaded | Not shown in current simplified UI | No | Operational metadata only | current UI pages |
| date_revised | Not shown in current simplified UI | No | Operational metadata only | current UI pages |
### 5.3 Gap to Target
To satisfy intended Read flow, implementation must add:
1. dedicated Source list and detail surfaces under Document context
2. explicit page_number presentation and navigation
3. clearer source metadata display independent of Job-centric pages
1. richer source detail and revision surfaces if the review workflow returns later
2. optional page-specific navigation if needed beyond the document-scoped list
## 6. UPDATE Mapping
### 6.1 Intended Update Behavior
Primary user update for Source is revised_text maintenance.
Primary user update for Source is revised_text maintenance in the service layer; the current UI does not expose this flow.
Intended editable fields (first release):
- revised_text
- revised_text, if the revision UI is restored later
Intended read-only fields (first release):
- document_id
@@ -139,13 +138,13 @@ Intended read-only fields (first release):
| Field | Updatable via UI | Updatable via Service | Notes |
|---|---|---|---|
| revised_text | Yes | Yes | Saved via TranscriptionService.upsert_revision_for_source() |
| revised_text | No | Yes | Saved via TranscriptionService.upsert_revision_for_source(), but not exposed in current UI |
| date_revised | No | Yes | Set automatically on revision save |
| other fields | No | Technically yes in service layer | No first-class UI editing flow |
### 6.3 Gap to Target
Implementation should add:
Implementation should add in a later revision:
1. explicit Source edit boundaries in dedicated Source UI
2. validation and save UX for revisions in document-scoped source views
3. optional future controls for page ordering and metadata corrections
@@ -154,7 +153,7 @@ Implementation should add:
### 7.1 Intended Delete Behavior
Source deletion should include dependency-aware guardrails.
Source deletion is deferred in the current UI.
Rules:
1. Deletion can proceed when policy allows cleanup of related JobSource records.
@@ -168,7 +167,7 @@ Rules:
### 7.3 Gap to Target
Implementation must add:
Implementation should add in a future revision:
1. source delete controls in source/document context UI
2. dependency checks for JobSource links
3. blocked-delete messaging and cleanup path guidance
+22 -31
View File
@@ -15,7 +15,7 @@ Managing Source records is page-first:
1. The user starts from a transcription job flow.
2. The user adds one or more source files.
3. The system creates Source records linked to the Document and linked to the Job through JobSource.
4. The user reviews original transcription output and can maintain revised text.
4. The user reviews source lists by document and can navigate to the owning Document or Job.
## 2. User Goal
@@ -34,15 +34,11 @@ The user wants to:
A Source list surface should support:
1. listing source pages for a selected Document
2. sorting by page_number
3. opening a source detail or revision view
3. opening the owning Document or Job context
### 3.2 Source Detail Surface
A Source detail surface should show:
1. file preview (image or PDF)
2. source metadata
3. original transcription context
4. revision editor and revision state
Source detail and revision surfaces are deferred in the current UI.
## 4. Entry Points
@@ -51,7 +47,7 @@ Primary entry points:
2. from Job detail, open source preview and revision editor
Current implementation note:
1. source interaction currently occurs in upload and job-detail flows
1. source interaction currently occurs in job-create and document-scoped source list flows
## 5. Create Source Flow
@@ -106,7 +102,7 @@ After successful source create:
1. Source is linked to the Document
2. Source appears in page order derived from alphabetical upload filename ordering
3. Source is linked to the Job through JobSource at create time
4. The user can open preview and revision workflow
4. The user can open the owning Document or Job context
### 5.8 Source Creation Invariant
@@ -119,40 +115,35 @@ For first release:
### 6.1 User Intent
The user wants to view each page file and understand file identity, processing context, and revision status.
The user wants to view each page file and understand file identity and processing context.
### 6.2 Read Surface Expectations
The UI should show:
1. file preview for the current Source
1. source lists grouped by Document
2. upload_name as the original user-provided filename
3. filename as the stored system filename
4. file_path information, with the option to keep it internal or partially hidden for safety
5. page_number and ordering context
6. whether revised_text exists
7. original transcription output context
4. page_number and ordering context
5. the owning Document and Job navigation context
### 6.3 Read Empty and Missing States
If source is missing:
1. Show clear not found or no source available messaging
If source has no revision:
1. Show empty-state guidance and create-revision action
If source metadata is partially unavailable:
1. Show fallback labels and keep preview and revision actions available where possible
1. Show fallback labels and keep navigation available where possible
## 7. Update Source Journey
### 7.1 User Intent
The user primarily updates page-level revision text while preserving raw machine output.
The user primarily tracks page-level source records while preserving raw machine output in the service layer.
### 7.2 Intended Editable Fields
Editable in first release:
1. revised_text
1. revised_text in a later revision, not in the current simplified UI
Read-only in first release:
1. upload_name
@@ -166,11 +157,11 @@ Read-only in first release:
### 7.3 Revision Save Behavior
On save:
1. validate revision text is non-empty
2. persist revised_text
3. set date_revised
4. show success feedback
5. keep user in current source context
1. validate revision text is non-empty when the revision UI exists in a later release
2. persist revised_text when the revision UI exists
3. set date_revised when the revision UI exists
4. show success feedback when the revision UI exists
5. keep user in current source context when the revision UI exists
### 7.4 Revision Failure Behavior
@@ -196,13 +187,13 @@ Delete is blocked when:
### 8.3 Delete UX
When blocked:
1. explain dependency constraints
2. Show cleanup guidance
1. explain dependency constraints in a future delete flow
2. show cleanup guidance in a future delete flow
When allowed:
1. confirm permanent removal
2. remove source
3. return to source list with success state
1. confirm permanent removal in a future delete flow
2. remove source in a future delete flow
3. return to source list with success state in a future delete flow
## 9. Relationship to Other Workflows
+11 -11
View File
@@ -14,8 +14,8 @@ Status legend:
| Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes |
|---|---|---|---|---|
| Read detail and metadata | RD-1, RD-2, RD-7 | Implemented | src/transcription/ui/pages/documents_page.py; src/transcription/services/documents.py; tests/ui/test_documents_page.py | Dedicated Document detail route renders metadata, read-only system timestamps, and invalid/missing-id states. |
| Related sections and empty states | RD-3, RD-4, RD-5, RD-6 | Implemented | src/transcription/ui/pages/documents_page.py; src/transcription/services/documents.py; tests/ui/test_documents_page.py | Document detail now links to document-scoped Jobs and Sources views, including empty-state guidance and filtered record rendering. |
| Update entry and validation | UP-1, UP-2, UP-3, UP-4, UP-5, UP-6 | Implemented | src/transcription/ui/pages/documents_page.py; src/transcription/services/documents.py; tests/ui/test_documents_page.py; tests/services/test_document_service.py | Dedicated edit page includes required-field validation messaging, date parsing rules, and save path routed back to document detail. |
| Related sections and navigation | RD-3, RD-4, RD-5, RD-6 | Implemented | src/transcription/ui/pages/documents_page.py; src/transcription/services/documents.py; tests/ui/test_documents_page.py | Document detail now shows linked people plus document-scoped Sources and Jobs navigation for the current document. |
| Update entry, validation, and author linkage | UP-1, UP-2, UP-3, UP-4, UP-5, UP-6 | Implemented | src/transcription/ui/pages/documents_page.py; src/transcription/services/documents.py; tests/ui/test_documents_page.py; tests/services/test_document_service.py | Dedicated edit page includes required-field validation messaging, date parsing rules, and author relationship selection with save path routed back to document detail. |
| Delete controls and guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Implemented | src/transcription/ui/pages/documents_page.py; src/transcription/services/documents.py; tests/ui/test_documents_page.py; tests/services/test_document_service.py | Dedicated delete page provides permanent-action confirmation, dependency-category blocking, and guarded backend delete behavior. |
## Person
@@ -23,7 +23,7 @@ Status legend:
| Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes |
|---|---|---|---|---|
| Create flow and validation | CR-1, CR-2, CR-3, CR-4, CR-5 | Implemented | src/transcription/ui/pages/people_page.py; src/transcription/services/documents.py; tests/ui/test_people_page.py | Dedicated Person create page with required full_name validation, optional field handling, and success routing to detail. |
| Read detail and linked documents | RD-1, RD-2, RD-3, RD-4 | Implemented | src/transcription/ui/pages/people_page.py; src/transcription/services/documents.py; tests/ui/test_people_page.py; tests/services/test_document_service.py | Person detail route renders metadata, read-only timestamps, linked-document section, and invalid/missing-id states. |
| Read detail and linked documents | RD-1, RD-2, RD-3, RD-4 | Implemented | src/transcription/ui/pages/people_page.py; src/transcription/services/documents.py; tests/ui/test_people_page.py; tests/services/test_document_service.py | Person detail route renders metadata, full-name summary, portrait preview when available, linked-document section, and invalid/missing-id states. |
| Update behavior | UP-1, UP-2, UP-3, UP-4, UP-5 | Implemented | src/transcription/ui/pages/people_page.py; src/transcription/services/documents.py; tests/ui/test_people_page.py; tests/services/test_document_service.py | Dedicated Person edit page supports allowed fields, required full_name validation, and save path back to detail. |
| Delete behavior and guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Implemented | src/transcription/ui/pages/people_page.py; src/transcription/services/documents.py; tests/ui/test_people_page.py; tests/services/test_document_service.py | Dedicated delete page provides permanent-action confirmation, linked-document blocking message, and guarded backend delete behavior. |
@@ -33,21 +33,21 @@ Status legend:
|---|---|---|---|---|
| Create entry and required links | CR-1, CR-2, CR-4, CR-5 | Implemented | src/transcription/services/store.py; src/transcription/ui/pages/jobs_page.py; src/transcription/ui/pages/upload_page.py; tests/services/test_store.py; tests/ui/test_jobs_page.py | Source upload/create is job-create-context only (legacy upload route redirects), with required Document and JobSource linkage enforced. |
| Ordering and filename policy | CR-3 | Implemented | src/transcription/services/store.py; tests/services/test_store.py; tests/ui/test_jobs_page.py | Multi-file/folder uploads are ordered alphabetically by original filename, helper text is visible, and stored filenames use generated unique-id plus extension. |
| Read and revision visibility | RD-1, RD-2, RD-3 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/ui/components/job_detail.py; src/transcription/ui/components/transcript.py | Source preview and revision context are available primarily in job detail flow. |
| Revision update behavior | UP-1, UP-2, UP-3, UP-4 | Implemented | src/transcription/services/transcription.py; src/transcription/ui/pages/jobs_page.py; src/transcription/ui/components/transcript.py | Revised text editing and save feedback path exists in job detail revision flow. |
| Delete and dependency guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/services/transcription.py; tests/ui/test_jobs_page.py; tests/services/test_transcription_service.py; tests/services/test_v2_crud.py | Job-detail source context now exposes delete entry with confirmation copy and dependency-aware service guardrails for multi-job links. |
| Read and navigation visibility | RD-1, RD-2, RD-3 | Partial | src/transcription/ui/pages/documents_page.py; src/transcription/ui/pages/jobs_page.py; tests/ui/test_documents_page.py; tests/ui/test_jobs_page.py | Source list context is now document-scoped and job detail is link-oriented; source preview and revision surfaces are deferred. |
| Revision update behavior | UP-1, UP-2, UP-3, UP-4 | Planned | src/transcription/services/transcription.py; tests/services/test_transcription_service.py | Revised text service support remains, but the current UI does not expose the editor. |
| Delete and dependency guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Planned | src/transcription/services/transcription.py; tests/services/test_transcription_service.py | Job-detail source delete UI was removed from the current simplified flow; backend guardrails remain for future reinstatement. |
## Job
| Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes |
|---|---|---|---|---|
| Create entry and required links | CR-1, CR-2, CR-5, CR-6 | Implemented | src/transcription/services/store.py; src/transcription/ui/pages/jobs_page.py; tests/ui/test_jobs_page.py; tests/services/test_store.py | Jobs list now has explicit Create entry and `/jobs/new` create flow with Document selection, upload validation, and submit routing to job detail. |
| Source ordering and upload behavior | CR-3 | Implemented | src/transcription/services/store.py; src/transcription/ui/pages/jobs_page.py; tests/services/test_store.py; tests/ui/test_jobs_page.py | Multi-file and folder upload affordances are present, uploads are sorted alphabetically by original filename, and helper guidance is shown in create UI. |
| Create entry and required links | CR-1, CR-2, CR-5, CR-6 | Implemented | src/transcription/services/store.py; src/transcription/ui/pages/jobs_page.py; tests/ui/test_jobs_page.py; tests/services/test_store.py | Jobs list now has explicit Create entry and `/jobs/new` create flow with Document selection, combined file/folder upload widget, and submit routing to job detail. |
| Source ordering and upload behavior | CR-3 | Implemented | src/transcription/services/store.py; src/transcription/ui/pages/jobs_page.py; tests/services/test_store.py; tests/ui/test_jobs_page.py | Multi-file and folder upload are supported through one widget, uploads are sorted alphabetically by original filename, and helper guidance is shown in create UI. |
| Provider/model/prompt visibility | CR-4, RD-4 | Implemented | src/transcription/services/workflows.py; src/transcription/ui/pages/jobs_page.py; tests/ui/test_jobs_page.py | Provider/model/prompt fields are visible in create and detail flows when known (with pending fallback labels). |
| Jobs list and detail read states | RD-1, RD-2, RD-3, RD-5 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/ui/components/table/jobs.py; tests/ui/test_jobs_page.py | Jobs list, detail route, and invalid/missing id states are present. |
| Revision update behavior | UP-1, UP-2, UP-3, UP-4 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/ui/components/transcript.py; src/transcription/services/transcription.py | Revision editing and save feedback exist in job detail source context. |
| Jobs list and detail read states | RD-1, RD-2, RD-3, RD-5 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/ui/components/table/jobs.py; tests/ui/test_jobs_page.py | Jobs list, detail route, document-scoped navigation, and invalid/missing id states are present. |
| Revision update behavior | UP-1, UP-2, UP-3, UP-4 | Planned | src/transcription/services/transcription.py; tests/services/test_transcription_service.py | Revision editing service support remains, but the current UI no longer exposes the editor. |
| Lifecycle visibility and retry indicators | UP-5 | Implemented | src/transcription/services/jobs.py; src/transcription/services/workflows.py; src/transcription/ui/pages/jobs_page.py; tests/ui/test_jobs_page.py | Job detail now surfaces lifecycle status plus retry/update metadata while lifecycle fields remain system-managed (no direct user edit controls). |
| Delete and dependency guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/services/jobs.py; tests/ui/test_jobs_page.py; tests/services/test_job_service.py | Dedicated Job delete page provides permanent-action confirmation, processing-state blocked-delete messaging, and dependent JobSource cleanup path when deletion is allowed. |
| Delete and dependency guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Planned | src/transcription/services/jobs.py; tests/services/test_job_service.py | Job delete UI is deferred in the current simplified flow. |
## Quality Gate Coverage
+1 -1
View File
@@ -90,7 +90,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
@app.get("/ui", include_in_schema=False)
async def ui_redirect() -> RedirectResponse:
return RedirectResponse(url="/ui/jobs", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
return RedirectResponse(url="/ui/documents", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
@app.get("/healthz")
def health() -> dict[str, str]:
+135 -15
View File
@@ -5,9 +5,12 @@ from __future__ import annotations
from datetime import date
from uuid import UUID
from fastapi import Request
from nicegui import ui
from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.errors import ErrorCategory
from transcription.services.documents import DocumentDeleteBlockedError
from transcription.services.documents import DocumentError
@@ -21,12 +24,98 @@ from ...db.session import SessionFactoryDep
def register_page() -> None:
"""Register documents list and detail routes."""
@ui.page("/documents/new")
async def document_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
ui.label("Create document").classes("text-h5 text-weight-medium")
ui.label("Document name is required.").classes("text-body2 vibe-text-muted")
name_input = ui.input(label="Document name").props("outlined")
document_type_input = ui.input(label="Document type").props("outlined")
date_input = ui.input(label="Exact date (YYYY-MM-DD)").props('outlined type="date"')
date_raw_input = ui.input(label="Approximate date").props("outlined")
location_input = ui.input(label="Document location").props("outlined")
archive_input = ui.input(label="Archive identifier").props("outlined")
notes_input = ui.textarea(label="Notes").props("outlined autogrow")
people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold())
author_options = {"": "No author"} | {str(person.id): person.full_name for person in people}
author_select = ui.select(author_options, label="Author (Person)", value="").props("outlined")
return_to = request.query_params.get("return_to")
async def submit_create() -> None:
candidate_name = (name_input.value or "").strip()
if not candidate_name:
ui.notify("Document name is required.", type="warning")
return
parsed_date: date | None = None
candidate_date_text = (date_input.value or "").strip()
if candidate_date_text:
try:
parsed_date = date.fromisoformat(candidate_date_text)
except ValueError:
ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
return
candidate = Document(
name=candidate_name,
document_type=(document_type_input.value or "").strip() or None,
document_date=parsed_date,
document_date_raw=(date_raw_input.value or "").strip() or None,
location_created=(location_input.value or "").strip() or None,
notes=(notes_input.value or "").strip() or None,
archive_identifier=(archive_input.value or "").strip() or None,
)
try:
created = await document_service.create_document(candidate)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Create failed", operation="documents.create")
return
selected_author = (author_select.value or "").strip()
if selected_author:
try:
parsed_person_id = UUID(selected_author)
except ValueError:
ui.notify("Selected author is invalid.", type="warning")
return
try:
await document_service.create_document_person(
DocumentPerson(
document_id=created.id,
person_id=parsed_person_id,
role=DocumentPersonRole.AUTHOR,
)
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Author link failed", operation="documents.create.link_author")
return
ui.notify("Document created", type="positive")
if return_to == "jobs_new":
ui.navigate.to(f"/jobs/new?document_id={created.id}")
return
ui.navigate.to(f"/documents/{created.id}")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Save document", on_click=submit_create, icon="save").props('unelevated color="primary"')
ui.button("Cancel", on_click=lambda: ui.navigate.to("/documents"), icon="arrow_back")
@ui.page("/documents")
async def documents_page(session_factory: SessionFactoryDep) -> None:
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
with ui.row().classes("w-full items-center justify-between"):
ui.label("Documents").classes("text-h5 text-weight-medium")
ui.button("Create new document", on_click=lambda: ui.navigate.to("/documents/new"), icon="note_add").props(
'unelevated color="primary"'
)
try:
documents = sorted(
@@ -40,6 +129,9 @@ def register_page() -> None:
if not documents:
ui.label("No documents yet.").classes("text-body1 vibe-text-muted")
ui.button("Create your first document", on_click=lambda: ui.navigate.to("/documents/new"), icon="note_add").props(
'unelevated color="primary"'
)
return
with ui.column().classes("w-full gap-2"):
@@ -77,6 +169,13 @@ def register_page() -> None:
ui.label(document.name).classes("text-h5 text-weight-medium")
ui.label(f"Document type: {document.document_type or 'unspecified'}").classes("text-subtitle1")
author_link = next(
(item for item in document.document_people if item.role == DocumentPersonRole.AUTHOR and item.person is not None),
None,
)
ui.label(f"Author: {author_link.person.full_name if author_link and author_link.person is not None else 'not set'}").classes(
"text-body2"
)
with ui.row().classes("w-full items-center gap-2"):
ui.button("Edit document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"), icon="edit").props(
@@ -111,40 +210,29 @@ def register_page() -> None:
ui.label("Sources").classes("text-subtitle1 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
ui.button(
"View sources",
"Sources",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/sources"),
icon="description",
).props("flat")
if not document.sources:
ui.label("No sources added yet.").classes("text-body2 vibe-text-muted")
ui.button(
"Add sources",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="upload_file",
).props('unelevated color="primary"')
else:
for source in sorted(document.sources, key=lambda item: item.page_number):
ui.label(f"Page {source.page_number}: {source.upload_name}").classes("text-body2")
ui.label(f"{len(document.sources)} source(s) linked").classes("text-body2")
ui.separator()
ui.label("Jobs").classes("text-subtitle1 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
ui.button("View jobs", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"), icon="work_history").props(
ui.button("Jobs", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"), icon="work_history").props(
"flat"
)
if not document.jobs:
ui.label("No jobs created yet.").classes("text-body2 vibe-text-muted")
ui.button(
"Create job",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="add",
).props('unelevated color="primary"')
else:
with ui.column().classes("w-full gap-1"):
for job in sorted(document.jobs, key=lambda item: item.date_created, reverse=True):
with ui.row().classes("w-full items-center justify-between"):
ui.label(f"{job.status.value} - {job.id}").classes("text-body2")
ui.button("Open", on_click=lambda _=None, job_id=job.id: ui.navigate.to(f"/jobs/{job_id}"), icon="open_in_new").props("flat")
ui.label(f"{len(document.jobs)} job(s) linked").classes("text-body2")
@ui.page("/documents/{document_id}/jobs")
async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> None:
@@ -254,6 +342,14 @@ def register_page() -> None:
location_input = ui.input(label="Document location", value=document.location_created or "").props("outlined")
archive_input = ui.input(label="Archive identifier", value=document.archive_identifier or "").props("outlined")
notes_input = ui.textarea(label="Notes", value=document.notes or "").props("outlined autogrow")
people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold())
author_options = {"": "No author"} | {str(person.id): person.full_name for person in people}
existing_author = next(
(item for item in document.document_people if item.role == DocumentPersonRole.AUTHOR),
None,
)
author_value = str(existing_author.person_id) if existing_author is not None else ""
author_select = ui.select(author_options, label="Author (Person)", value=author_value).props("outlined")
async def submit_edit() -> None:
candidate_name = (name_input.value or "").strip()
@@ -293,6 +389,30 @@ def register_page() -> None:
show_error(exc, title="Save failed", operation="documents.edit.save")
return
selected_author = (author_select.value or "").strip()
existing_author_links = [
link for link in document.document_people if link.role == DocumentPersonRole.AUTHOR
]
try:
if not selected_author:
for link in existing_author_links:
await document_service.delete_document_person(link)
else:
selected_author_id = UUID(selected_author)
if not any(link.person_id == selected_author_id for link in existing_author_links):
for link in existing_author_links:
await document_service.delete_document_person(link)
await document_service.create_document_person(
DocumentPerson(
document_id=document.id,
person_id=selected_author_id,
role=DocumentPersonRole.AUTHOR,
)
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Author update failed", operation="documents.edit.link_author")
return
ui.notify("Document updated", type="positive")
ui.navigate.to(f"/documents/{document.id}")
+26 -129
View File
@@ -8,26 +8,19 @@ from uuid import UUID
from nicegui import ui
from transcription.db.models import Job
from transcription.db.models import JobStatus
from transcription.db.models import Source
from transcription.db.session import session_scope
from transcription.services.documents import DocumentService
from transcription.services.jobs import JobDeleteBlockedError
from transcription.services.jobs import JobService
from transcription.services.transcription import SourceDeleteBlockedError
from transcription.services.store import create_job_for_document
from transcription.services.transcription import TranscriptionService
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.table.jobs import render_jobs_table
from transcription.worker import resolve_worker_notifier
from ...db.session import SessionFactoryDep
from ..components.document_panzoom import render_document_panzoom
from ..components.table.jobs import JobTableRow
from ..components.transcript import render_original_transcription_card
from ..components.transcript import render_revision_row
def register_page() -> None: # noqa: PLR0915
@@ -72,6 +65,11 @@ def register_page() -> None: # noqa: PLR0915
"text-body1 text-warning"
)
with ui.row():
ui.button(
"Create document",
on_click=lambda: ui.navigate.to("/documents/new?return_to=jobs_new"),
icon="note_add",
).props('unelevated color="primary"')
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back")
return
@@ -131,13 +129,7 @@ def register_page() -> None: # noqa: PLR0915
ui.upload(
on_upload=on_upload,
auto_upload=True,
label="Select source files",
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf" multiple')
ui.upload(
on_upload=on_upload,
auto_upload=True,
label="Upload folder",
label="Select source files or a folder",
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf" webkitdirectory directory multiple')
render_upload_list()
@@ -182,9 +174,8 @@ def register_page() -> None: # noqa: PLR0915
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back")
@ui.page("/jobs/{job_id}")
async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None: # noqa: PLR0915
async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None:
jobs_service = JobService(session_factory=session_factory)
transcription_service = TranscriptionService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
try:
@@ -199,31 +190,16 @@ def register_page() -> None: # noqa: PLR0915
ui.label("Job not found").classes("text-h6 text-negative")
return
source = _resolve_primary_source(job)
with ui.splitter(value=30).classes("w-full h-[calc(100vh-64px)]") as splitter:
with splitter.before, ui.column(align_items="stretch").classes("w-full h-full p-4 gap-3"):
if source is not None:
render_document_panzoom(source=source)
else:
ui.label("No source preview is available for this job.").classes("text-body2 vibe-text-muted")
with splitter.after, ui.column(align_items="stretch").classes("w-full h-full p-4 gap-3"):
with ui.row():
ui.button(icon="arrow_back", on_click=ui.navigate.back)
with ui.column().classes("w-full gap-3"):
with ui.row().classes("w-full items-center justify-between"):
ui.label(f"{job.id}").classes("text-h6 text-weight-bold")
ui.button(icon="arrow_back", on_click=ui.navigate.back)
match job.status:
case JobStatus.TRANSCRIBED:
ui.chip(job.status.value.upper(), color="positive", text_color="white").props("outline")
case _:
ui.label(f"{job.status.value}").classes("text-subtitle1 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
ui.button(
"Delete job",
on_click=lambda: ui.navigate.to(f"/jobs/{parsed_job_id}/delete"),
icon="delete",
).props("outline color=negative")
ui.label(f"Job {job.id}").classes("text-h6 text-weight-bold")
with ui.column().classes("gap-1"):
ui.label(f"Provider: {job.provider or 'pending'}").classes("text-body2")
@@ -232,93 +208,24 @@ def register_page() -> None: # noqa: PLR0915
ui.label(f"Retry count: {job.retry_count}").classes("text-body2")
ui.label(f"Last updated: {job.date_updated.isoformat()}").classes("text-body2")
render_original_transcription_card(job=job)
@ui.refreshable
async def render_revision_panel() -> None:
refreshed_job = await jobs_service.read_job(job_id=parsed_job_id)
refreshed_source = _resolve_primary_source(refreshed_job)
if refreshed_source is None:
ui.label("No source is available for revision editing.").classes("text-body2 vibe-text-muted")
return
current_revision_text = refreshed_source.revised_text
default_revision_text = current_revision_text or ""
ui.label("Revision Editor").classes("text-subtitle1 text-weight-medium")
editor = ui.textarea(label="Revision text", value=default_revision_text).props("autogrow outlined")
editor.classes("w-full")
async def delete_source() -> None:
try:
await transcription_service.delete_source_from_job_context(
job_id=parsed_job_id,
source_id=refreshed_source.id,
)
except SourceDeleteBlockedError as exc:
ui.notify(exc.message, type="warning")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Delete source failed", operation="jobs.delete_source")
return
ui.notify("Source deleted", type="positive")
ui.navigate.to(f"/jobs/{parsed_job_id}")
async def confirm_delete_source() -> None:
delete_dialog.close()
await delete_source()
async def save_revision() -> None:
candidate = (editor.value or "").strip()
if not candidate:
ui.notify("Revision text is required.", type="warning")
return
try:
await transcription_service.upsert_revision_for_source(
source_id=refreshed_source.id,
text=candidate,
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Save failed", operation="jobs.save_revision")
return
ui.notify("Revision saved", type="positive")
await render_revision_panel.refresh()
with ui.row().classes("w-full justify-end gap-2"):
with ui.dialog() as delete_dialog, ui.card().classes("min-w-[22rem]"):
ui.label("Delete source").classes("text-subtitle1 text-weight-medium")
ui.label("This permanently deletes the source from this job context.").classes("text-body2")
ui.label("If related job history exists, deletion may be blocked.").classes(
"text-body2 vibe-text-muted"
)
with ui.row().classes("w-full justify-end gap-2"):
ui.button("Cancel", on_click=delete_dialog.close)
ui.separator()
ui.label("Document Links").classes("text-subtitle1 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
ui.button(
"Delete source",
on_click=confirm_delete_source,
icon="delete_forever",
).props('unelevated color="negative"')
ui.button("Delete source", on_click=delete_dialog.open, icon="delete").props("outline color=negative")
"Document",
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}"),
icon="description",
).props("flat")
ui.button(
"Create revision" if current_revision_text is None else "Update revision",
on_click=save_revision,
icon="save",
).props('unelevated color="primary"')
if current_revision_text is None:
ui.label("No source revision exists for this source.").classes("text-body2 vibe-text-muted")
return
render_revision_row(
revision=refreshed_source,
initially_expanded=True,
)
await render_revision_panel()
"Sources",
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}/sources"),
icon="description",
).props("flat")
ui.button(
"Jobs",
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}/jobs"),
icon="work_history",
).props("flat")
@ui.page("/jobs/{job_id}/delete")
async def job_delete_page(job_id: str, session_factory: SessionFactoryDep) -> None:
@@ -375,13 +282,3 @@ def register_page() -> None: # noqa: PLR0915
)
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back")
def _resolve_primary_source(job: Job) -> Source | None:
if not job.job_sources:
return None
for job_source in job.job_sources:
if job_source.source is not None:
return job_source.source
return None
+42 -4
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
from datetime import date
from urllib.parse import quote
from uuid import UUID
from nicegui import ui
@@ -29,6 +30,35 @@ def _parse_optional_date(value: str | None, *, label: str) -> date | None:
raise ValueError(f"{label} must use YYYY-MM-DD.") from exc
def _bind_portrait_file_picker(portrait_path_input: ui.input) -> None:
async def on_portrait_selected(event) -> None:
portrait_path_input.value = event.file.name
ui.notify("Portrait filename selected. Edit the path if needed.", type="info")
ui.upload(
on_upload=on_portrait_selected,
auto_upload=True,
label="Choose portrait file",
).props('accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"')
ui.label("This picker fills the filename from your selected image.").classes("text-body2 vibe-text-muted")
def _resolve_portrait_src(path: str | None) -> str | None:
candidate = (path or "").strip()
if not candidate:
return None
normalized = candidate.replace("\\", "/")
lowered = normalized.casefold()
if lowered.startswith("http://") or lowered.startswith("https://") or lowered.startswith("data:"):
return normalized
if normalized.startswith("/"):
return normalized
if lowered.startswith("uploads/"):
return f"/{normalized}"
return f"/uploads/{quote(normalized)}"
def register_page() -> None: # noqa: PLR0915
"""Register people list and CRUD routes."""
@@ -82,16 +112,17 @@ def register_page() -> None: # noqa: PLR0915
display_name_input = ui.input(label="Display name").props("outlined")
maiden_name_input = ui.input(label="Maiden name").props("outlined")
birth_date_input = ui.input(label="Birth date (YYYY-MM-DD)").props("outlined")
birth_date_input = ui.input(label="Birth date (YYYY-MM-DD)").props('outlined type="date"')
birth_date_raw_input = ui.input(label="Birth date (approximate/raw)").props("outlined")
birth_place_input = ui.input(label="Birth place").props("outlined")
death_date_input = ui.input(label="Death date (YYYY-MM-DD)").props("outlined")
death_date_input = ui.input(label="Death date (YYYY-MM-DD)").props('outlined type="date"')
death_date_raw_input = ui.input(label="Death date (approximate/raw)").props("outlined")
death_place_input = ui.input(label="Death place").props("outlined")
biography_input = ui.textarea(label="Biography").props("outlined autogrow")
portrait_path_input = ui.input(label="Portrait path").props("outlined")
_bind_portrait_file_picker(portrait_path_input)
async def submit_create() -> None:
full_name = (full_name_input.value or "").strip()
@@ -166,6 +197,7 @@ def register_page() -> None: # noqa: PLR0915
).props("outline color=negative")
with ui.column().classes("w-full gap-1"):
ui.label(f"Full name: {person.full_name}")
ui.label(f"Display name: {person.display_name or 'not set'}")
ui.label(f"Maiden name: {person.maiden_name or 'not set'}")
ui.label(f"Birth date: {person.birth_date.isoformat() if person.birth_date else 'not set'}")
@@ -176,6 +208,11 @@ def register_page() -> None: # noqa: PLR0915
ui.label(f"Death place: {person.death_place or 'not set'}")
ui.label(f"Biography: {person.biography or 'not set'}")
ui.label(f"Portrait path: {person.portrait_path or 'not set'}")
portrait_src = _resolve_portrait_src(person.portrait_path)
if portrait_src is not None:
ui.image(portrait_src).classes("w-40 rounded shadow")
else:
ui.label("No portrait image set.").classes("text-body2 vibe-text-muted")
ui.label(f"Created at (read-only): {person.created_at.isoformat()}").classes("text-body2")
ui.label(f"Updated at (read-only): {person.updated_at.isoformat()}").classes("text-body2")
@@ -230,7 +267,7 @@ def register_page() -> None: # noqa: PLR0915
birth_date_input = ui.input(
label="Birth date (YYYY-MM-DD)",
value=person.birth_date.isoformat() if person.birth_date else "",
).props("outlined")
).props('outlined type="date"')
birth_date_raw_input = ui.input(label="Birth date (approximate/raw)", value=person.birth_date_raw or "").props(
"outlined"
)
@@ -239,7 +276,7 @@ def register_page() -> None: # noqa: PLR0915
death_date_input = ui.input(
label="Death date (YYYY-MM-DD)",
value=person.death_date.isoformat() if person.death_date else "",
).props("outlined")
).props('outlined type="date"')
death_date_raw_input = ui.input(label="Death date (approximate/raw)", value=person.death_date_raw or "").props(
"outlined"
)
@@ -247,6 +284,7 @@ def register_page() -> None: # noqa: PLR0915
biography_input = ui.textarea(label="Biography", value=person.biography or "").props("outlined autogrow")
portrait_path_input = ui.input(label="Portrait path", value=person.portrait_path or "").props("outlined")
_bind_portrait_file_picker(portrait_path_input)
async def submit_edit() -> None:
full_name = (full_name_input.value or "").strip()
+30 -6
View File
@@ -29,7 +29,28 @@ class TestDocumentsPageRendering:
assert response.status_code == 200
assert "Documents" in response.text
assert "Create new document" in response.text
assert "No documents yet." in response.text
assert "Create your first document" in response.text
def test_document_create_page_renders_fields(self, app_client):
"""GET /ui/documents/new renders document-create form fields."""
_, client = app_client
response = client.get("/ui/documents/new")
assert response.status_code == 200
assert "Create document" in response.text
assert "Document name is required." in response.text
assert "Document name" in response.text
assert "Document type" in response.text
assert "Author (Person)" in response.text
assert "Exact date (YYYY-MM-DD)" in response.text
assert "Approximate date" in response.text
assert "Document location" in response.text
assert "Archive identifier" in response.text
assert "Notes" in response.text
assert "Save document" in response.text
def test_documents_page_lists_seeded_documents(self, app_client):
"""GET /ui/documents lists seeded document cards."""
@@ -75,6 +96,7 @@ class TestDocumentsPageRendering:
assert response.status_code == 200
assert "Zenna Letter" in response.text
assert "Document type: letter" in response.text
assert "Author: not set" in response.text
assert "Exact date: 1885-07-13" in response.text
assert "Approximate date: c. 1885" in response.text
assert "Location created: Ohio" in response.text
@@ -83,12 +105,12 @@ class TestDocumentsPageRendering:
assert "Created at (read-only):" in response.text
assert "Updated at (read-only):" in response.text
assert "No linked people yet." in response.text
assert "No sources added yet." in response.text
assert "No jobs created yet." in response.text
assert "0 source(s) linked" in response.text
assert "0 job(s) linked" in response.text
assert "Add sources" in response.text
assert "Create job" in response.text
assert "View sources" in response.text
assert "View jobs" in response.text
assert "Sources" in response.text
assert "Jobs" in response.text
assert "Edit document" in response.text
assert "Delete document" in response.text
@@ -135,8 +157,9 @@ class TestDocumentsPageRendering:
assert response.status_code == 200
assert "Jane Doe (author)" in response.text
assert "Page 1: 001_page.png" in response.text
assert "queued -" in response.text
assert "Author: Jane Doe" in response.text
assert "1 source(s) linked" in response.text
assert "1 job(s) linked" in response.text
def test_document_jobs_page_filters_to_document_context(self, app_client):
_, client = app_client
@@ -245,6 +268,7 @@ class TestDocumentsPageRendering:
assert "Document name and document type are required." in response.text
assert "Document name" in response.text
assert "Document type" in response.text
assert "Author (Person)" in response.text
assert "Exact date (YYYY-MM-DD)" in response.text
assert "Approximate date" in response.text
assert "Document location" in response.text
+12 -41
View File
@@ -1,7 +1,6 @@
"""Tests for the jobs page route."""
import asyncio
from pathlib import Path
from uuid import uuid4
import pytest
@@ -33,6 +32,7 @@ class TestPageRendering:
assert response.status_code == 200
assert "Create job" in response.text
assert "No documents available. Create a Document before creating a Job." in response.text
assert "Create document" in response.text
def test_job_create_page_lists_available_documents(self, app_client):
"""GET /ui/jobs/new renders document choices when Documents exist."""
@@ -52,7 +52,7 @@ class TestPageRendering:
assert "Seeded Document" in response.text
assert "Files are processed alphabetically by original filename." in response.text
assert "No files uploaded yet." in response.text
assert "Upload folder" in response.text
assert "Select source files or a folder" in response.text
def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job):
"""GET /ui/jobs lists seeded jobs from the in-memory database."""
@@ -65,37 +65,28 @@ class TestPageRendering:
assert "sample.pdf" in response.text
assert "transcribed" in response.text
def test_job_detail_page_renders_seeded_job(self, app_client, seed_job):
"""GET /ui/jobs/{job_id} renders detail content for a real seeded job."""
def test_job_detail_page_renders_document_links(self, app_client, seed_job):
"""GET /ui/jobs/{job_id} renders document-scoped navigation links."""
_, client = app_client
fixture_path = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid" / "single_page_pdf.pdf"
job_id = seed_job(
filename="detail.pdf",
status=JobStatus.TRANSCRIBED,
transcription_text="original text",
revision_text="hello",
source_file=fixture_path,
)
response = client.get(f"/ui/jobs/{job_id}")
assert response.status_code == 200
assert "Original Transcription" in response.text
assert "detail.pdf" in response.text
assert "Source revision" in response.text
assert "Source revision" in response.text
assert "hello" in response.text
assert "original text" in response.text
assert "Document preview" in response.text
assert "/uploads/detail.pdf" in response.text
assert "Job" in response.text
assert "Provider:" in response.text
assert "Model:" in response.text
assert "Prompt:" in response.text
assert "Retry count:" in response.text
assert "Last updated:" in response.text
assert "Delete job" in response.text
assert "Delete source" in response.text
assert "This permanently deletes the source from this job context." in response.text
assert "Document Links" in response.text
assert "Sources" in response.text
assert "Jobs" in response.text
assert "Delete job" not in response.text
def test_job_detail_page_rejects_invalid_id(self, app_client):
"""GET /ui/jobs/{job_id} shows validation feedback for malformed IDs."""
@@ -114,39 +105,19 @@ class TestPageRendering:
assert response.status_code == 200
assert "Job not found" in response.text
def test_job_detail_page_shows_revision_editor_when_none_exists(self, app_client, seed_job):
"""GET /ui/jobs/{job_id} renders revision editor and create action for sources with no revision."""
def test_job_detail_page_hides_delete_action(self, app_client, seed_job):
"""GET /ui/jobs/{job_id} does not expose job deletion controls in this revision."""
_, client = app_client
job_id = seed_job(
filename="no-revision.pdf",
status=JobStatus.TRANSCRIBED,
transcription_text="original text",
revision_text=None,
)
response = client.get(f"/ui/jobs/{job_id}")
assert response.status_code == 200
assert "Revision Editor" in response.text
assert "Create revision" in response.text
assert "No source revision exists for this source." in response.text
def test_job_detail_page_shows_update_action_for_existing_revision(self, app_client, seed_job):
"""GET /ui/jobs/{job_id} renders revision editor with update action when revision exists."""
_, client = app_client
job_id = seed_job(
filename="with-revision.pdf",
status=JobStatus.TRANSCRIBED,
transcription_text="original text",
revision_text="hello",
)
response = client.get(f"/ui/jobs/{job_id}")
assert response.status_code == 200
assert "Revision Editor" in response.text
assert "Update revision" in response.text
assert "hello" in response.text
assert "Delete job" not in response.text
def test_job_delete_page_shows_confirmation_when_not_processing(self, app_client, seed_job):
_, client = app_client
+1
View File
@@ -85,6 +85,7 @@ class TestPeoplePageRendering:
assert response.status_code == 200
assert "Grace Hopper" in response.text
assert "Full name: Grace Hopper" in response.text
assert "Display name: Grace" in response.text
assert "Maiden name: Murray" in response.text
assert "Birth date: 1906-12-09" in response.text
+3 -3
View File
@@ -15,13 +15,13 @@ class TestPageRendering:
assert response.status_code == 307
assert response.headers["location"] == "/ui"
def test_ui_redirects_to_upload(self, app_client):
"""GET /ui redirects to the jobs page."""
def test_ui_redirects_to_documents(self, app_client):
"""GET /ui redirects to the documents page."""
_, client = app_client
response = client.get("/ui", follow_redirects=False)
assert response.status_code == 307
assert response.headers["location"] == "/ui/jobs"
assert response.headers["location"] == "/ui/documents"
def test_upload_page_renders_expected_controls(self, app_client):
"""GET /ui/upload redirects to the job-create flow."""