Revamped the Documents, People, & Jobs too.

This commit is contained in:
Jim Lancaster
2026-08-05 13:05:41 -05:00
parent 72bc96ab3a
commit fd3ca60008
15 changed files with 1719 additions and 1338 deletions
@@ -39,21 +39,61 @@ def _serialize_rows(rows: Sequence[DocumentTableRow]) -> list[dict[str, Any]]:
def render_documents_table(rows: Sequence[DocumentTableRow]) -> None: def render_documents_table(rows: Sequence[DocumentTableRow]) -> None:
"""Render documents table and open detail page when clicking a row.""" """Render documents table with search filtering and custom type chips."""
if not rows: if not rows:
with archival_card(extra_classes="p-8 text-center"): with archival_card(extra_classes="p-8 text-center"):
render_empty_state("No documents in repository yet.") render_empty_state("No documents in repository yet.")
return return
build_table( table = build_table(
rows=_serialize_rows(rows), rows=_serialize_rows(rows),
columns=[ columns=[
{"name": "name", "label": "Document Title", "field": "name", "sortable": True, "classes": "font-serif font-semibold"}, {
{"name": "document_type", "label": "Type", "field": "document_type", "sortable": True}, "name": "name",
{"name": "archive_identifier", "label": "Archive Ref", "field": "archive_identifier", "sortable": True, "classes": "font-mono"}, "label": "Document Title",
{"name": "created_at", "label": "Created", "field": "created_at", "sortable": True}, "field": "name",
"sortable": True,
"classes": "font-serif font-semibold",
},
{
"name": "document_type",
"label": "Type",
"field": "document_type",
"sortable": True,
},
{
"name": "archive_identifier",
"label": "Archive Ref",
"field": "archive_identifier",
"sortable": True,
"classes": "font-mono text-xs",
},
{
"name": "created_at",
"label": "Created",
"field": "created_at",
"sortable": True,
},
], ],
default_sort_by="name", default_sort_by="name",
classes="app-table w-full", search_placeholder="Search documents by title, type, or reference...",
on_row_click_id=lambda doc_id: ui.navigate.to(f"/documents/{doc_id}"), on_row_click_id=lambda doc_id: ui.navigate.to(f"/documents/{doc_id}"),
) )
# Render document type using a subtle Quasar badge
table.add_slot(
"body-cell-document_type",
r"""
<q-td :props="props">
<q-chip
dense
square
size="sm"
color="primary"
text-color="white"
>
{{ props.value }}
</q-chip>
</q-td>
""",
)
+68 -13
View File
@@ -4,8 +4,7 @@ from __future__ import annotations
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, datetime
from datetime import datetime
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
@@ -29,7 +28,7 @@ class JobTableRow:
def _format_timestamp(value: str) -> str: def _format_timestamp(value: str) -> str:
"""Return a friendly UTC timestamp for table display.""" """Return a friendly local timestamp for table display."""
try: try:
parsed = datetime.fromisoformat(value) parsed = datetime.fromisoformat(value)
except ValueError: except ValueError:
@@ -42,7 +41,7 @@ def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]:
return [ return [
{ {
"id": str(row.id), "id": str(row.id),
"status": row.status.upper(), "status": row.status.lower(),
"filename": row.filename, "filename": row.filename,
"retry_count": row.retry_count, "retry_count": row.retry_count,
"date_created": _format_timestamp(row.date_created), "date_created": _format_timestamp(row.date_created),
@@ -55,24 +54,80 @@ def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]:
def render_jobs_table(rows: Sequence[JobTableRow]) -> None: def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
"""Render jobs table and open a detail page when clicking a row.""" """Render jobs table with search filtering and custom status chips."""
if not rows: if not rows:
with archival_card(extra_classes="p-8 text-center"): with archival_card(extra_classes="p-8 text-center"):
render_empty_state("No active or historical processing jobs found.") render_empty_state("No active or historical processing jobs found.")
return return
build_table( table = build_table(
rows=_serialize_rows(rows), rows=_serialize_rows(rows),
columns=[ columns=[
{"name": "id", "label": "Job ID", "field": "id", "sortable": True, "classes": "font-mono"}, {
{"name": "status", "label": "Status", "field": "status", "sortable": True, "classes": "font-semibold ui-link-primary"}, "name": "id",
{"name": "filename", "label": "Source Filename", "field": "filename", "sortable": True, "classes": "font-mono"}, "label": "Job ID",
{"name": "retry_count", "label": "Retries", "field": "retry_count", "sortable": True}, "field": "id",
{"name": "date_created", "label": "Created", "field": "date_created", "sortable": True}, "sortable": True,
{"name": "date_updated", "label": "Updated", "field": "date_updated", "sortable": True}, "classes": "font-mono text-xs",
},
{
"name": "status",
"label": "Status",
"field": "status",
"sortable": True,
"classes": "font-mono",
},
{
"name": "filename",
"label": "Source Filename",
"field": "filename",
"sortable": True,
"classes": "font-mono text-xs",
},
{
"name": "retry_count",
"label": "Retries",
"field": "retry_count",
"sortable": True,
},
{
"name": "date_created",
"label": "Created",
"field": "created_sort",
"sortable": True,
},
{
"name": "date_updated",
"label": "Updated",
"field": "updated_sort",
"sortable": True,
},
], ],
default_sort_by="created_sort", default_sort_by="created_sort",
default_descending=True, default_descending=True,
classes="app-table w-full", search_placeholder="Search jobs by ID, filename, or status...",
on_row_click_id=lambda job_id: ui.navigate.to(f"/jobs/{job_id}"), on_row_click_id=lambda job_id: ui.navigate.to(f"/jobs/{job_id}"),
) )
# Render job execution status using themed Quasar chips
table.add_slot(
"body-cell-status",
r"""
<q-td :props="props">
<q-chip
dense
square
size="sm"
:color="
props.value === 'completed' || props.value === 'transcribed' ? 'positive' :
props.value === 'failed' ? 'negative' :
props.value === 'processing' ? 'secondary' :
props.value === 'queued' ? 'warning' : 'grey-6'
"
text-color="white"
>
{{ props.value.toUpperCase() }}
</q-chip>
</q-td>
""",
)
@@ -39,21 +39,56 @@ def _serialize_rows(rows: Sequence[PersonTableRow]) -> list[dict[str, Any]]:
def render_people_table(rows: Sequence[PersonTableRow]) -> None: def render_people_table(rows: Sequence[PersonTableRow]) -> None:
"""Render people table and open detail page when clicking a row.""" """Render people table with search filtering and custom typography."""
if not rows: if not rows:
with archival_card(extra_classes="p-8 text-center"): with archival_card(extra_classes="p-8 text-center"):
render_empty_state("No person records found in repository.") render_empty_state("No person records found in repository.")
return return
build_table( table = build_table(
rows=_serialize_rows(rows), rows=_serialize_rows(rows),
columns=[ columns=[
{"name": "full_name", "label": "Full Name", "field": "full_name", "sortable": True, "classes": "font-serif font-semibold"}, {
{"name": "display_name", "label": "Display Name", "field": "display_name", "sortable": True}, "name": "full_name",
{"name": "maiden_name", "label": "Maiden Name", "field": "maiden_name", "sortable": True}, "label": "Full Name",
{"name": "birth_date", "label": "Birth Date", "field": "birth_date", "sortable": True}, "field": "full_name",
"sortable": True,
"classes": "font-serif font-semibold",
},
{
"name": "display_name",
"label": "Display Name",
"field": "display_name",
"sortable": True,
},
{
"name": "maiden_name",
"label": "Maiden Name",
"field": "maiden_name",
"sortable": True,
},
{
"name": "birth_date",
"label": "Birth Date",
"field": "birth_date",
"sortable": True,
"classes": "font-mono text-xs",
},
], ],
default_sort_by="full_name", default_sort_by="full_name",
classes="app-table w-full", search_placeholder="Search people by name or birth date...",
on_row_click_id=lambda person_id: ui.navigate.to(f"/people/{person_id}"), on_row_click_id=lambda person_id: ui.navigate.to(f"/people/{person_id}"),
) )
# Custom column template adding an archival entity icon next to person's name
table.add_slot(
"body-cell-full_name",
r"""
<q-td :props="props">
<div class="row items-center q-gutter-x-xs">
<q-icon name="person" size="xs" color="primary" />
<span class="font-serif font-semibold">{{ props.value }}</span>
</div>
</q-td>
""",
)
+174 -278
View File
@@ -9,26 +9,25 @@ from fastapi import Request
from fastapi.responses import RedirectResponse 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, DocumentPerson, DocumentPersonRole
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.errors import ErrorCategory from transcription.errors import ErrorCategory
from transcription.services.documents import DocumentDeleteBlockedError from transcription.services.documents import (
from transcription.services.documents import DocumentError DocumentDeleteBlockedError,
from transcription.services.documents import DocumentService DocumentError,
DocumentService,
)
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, 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.primitives import destructive_button from transcription.ui.components.primitives import (
from transcription.ui.components.primitives import render_empty_state destructive_button,
from transcription.ui.components.primitives import section_header_row render_empty_state,
from transcription.ui.components.table.documents import DocumentTableRow section_header_row,
from transcription.ui.components.table.documents import render_documents_table )
from transcription.ui.components.table.documents import DocumentTableRow, render_documents_table
from transcription.ui.components.viewers import dark_room_viewer from transcription.ui.components.viewers import dark_room_viewer
from transcription.ui.theme import apply_archival_theme from transcription.ui.theme import apply_archival_theme, page_header
from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep from ...db.session import SessionFactoryDep
@@ -47,61 +46,31 @@ def register_page() -> None:
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"): with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
page_header("Create Document", subtitle="Document name is required.") page_header("Create Document", subtitle="Document name is required.")
with archival_card(extra_classes="gap-3"):
name_input = ui.input(label="Document name").props("outlined bg-white").classes("w-full")
document_type_input = ui.input(label="Document type").props("outlined bg-white").classes("w-full")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
date_input = ui.input(label="Exact date (YYYY-MM-DD)").props('outlined bg-white type="date"')
date_raw_input = ui.input(label="Approximate date").props("outlined bg-white")
location_input = ui.input(label="Document location").props("outlined bg-white").classes("w-full")
archive_input = ui.input(label="Archive identifier").props("outlined bg-white").classes("w-full")
notes_input = ui.textarea(label="Notes").props("outlined bg-white autogrow").classes("w-full")
people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold()) people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold())
author_options = ( form = _render_document_form_fields(people=people)
{"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"}
| {str(person.id): person.full_name for person in people}
)
def on_author_change(event) -> None:
selected = str(event.value or "").strip()
if selected == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
author_select = (
ui.select(author_options, label="Author (Person)", value="", on_change=on_author_change)
.props("outlined bg-white")
.classes("w-full")
)
ui.link("Create new person", "/people/new").classes("text-xs ui-link-primary font-medium")
requested_doc_id = request.query_params.get("document_id")
return_to = request.query_params.get("return_to") return_to = request.query_params.get("return_to")
async def submit_create() -> None: async def submit_create() -> None:
candidate_name = (name_input.value or "").strip() candidate_name = (form["name"].value or "").strip()
if not candidate_name: if not candidate_name:
ui.notify("Document name is required.", type="warning") ui.notify("Document name is required.", type="warning")
return return
parsed_date: date | None = None parsed_date = _parse_iso_date(form["date"].value)
candidate_date_text = (date_input.value or "").strip() if form["date"].value and parsed_date is None:
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") ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
return return
candidate = Document( candidate = Document(
name=candidate_name, name=candidate_name,
document_type=(document_type_input.value or "").strip() or None, document_type=(form["type"].value or "").strip() or None,
document_date=parsed_date, document_date=parsed_date,
document_date_raw=(date_raw_input.value or "").strip() or None, document_date_raw=(form["date_raw"].value or "").strip() or None,
location_created=(location_input.value or "").strip() or None, location_created=(form["location"].value or "").strip() or None,
notes=(notes_input.value or "").strip() or None, notes=(form["notes"].value or "").strip() or None,
archive_identifier=(archive_input.value or "").strip() or None, archive_identifier=(form["archive"].value or "").strip() or None,
) )
try: try:
@@ -110,14 +79,13 @@ def register_page() -> None:
show_error(exc, title="Create failed", operation="documents.create") show_error(exc, title="Create failed", operation="documents.create")
return return
selected_author = (author_select.value or "").strip() selected_author = (form["author"].value or "").strip()
if selected_author == CREATE_NEW_PERSON_OPTION: if selected_author == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new") ui.navigate.to("/people/new")
return return
if selected_author: if selected_author:
try: parsed_author_id = _parse_uuid(selected_author)
parsed_person_id = UUID(selected_author) if parsed_author_id is None:
except ValueError:
ui.notify("Selected author is invalid.", type="warning") ui.notify("Selected author is invalid.", type="warning")
return return
@@ -125,7 +93,7 @@ def register_page() -> None:
await document_service.create_document_person( await document_service.create_document_person(
DocumentPerson( DocumentPerson(
document_id=created.id, document_id=created.id,
person_id=parsed_person_id, person_id=parsed_author_id,
role=DocumentPersonRole.AUTHOR, role=DocumentPersonRole.AUTHOR,
) )
) )
@@ -168,7 +136,6 @@ def register_page() -> None:
show_error(exc, title="Load failed", operation="documents.list") show_error(exc, title="Load failed", operation="documents.list")
return return
# Format documents into read-model rows for the table renderer
rows = [ rows = [
DocumentTableRow( DocumentTableRow(
id=doc.id, id=doc.id,
@@ -187,14 +154,13 @@ def register_page() -> None:
document_service = DocumentService(session_factory=session_factory) document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents") render_navigation_header(current_path="/documents")
try: parsed_doc_id = _parse_uuid(document_id)
parsed_document_id = UUID(document_id) if parsed_doc_id is None:
except ValueError:
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4") ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
return return
try: try:
document = await document_service.read_document_detail(document_id=parsed_document_id) document = await document_service.read_document_detail(document_id=parsed_doc_id)
except DocumentError: except DocumentError:
ui.label("Document not found").classes("text-h6 text-red-800 p-4") ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return return
@@ -202,18 +168,7 @@ def register_page() -> None:
show_error(exc, title="Load failed", operation="documents.read") show_error(exc, title="Load failed", operation="documents.read")
return return
author_link = next(
(
item
for item in document.document_people
if item.role == DocumentPersonRole.AUTHOR and item.person is not None
),
None,
)
# Main Bento Grid Wrapper
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"): with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
# Header Bar
with section_header_row(): with section_header_row():
page_header(document.name, subtitle=f"Type: {document.document_type or 'Unspecified'} | ID: {document.id}") page_header(document.name, subtitle=f"Type: {document.document_type or 'Unspecified'} | ID: {document.id}")
@@ -230,73 +185,10 @@ def register_page() -> None:
extra_classes="text-xs", extra_classes="text-xs",
) )
# High-Density Bento Grid Layout
with ui.grid().classes("w-full grid-cols-12 gap-4"): with ui.grid().classes("w-full grid-cols-12 gap-4"):
# ZONE 1: Source Image Viewer (Cols 1-5) _render_bento_viewer_zone(document)
with ui.column().classes("col-span-12 lg:col-span-5"): _render_bento_metadata_zone(document)
source_path = document.sources[0].file_path if document.sources else None _render_bento_relations_zone(document)
dark_room_viewer(source_path, count_label=f"{len(document.sources)} Source(s) Linked")
with ui.row().classes("w-full justify-between items-center mt-2"):
ui.button(
"View All Sources",
on_click=lambda: ui.navigate.to(f"/sources?document_id={document.id}"),
icon="description",
).props("flat dense text-xs").classes("ui-link-primary")
ui.button(
"+ Add Source",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="add",
).classes("ui-btn-primary text-xs")
# ZONE 2: Metadata & Archival Attributes (Cols 6-8)
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Archival Metadata"):
metadata_row("Author:", author_link.person.full_name if author_link and author_link.person else "Not set")
metadata_row("Exact Date:", document.document_date.isoformat() if document.document_date else "Not set")
metadata_row("Approx. Date:", document.document_date_raw or "Not set")
metadata_row("Location Created:", document.location_created or "Not set")
metadata_row("Archive Identifier:", document.archive_identifier or "Not set")
with ui.column().classes("w-full mt-2"):
ui.label("Archival Notes:").classes("ui-text-muted text-xs mb-1")
ui.label(document.notes or "No notes added.").classes(
"p-2 ui-note-box text-xs"
)
with archival_card(title="System Logistics"):
ui.label(f"Created: {document.created_at.isoformat()}").classes("text-[11px] ui-text-muted")
ui.label(f"Updated: {document.updated_at.isoformat()}").classes("text-[11px] ui-text-muted")
# ZONE 3: Related Entities & Pipeline Jobs (Cols 9-12)
with ui.column().classes("col-span-12 lg:col-span-3 gap-4"):
with archival_card(title="Related People"):
if not document.document_people:
render_empty_state("No linked people yet.", italic=True)
else:
with ui.column().classes("w-full gap-2"):
for link in document.document_people:
person_label = link.person.full_name if link.person is not None else "Unknown person"
with ui.row().classes(
"w-full justify-between items-center ui-row-surface p-2"
):
ui.label(person_label).classes("text-xs font-semibold ui-text-primary")
archival_badge(link.role.value)
with archival_card(title="Pipeline Jobs"):
with ui.row().classes("w-full justify-between items-center mb-2"):
ui.label(f"{len(document.jobs)} Active Jobs").classes("text-xs ui-link-primary font-bold")
with ui.row().classes("w-full gap-2 mt-2"):
ui.button(
"View Jobs",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"),
icon="work_history",
).props("flat dense text-xs").classes("ui-link-primary")
ui.button(
"+ Add Job",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="add",
).classes("ui-btn-primary text-xs")
@ui.page("/documents/{document_id}/jobs") @ui.page("/documents/{document_id}/jobs")
async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> None: async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> None:
@@ -304,14 +196,13 @@ def register_page() -> None:
document_service = DocumentService(session_factory=session_factory) document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents") render_navigation_header(current_path="/documents")
try: parsed_doc_id = _parse_uuid(document_id)
parsed_document_id = UUID(document_id) if parsed_doc_id is None:
except ValueError:
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4") ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
return return
try: try:
document = await document_service.read_document_detail(document_id=parsed_document_id) document = await document_service.read_document_detail(document_id=parsed_doc_id)
except DocumentError: except DocumentError:
ui.label("Document not found").classes("text-h6 text-red-800 p-4") ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return return
@@ -323,16 +214,8 @@ def register_page() -> None:
with section_header_row(): with section_header_row():
page_header(f"Jobs for {document.name}") page_header(f"Jobs for {document.name}")
with ui.row().classes("gap-2"): with ui.row().classes("gap-2"):
ui.button( ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props("flat")
"Back to Document", ui.button("Create Job", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="add").classes("ui-btn-primary")
on_click=lambda: ui.navigate.to(f"/documents/{document.id}"),
icon="arrow_back",
).props("flat")
ui.button(
"Create Job",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="add",
).classes("ui-btn-primary")
if not document.jobs: if not document.jobs:
with archival_card(extra_classes="p-6 text-center"): with archival_card(extra_classes="p-6 text-center"):
@@ -345,11 +228,7 @@ def register_page() -> None:
with ui.row().classes("items-center gap-2"): with ui.row().classes("items-center gap-2"):
archival_badge(job.status.value) archival_badge(job.status.value)
ui.label(f"Job ID: {job.id}").classes("text-xs font-mono ui-text-primary") ui.label(f"Job ID: {job.id}").classes("text-xs font-mono ui-text-primary")
ui.button( ui.button("Open Job", on_click=lambda _=None, jid=job.id: ui.navigate.to(f"/jobs/{jid}"), icon="open_in_new").props("flat dense").classes("text-xs ui-link-primary")
"Open Job",
on_click=lambda _=None, job_id=job.id: ui.navigate.to(f"/jobs/{job_id}"),
icon="open_in_new",
).props("flat dense").classes("text-xs ui-link-primary")
@ui.page("/documents/{document_id}/sources") @ui.page("/documents/{document_id}/sources")
async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse: async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse:
@@ -362,14 +241,13 @@ def register_page() -> None:
document_service = DocumentService(session_factory=session_factory) document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents") render_navigation_header(current_path="/documents")
try: parsed_doc_id = _parse_uuid(document_id)
parsed_document_id = UUID(document_id) if parsed_doc_id is None:
except ValueError:
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4") ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
return return
try: try:
document = await document_service.read_document_detail(document_id=parsed_document_id) document = await document_service.read_document_detail(document_id=parsed_doc_id)
except DocumentError: except DocumentError:
ui.label("Document not found").classes("text-h6 text-red-800 p-4") ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return return
@@ -380,69 +258,13 @@ def register_page() -> None:
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"): with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
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.")
with archival_card(extra_classes="gap-3"):
name_input = ui.input(label="Document name", value=document.name).props("outlined bg-white").classes("w-full")
document_type_input = (
ui.input(label="Document type", value=document.document_type or "")
.props("outlined bg-white")
.classes("w-full")
)
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
date_input = ui.input(
label="Exact date (YYYY-MM-DD)",
value=document.document_date.isoformat() if document.document_date else "",
).props('outlined bg-white type="date"')
date_raw_input = (
ui.input(label="Approximate date", value=document.document_date_raw or "")
.props("outlined bg-white")
)
location_input = (
ui.input(label="Document location", value=document.location_created or "")
.props("outlined bg-white")
.classes("w-full")
)
archive_input = (
ui.input(label="Archive identifier", value=document.archive_identifier or "")
.props("outlined bg-white")
.classes("w-full")
)
notes_input = (
ui.textarea(label="Notes", value=document.notes or "").props("outlined bg-white autogrow").classes("w-full")
)
people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold()) people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold())
author_options = ( existing_author = next((link for link in document.document_people if link.role == DocumentPersonRole.AUTHOR), None)
{"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"} form = _render_document_form_fields(document=document, people=people, existing_author_id=existing_author.person_id if existing_author else None)
| {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 ""
def on_author_change(event) -> None:
selected = str(event.value or "").strip()
if selected == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
author_select = (
ui.select(
author_options,
label="Author (Person)",
value=author_value,
on_change=on_author_change,
)
.props("outlined bg-white")
.classes("w-full")
)
ui.link("Create new person", "/people/new").classes("text-xs ui-link-primary font-medium")
async def submit_edit() -> None: async def submit_edit() -> None:
candidate_name = (name_input.value or "").strip() candidate_name = (form["name"].value or "").strip()
candidate_type = (document_type_input.value or "").strip() candidate_type = (form["type"].value or "").strip()
if not candidate_name: if not candidate_name:
ui.notify("Document name is required.", type="warning") ui.notify("Document name is required.", type="warning")
return return
@@ -450,12 +272,8 @@ def register_page() -> None:
ui.notify("Document type is required.", type="warning") ui.notify("Document type is required.", type="warning")
return return
parsed_date: date | None = None parsed_date = _parse_iso_date(form["date"].value)
candidate_date_text = (date_input.value or "").strip() if form["date"].value and parsed_date is None:
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") ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
return return
@@ -464,10 +282,10 @@ def register_page() -> None:
name=candidate_name, name=candidate_name,
document_type=candidate_type, document_type=candidate_type,
document_date=parsed_date, document_date=parsed_date,
document_date_raw=(date_raw_input.value or "").strip() or None, document_date_raw=(form["date_raw"].value or "").strip() or None,
location_created=(location_input.value or "").strip() or None, location_created=(form["location"].value or "").strip() or None,
notes=(notes_input.value or "").strip() or None, notes=(form["notes"].value or "").strip() or None,
archive_identifier=(archive_input.value or "").strip() or None, archive_identifier=(form["archive"].value or "").strip() or None,
created_at=document.created_at, created_at=document.created_at,
updated_at=document.updated_at, updated_at=document.updated_at,
) )
@@ -478,13 +296,12 @@ def register_page() -> None:
show_error(exc, title="Save failed", operation="documents.edit.save") show_error(exc, title="Save failed", operation="documents.edit.save")
return return
selected_author = (author_select.value or "").strip() selected_author = (form["author"].value or "").strip()
if selected_author == CREATE_NEW_PERSON_OPTION: if selected_author == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new") ui.navigate.to("/people/new")
return return
existing_author_links = [
link for link in document.document_people if link.role == DocumentPersonRole.AUTHOR existing_author_links = [link for link in document.document_people if link.role == DocumentPersonRole.AUTHOR]
]
try: try:
if not selected_author: if not selected_author:
for link in existing_author_links: for link in existing_author_links:
@@ -495,11 +312,7 @@ def register_page() -> None:
for link in existing_author_links: for link in existing_author_links:
await document_service.delete_document_person(link) await document_service.delete_document_person(link)
await document_service.create_document_person( await document_service.create_document_person(
DocumentPerson( DocumentPerson(document_id=document.id, person_id=selected_author_id, role=DocumentPersonRole.AUTHOR)
document_id=document.id,
person_id=selected_author_id,
role=DocumentPersonRole.AUTHOR,
)
) )
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
show_error(exc, title="Author update failed", operation="documents.edit.link_author") show_error(exc, title="Author update failed", operation="documents.edit.link_author")
@@ -510,9 +323,7 @@ def register_page() -> None:
with ui.row().classes("w-full items-center gap-2 mt-2"): with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save changes", on_click=submit_edit, icon="save").classes("ui-btn-primary") ui.button("Save changes", on_click=submit_edit, icon="save").classes("ui-btn-primary")
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props( ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props("flat")
"flat"
)
@ui.page("/documents/{document_id}/delete") @ui.page("/documents/{document_id}/delete")
async def document_delete_page(document_id: str, session_factory: SessionFactoryDep) -> None: async def document_delete_page(document_id: str, session_factory: SessionFactoryDep) -> None:
@@ -520,14 +331,13 @@ def register_page() -> None:
document_service = DocumentService(session_factory=session_factory) document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents") render_navigation_header(current_path="/documents")
try: parsed_doc_id = _parse_uuid(document_id)
parsed_document_id = UUID(document_id) if parsed_doc_id is None:
except ValueError:
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4") ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
return return
try: try:
document = await document_service.read_document_detail(document_id=parsed_document_id) document = await document_service.read_document_detail(document_id=parsed_doc_id)
except DocumentError: except DocumentError:
ui.label("Document not found").classes("text-h6 text-red-800 p-4") ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return return
@@ -541,28 +351,15 @@ def register_page() -> None:
with archival_card(extra_classes="gap-2"): with archival_card(extra_classes="gap-2"):
ui.label(f"Document: {document.name}").classes("text-sm font-semibold ui-text-primary") ui.label(f"Document: {document.name}").classes("text-sm font-semibold ui-text-primary")
has_sources = bool(document.sources) if document.sources or document.jobs:
has_jobs = bool(document.jobs)
if has_sources or has_jobs:
ui.label("Delete is blocked because related records exist.").classes("text-xs text-red-800 font-bold mt-2") ui.label("Delete is blocked because related records exist.").classes("text-xs text-red-800 font-bold mt-2")
categories: list[str] = [] deps = [cat for cat, present in [("Sources", bool(document.sources)), ("Jobs", bool(document.jobs))] if present]
if has_sources: ui.label(f"Dependencies present: {', '.join(deps)}").classes("text-xs ui-text-muted")
categories.append("Sources")
if has_jobs:
categories.append("Jobs")
ui.label(f"Dependencies present: {', '.join(categories)}").classes("text-xs ui-text-muted")
ui.label("Remove related records first, then retry deletion.").classes("text-xs ui-text-muted italic") ui.label("Remove related records first, then retry deletion.").classes("text-xs ui-text-muted italic")
with ui.row().classes("w-full items-center gap-2 mt-4"): with ui.row().classes("w-full items-center gap-2 mt-4"):
ui.button( ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").classes("ui-btn-primary text-xs")
"Back to Document", ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props("flat text-xs")
on_click=lambda: ui.navigate.to(f"/documents/{document.id}"),
icon="arrow_back",
).classes("ui-btn-primary text-xs")
ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
"flat text-xs"
)
return return
ui.label("This action permanently deletes the document.").classes("text-xs text-red-800 font-medium") ui.label("This action permanently deletes the document.").classes("text-xs text-red-800 font-medium")
@@ -589,12 +386,111 @@ def register_page() -> None:
ui.navigate.to("/documents") ui.navigate.to("/documents")
with ui.row().classes("w-full items-center gap-2 mt-2"): with ui.row().classes("w-full items-center gap-2 mt-2"):
destructive_button( destructive_button("Delete document permanently", on_click=submit_delete, icon="delete_forever", variant="solid")
"Delete document permanently", ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props("flat")
on_click=submit_delete,
icon="delete_forever",
variant="solid", # --- Helper Sub-Components ---
)
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props(
"flat" def _render_document_form_fields(
) *, document: Document | None = None, people: list[Any], existing_author_id: UUID | None = None
) -> dict[str, Any]:
with archival_card(extra_classes="gap-3"):
name_input = ui.input(label="Document name", value=document.name if document else "").props("outlined bg-white").classes("w-full")
type_input = ui.input(label="Document type", value=document.document_type if document and document.document_type else "").props("outlined bg-white").classes("w-full")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
date_input = ui.input(
label="Exact date (YYYY-MM-DD)",
value=document.document_date.isoformat() if document and document.document_date else "",
).props('outlined bg-white type="date"')
date_raw_input = ui.input(label="Approximate date", value=document.document_date_raw if document and document.document_date_raw else "").props("outlined bg-white")
location_input = ui.input(label="Document location", value=document.location_created if document and document.location_created else "").props("outlined bg-white").classes("w-full")
archive_input = ui.input(label="Archive identifier", value=document.archive_identifier if document and document.archive_identifier else "").props("outlined bg-white").classes("w-full")
notes_input = ui.textarea(label="Notes", value=document.notes if document and document.notes else "").props("outlined bg-white autogrow").classes("w-full")
author_options = {"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"} | {str(p.id): p.full_name for p in people}
author_select = ui.select(author_options, label="Author (Person)", value=str(existing_author_id) if existing_author_id else "").props("outlined bg-white").classes("w-full")
return {
"name": name_input,
"type": type_input,
"date": date_input,
"date_raw": date_raw_input,
"location": location_input,
"archive": archive_input,
"notes": notes_input,
"author": author_select,
}
def _render_bento_viewer_zone(document: Document) -> None:
with ui.column().classes("col-span-12 lg:col-span-5"):
source_path = document.sources[0].file_path if document.sources else None
dark_room_viewer(source_path, count_label=f"{len(document.sources)} Source(s) Linked")
with ui.row().classes("w-full justify-between items-center mt-2"):
ui.button("View All Sources", on_click=lambda: ui.navigate.to(f"/sources?document_id={document.id}"), icon="description").props("flat dense text-xs").classes("ui-link-primary")
ui.button("+ Add Source", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="add").classes("ui-btn-primary text-xs")
def _render_bento_metadata_zone(document: Document) -> None:
author_link = next((item for item in document.document_people if item.role == DocumentPersonRole.AUTHOR and item.person is not None), None)
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Archival Metadata"):
metadata_row("Author:", author_link.person.full_name if author_link and author_link.person else "Not set")
metadata_row("Exact Date:", document.document_date.isoformat() if document.document_date else "Not set")
metadata_row("Approx. Date:", document.document_date_raw or "Not set")
metadata_row("Location Created:", document.location_created or "Not set")
metadata_row("Archive Identifier:", document.archive_identifier or "Not set")
with ui.column().classes("w-full mt-2"):
ui.label("Archival Notes:").classes("ui-text-muted text-xs mb-1")
ui.label(document.notes or "No notes added.").classes("p-2 ui-note-box text-xs")
with archival_card(title="System Logistics"):
ui.label(f"Created: {document.created_at.isoformat()}").classes("text-[11px] ui-text-muted")
ui.label(f"Updated: {document.updated_at.isoformat()}").classes("text-[11px] ui-text-muted")
def _render_bento_relations_zone(document: Document) -> None:
with ui.column().classes("col-span-12 lg:col-span-3 gap-4"):
with archival_card(title="Related People"):
if not document.document_people:
render_empty_state("No linked people yet.", italic=True)
else:
with ui.column().classes("w-full gap-2"):
for link in document.document_people:
person_label = link.person.full_name if link.person is not None else "Unknown person"
with ui.row().classes("w-full justify-between items-center ui-row-surface p-2"):
ui.label(person_label).classes("text-xs font-semibold ui-text-primary")
archival_badge(link.role.value)
with archival_card(title="Pipeline Jobs"):
with ui.row().classes("w-full justify-between items-center mb-2"):
ui.label(f"{len(document.jobs)} Active Jobs").classes("text-xs ui-link-primary font-bold")
with ui.row().classes("w-full gap-2 mt-2"):
ui.button("View Jobs", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"), icon="work_history").props("flat dense text-xs").classes("ui-link-primary")
ui.button("+ Add Job", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="add").classes("ui-btn-primary text-xs")
def _parse_uuid(value: str | None) -> UUID | None:
if not value:
return None
try:
return UUID(value)
except ValueError:
return None
def _parse_iso_date(value: str | None) -> date | None:
candidate = (value or "").strip()
if not candidate:
return None
try:
return date.fromisoformat(candidate)
except ValueError:
return None
+179 -172
View File
@@ -8,30 +8,30 @@ from uuid import UUID
from fastapi import Request from fastapi import Request
from nicegui import ui from nicegui import ui
from transcription.db.models import JobSourceStatus from transcription.db.models import Job, JobSourceStatus, JobStatus
from transcription.db.models import JobStatus
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
from transcription.services.jobs import JobDeleteBlockedError from transcription.services.jobs import (
from transcription.services.jobs import JobCancelBlockedError JobCancelBlockedError,
from transcription.services.jobs import JobResubmitBlockedError JobDeleteBlockedError,
from transcription.services.jobs import JobService JobResubmitBlockedError,
JobService,
)
from transcription.services.store import create_job_for_document from transcription.services.store import create_job_for_document
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, 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.primitives import destructive_button from transcription.ui.components.primitives import (
from transcription.ui.components.primitives import render_empty_state destructive_button,
from transcription.ui.components.primitives import section_header_row render_empty_state,
from transcription.ui.components.table.jobs import render_jobs_table section_header_row,
from transcription.ui.theme import apply_archival_theme )
from transcription.ui.theme import page_header from transcription.ui.components.table.jobs import JobTableRow, render_jobs_table
from transcription.ui.theme import apply_archival_theme, page_header
from transcription.worker import resolve_worker_notifier from transcription.worker import resolve_worker_notifier
from ...db.session import SessionFactoryDep from ...db.session import SessionFactoryDep
from ..components.table.jobs import JobTableRow
def register_page() -> None: # noqa: PLR0915 def register_page() -> None: # noqa: PLR0915
@@ -80,27 +80,15 @@ def register_page() -> None: # noqa: PLR0915
documents = await documents_service.list_documents() documents = await documents_service.list_documents()
if not documents: if not documents:
with archival_card(extra_classes="p-6 text-center"): _render_no_documents_card()
render_empty_state(
"No documents available. Create a Document before creating a Job.",
extra_classes="text-red-800 font-medium mb-4",
)
with ui.row().classes("justify-center gap-2"):
ui.button(
"Create document",
on_click=lambda: ui.navigate.to("/documents/new?return_to=jobs_new"),
icon="note_add",
).classes("ui-btn-primary")
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
return return
uploaded_files: list[tuple[str, bytes]] = [] uploaded_files: list[tuple[str, bytes]] = []
with archival_card(extra_classes="gap-3"): with archival_card(extra_classes="gap-3"):
document_options = {str(document.id): document.name for document in documents} document_options = {str(doc.id): doc.name for doc in documents}
document_select = ( document_select = ui.select(document_options, label="Target Document").props("outlined bg-white").classes("w-full")
ui.select(document_options, label="Target Document").props("outlined bg-white").classes("w-full")
)
requested_document_id = request.query_params.get("document_id") requested_document_id = request.query_params.get("document_id")
if requested_document_id in document_options: if requested_document_id in document_options:
document_select.value = requested_document_id document_select.value = requested_document_id
@@ -110,65 +98,10 @@ def register_page() -> None: # noqa: PLR0915
model_input = ui.input(label="Model").props("outlined bg-white") model_input = ui.input(label="Model").props("outlined bg-white")
prompt_input = ui.input(label="Prompt").props("outlined bg-white") prompt_input = ui.input(label="Prompt").props("outlined bg-white")
with archival_card(title="Source Files"): _render_upload_section(uploaded_files)
ui.label(
"Files are processed alphabetically by original filename. Use leading numbers such as 001, 002, 003 to control order."
).classes("text-xs ui-text-muted mb-2")
@ui.refreshable
def render_upload_list() -> None:
if not uploaded_files:
render_empty_state("No files uploaded yet.", italic=True)
return
def remove_file(index: int) -> None:
if 0 <= index < len(uploaded_files):
removed_name, _ = uploaded_files.pop(index)
ui.notify(f"Removed {removed_name}", type="info")
render_upload_list.refresh()
def clear_files() -> None:
uploaded_files.clear()
ui.notify("Cleared queued files", type="info")
render_upload_list.refresh()
ordered_uploads = sorted(
enumerate(uploaded_files),
key=lambda item: Path(item[1][0]).name.casefold(),
)
with ui.column().classes("gap-1 w-full mt-2"):
for index, (filename, _) in ordered_uploads:
with ui.row().classes(
"w-full items-center justify-between ui-row-surface p-2"
):
ui.label(Path(filename).name).classes("text-xs font-mono ui-text-primary")
ui.button(icon="delete", on_click=lambda idx=index: remove_file(idx)).props(
"flat round dense color=negative text-xs"
)
with ui.row().classes("w-full justify-end mt-2"):
ui.button("Clear files", on_click=clear_files, icon="clear_all").props("flat dense").classes(
"text-xs text-red-800"
)
async def on_upload(event) -> None:
payload = await event.file.read()
uploaded_files.append((event.file.name, payload))
ui.notify(f"Added {event.file.name}", type="positive")
render_upload_list.refresh()
ui.upload(
on_upload=on_upload,
auto_upload=True,
label="Select source files or a folder",
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf" webkitdirectory directory multiple').classes("w-full")
render_upload_list()
async def submit_create() -> None: async def submit_create() -> None:
selected_document = document_select.value if not document_select.value:
if not selected_document:
ui.notify("Document is required.", type="warning") ui.notify("Document is required.", type="warning")
return return
if not uploaded_files: if not uploaded_files:
@@ -176,7 +109,7 @@ def register_page() -> None: # noqa: PLR0915
return return
try: try:
document_id = UUID(str(selected_document)) document_id = UUID(str(document_select.value))
except ValueError: except ValueError:
ui.notify("Selected document id is invalid.", type="warning") ui.notify("Selected document id is invalid.", type="warning")
return return
@@ -200,9 +133,7 @@ def register_page() -> None: # noqa: PLR0915
ui.navigate.to(f"/jobs/{result.job_id}") ui.navigate.to(f"/jobs/{result.job_id}")
with ui.row().classes("w-full items-center gap-2 mt-2"): with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Submit for transcription", on_click=submit_create, icon="play_arrow").classes( ui.button("Submit for transcription", on_click=submit_create, icon="play_arrow").classes("ui-btn-primary")
"ui-btn-primary"
)
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat") ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
@ui.page("/jobs/{job_id}") @ui.page("/jobs/{job_id}")
@@ -211,9 +142,8 @@ def register_page() -> None: # noqa: PLR0915
jobs_service = JobService(session_factory=session_factory) jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs") render_navigation_header(current_path="/jobs")
try: parsed_job_id = _parse_uuid(job_id)
parsed_job_id = UUID(job_id) if parsed_job_id is None:
except ValueError:
ui.label("Invalid job id").classes("text-h6 text-red-800 p-4") ui.label("Invalid job id").classes("text-h6 text-red-800 p-4")
return return
@@ -224,52 +154,11 @@ def register_page() -> None: # noqa: PLR0915
return return
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"): with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
with section_header_row(classes="justify-between items-center"): _render_job_detail_header(job)
page_header(f"Job Record: {job.id}")
with ui.row().classes("items-center gap-2"):
archival_badge(job.status.value.upper())
if job.status in {JobStatus.QUEUED, JobStatus.PROCESSING}:
destructive_button(
"Cancel",
on_click=lambda: ui.navigate.to(f"/jobs/{job.id}/cancel"),
icon="stop_circle",
extra_classes="text-xs",
)
if job.status != JobStatus.TRANSCRIBED:
ui.button("Resubmit", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}/resubmit"), icon="replay").props(
"outlined"
).classes("text-xs")
destructive_button(
"Delete Job",
on_click=lambda: ui.navigate.to(f"/jobs/{job.id}/delete"),
icon="delete",
extra_classes="text-xs",
)
with ui.grid().classes("w-full grid-cols-1 md:grid-cols-2 gap-4"): with ui.grid().classes("w-full grid-cols-1 md:grid-cols-2 gap-4"):
with archival_card(title="Execution Logistics"): _render_job_logistics(job)
metadata_row("Provider:", job.provider or "pending") _render_job_document_links(job)
metadata_row("Model:", job.model or "pending")
metadata_row("Prompt:", job.prompt_name or "pending")
metadata_row("Retry Count:", str(job.retry_count))
metadata_row("Last Updated:", job.date_updated.isoformat())
with archival_card(title="Document Links"):
ui.label("Navigate to related archival records:").classes("text-xs ui-text-muted mb-3")
with ui.column().classes("w-full gap-2"):
ui.button(
"View Linked Document",
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}"),
icon="description",
).classes("ui-btn-primary text-xs w-full")
ui.button(
"View Linked Sources",
on_click=lambda: ui.navigate.to(f"/sources?job_id={job.id}"),
icon="description",
).props("flat text-xs").classes("ui-link-primary w-full")
@ui.page("/jobs/{job_id}/cancel") @ui.page("/jobs/{job_id}/cancel")
async def job_cancel_page(job_id: str, request: Request, session_factory: SessionFactoryDep) -> None: async def job_cancel_page(job_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
@@ -277,9 +166,8 @@ def register_page() -> None: # noqa: PLR0915
jobs_service = JobService(session_factory=session_factory) jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs") render_navigation_header(current_path="/jobs")
try: parsed_job_id = _parse_uuid(job_id)
parsed_job_id = UUID(job_id) if parsed_job_id is None:
except ValueError:
ui.label("Invalid job id").classes("text-h6 text-red-800 p-4") ui.label("Invalid job id").classes("text-h6 text-red-800 p-4")
return return
@@ -318,12 +206,7 @@ def register_page() -> None: # noqa: PLR0915
ui.navigate.to(f"/jobs/{job.id}") ui.navigate.to(f"/jobs/{job.id}")
with ui.row().classes("w-full items-center gap-2 mt-2"): with ui.row().classes("w-full items-center gap-2 mt-2"):
destructive_button( destructive_button("Cancel job", on_click=submit_cancel, icon="stop_circle", variant="solid")
"Cancel job",
on_click=submit_cancel,
icon="stop_circle",
variant="solid",
)
ui.button("Back to Job", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat") ui.button("Back to Job", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat")
@ui.page("/jobs/{job_id}/resubmit") @ui.page("/jobs/{job_id}/resubmit")
@@ -332,9 +215,8 @@ def register_page() -> None: # noqa: PLR0915
jobs_service = JobService(session_factory=session_factory) jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs") render_navigation_header(current_path="/jobs")
try: parsed_job_id = _parse_uuid(job_id)
parsed_job_id = UUID(job_id) if parsed_job_id is None:
except ValueError:
ui.label("Invalid job id").classes("text-h6 text-red-800 p-4") ui.label("Invalid job id").classes("text-h6 text-red-800 p-4")
return return
@@ -344,7 +226,7 @@ def register_page() -> None: # noqa: PLR0915
ui.label("Job not found").classes("text-h6 text-red-800 p-4") ui.label("Job not found").classes("text-h6 text-red-800 p-4")
return return
non_transcribed_count = sum(1 for job_source in job.job_sources if job_source.status != JobSourceStatus.TRANSCRIBED) non_transcribed_count = sum(1 for js in job.job_sources if js.status != JobSourceStatus.TRANSCRIBED)
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"): with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
page_header("Resubmit Job") page_header("Resubmit Job")
@@ -353,9 +235,9 @@ def register_page() -> None: # noqa: PLR0915
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary") ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary")
metadata_row("Current Status:", job.status.value) metadata_row("Current Status:", job.status.value)
metadata_row("Non-Transcribed Sources:", str(non_transcribed_count)) metadata_row("Non-Transcribed Sources:", str(non_transcribed_count))
ui.label("Resubmit queues all non-transcribed linked sources. New results overwrite prior page-level results.").classes( ui.label(
"text-xs ui-text-muted" "Resubmit queues all non-transcribed linked sources. New results overwrite prior page-level results."
) ).classes("text-xs ui-text-muted")
async def submit_resubmit() -> None: async def submit_resubmit() -> None:
try: try:
@@ -385,9 +267,8 @@ def register_page() -> None: # noqa: PLR0915
jobs_service = JobService(session_factory=session_factory) jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs") render_navigation_header(current_path="/jobs")
try: parsed_job_id = _parse_uuid(job_id)
parsed_job_id = UUID(job_id) if parsed_job_id is None:
except ValueError:
ui.label("Invalid job id").classes("text-h6 text-red-800 p-4") ui.label("Invalid job id").classes("text-h6 text-red-800 p-4")
return return
@@ -404,16 +285,12 @@ def register_page() -> None: # noqa: PLR0915
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary") ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary")
if job.status == JobStatus.PROCESSING: if job.status == JobStatus.PROCESSING:
ui.label("Delete is blocked while the job is processing.").classes( ui.label("Delete is blocked while the job is processing.").classes("text-xs text-red-800 font-bold mt-2")
"text-xs text-red-800 font-bold mt-2"
)
ui.label("Wait for processing to complete, then retry delete.").classes("text-xs ui-text-muted italic") ui.label("Wait for processing to complete, then retry delete.").classes("text-xs ui-text-muted italic")
with ui.row().classes("w-full items-center gap-2 mt-4"): with ui.row().classes("w-full items-center gap-2 mt-4"):
ui.button( ui.button("Back to Job", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").classes(
"Back to Job", "ui-btn-primary text-xs"
on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), )
icon="arrow_back",
).classes("ui-btn-primary text-xs")
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props( ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
"flat text-xs" "flat text-xs"
) )
@@ -441,10 +318,140 @@ def register_page() -> None: # noqa: PLR0915
ui.navigate.to("/jobs") ui.navigate.to("/jobs")
with ui.row().classes("w-full items-center gap-2 mt-2"): with ui.row().classes("w-full items-center gap-2 mt-2"):
destructive_button( destructive_button("Delete job permanently", on_click=submit_delete, icon="delete_forever", variant="solid")
"Delete job permanently",
on_click=submit_delete,
icon="delete_forever",
variant="solid",
)
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat") ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat")
# --- Helper Sub-Components ---
def _render_no_documents_card() -> None:
with archival_card(extra_classes="p-6 text-center"):
render_empty_state(
"No documents available. Create a Document before creating a Job.",
extra_classes="text-red-800 font-medium mb-4",
)
with ui.row().classes("justify-center gap-2"):
ui.button(
"Create document",
on_click=lambda: ui.navigate.to("/documents/new?return_to=jobs_new"),
icon="note_add",
).classes("ui-btn-primary")
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
def _render_upload_section(uploaded_files: list[tuple[str, bytes]]) -> None:
with archival_card(title="Source Files"):
ui.label(
"Files are processed alphabetically by original filename. Use leading numbers such as 001, 002, 003 to control order."
).classes("text-xs ui-text-muted mb-2")
@ui.refreshable
def render_upload_list() -> None:
if not uploaded_files:
render_empty_state("No files uploaded yet.", italic=True)
return
def remove_file(index: int) -> None:
if 0 <= index < len(uploaded_files):
removed_name, _ = uploaded_files.pop(index)
ui.notify(f"Removed {removed_name}", type="info")
render_upload_list.refresh()
def clear_files() -> None:
uploaded_files.clear()
ui.notify("Cleared queued files", type="info")
render_upload_list.refresh()
ordered_uploads = sorted(
enumerate(uploaded_files),
key=lambda item: Path(item[1][0]).name.casefold(),
)
with ui.column().classes("gap-1 w-full mt-2"):
for index, (filename, _) in ordered_uploads:
with ui.row().classes("w-full items-center justify-between ui-row-surface p-2"):
ui.label(Path(filename).name).classes("text-xs font-mono ui-text-primary")
ui.button(icon="delete", on_click=lambda idx=index: remove_file(idx)).props(
"flat round dense color=negative text-xs"
)
with ui.row().classes("w-full justify-end mt-2"):
ui.button("Clear files", on_click=clear_files, icon="clear_all").props("flat dense").classes(
"text-xs text-red-800"
)
async def on_upload(event) -> None:
payload = await event.file.read()
uploaded_files.append((event.file.name, payload))
ui.notify(f"Added {event.file.name}", type="positive")
render_upload_list.refresh()
ui.upload(
on_upload=on_upload,
auto_upload=True,
label="Select source files or a folder",
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf" webkitdirectory directory multiple').classes("w-full")
render_upload_list()
def _render_job_detail_header(job: Job) -> None:
with section_header_row(classes="justify-between items-center"):
page_header(f"Job Record: {job.id}")
with ui.row().classes("items-center gap-2"):
archival_badge(job.status.value.upper())
if job.status in {JobStatus.QUEUED, JobStatus.PROCESSING}:
destructive_button(
"Cancel",
on_click=lambda: ui.navigate.to(f"/jobs/{job.id}/cancel"),
icon="stop_circle",
extra_classes="text-xs",
)
if job.status != JobStatus.TRANSCRIBED:
ui.button("Resubmit", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}/resubmit"), icon="replay").props(
"outlined"
).classes("text-xs")
destructive_button(
"Delete Job",
on_click=lambda: ui.navigate.to(f"/jobs/{job.id}/delete"),
icon="delete",
extra_classes="text-xs",
)
def _render_job_logistics(job: Job) -> None:
with archival_card(title="Execution Logistics"):
metadata_row("Provider:", job.provider or "pending")
metadata_row("Model:", job.model or "pending")
metadata_row("Prompt:", job.prompt_name or "pending")
metadata_row("Retry Count:", str(job.retry_count))
metadata_row("Last Updated:", job.date_updated.isoformat())
def _render_job_document_links(job: Job) -> None:
with archival_card(title="Document Links"):
ui.label("Navigate to related archival records:").classes("text-xs ui-text-muted mb-3")
with ui.column().classes("w-full gap-2"):
ui.button(
"View Linked Document",
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}"),
icon="description",
).classes("ui-btn-primary text-xs w-full")
ui.button(
"View Linked Sources",
on_click=lambda: ui.navigate.to(f"/sources?job_id={job.id}"),
icon="description",
).props("flat text-xs").classes("ui-link-primary w-full")
def _parse_uuid(value: str | None) -> UUID | None:
if not value:
return None
try:
return UUID(value)
except ValueError:
return None
+230 -234
View File
@@ -4,8 +4,7 @@ from __future__ import annotations
from datetime import date from datetime import date
from urllib.parse import quote from urllib.parse import quote
from uuid import UUID from uuid import UUID, uuid4
from uuid import uuid4
from fastapi import Request from fastapi import Request
from nicegui import ui from nicegui import ui
@@ -13,94 +12,24 @@ from nicegui import ui
from transcription.config import Settings, get_settings from transcription.config import Settings, get_settings
from transcription.db.models import Person from transcription.db.models import Person
from transcription.errors import ErrorCategory from transcription.errors import ErrorCategory
from transcription.services.documents import ( from transcription.services.documents import DocumentError, DocumentService
DocumentError,
DocumentService,
)
from transcription.services.store import UploadError, store_person_portrait from transcription.services.store import UploadError, store_person_portrait
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 metadata_row from transcription.ui.components.data_display import archival_badge, metadata_row
from transcription.ui.components.error_presenter import show_error from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.primitives import destructive_button from transcription.ui.components.primitives import (
from transcription.ui.components.primitives import render_empty_state destructive_button,
from transcription.ui.components.primitives import section_header_row render_empty_state,
section_header_row,
)
from transcription.ui.components.table.people import PersonTableRow, render_people_table from transcription.ui.components.table.people import PersonTableRow, render_people_table
from transcription.ui.components.viewers import dark_room_viewer from transcription.ui.components.viewers import dark_room_viewer
from transcription.ui.theme import apply_archival_theme from transcription.ui.theme import apply_archival_theme, page_header
from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep from ...db.session import SessionFactoryDep
def _parse_optional_date(value: str | None, *, label: str) -> date | None:
candidate = (value or "").strip()
if not candidate:
return None
try:
return date.fromisoformat(candidate)
except ValueError as exc:
raise ValueError(f"{label} must use YYYY-MM-DD.") from exc
def _bind_portrait_file_picker(portrait_path_input: ui.input, *, settings: Settings, person_id: UUID) -> None:
async def on_portrait_selected(event) -> None:
payload = await event.file.read()
try:
stored_path = store_person_portrait(
person_id=person_id,
filename=event.file.name,
file_bytes=payload,
settings=settings,
)
except UploadError as exc:
ui.notify(str(exc), type="negative")
return
except Exception: # noqa: BLE001
ui.notify("Unable to store portrait image.", type="negative")
return
try:
relative_path = stored_path.resolve().relative_to(settings.upload_dir.resolve()).as_posix()
except ValueError:
relative_path = stored_path.name
portrait_path_input.value = relative_path
ui.notify("Portrait uploaded.", type="positive")
ui.upload(
on_upload=on_portrait_selected,
auto_upload=True,
label="Choose portrait file",
).props('accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"').classes("w-full")
portrait_dir = settings.upload_dir / "persons" / str(person_id)
ui.label(f"Portraits are stored under {portrait_dir}.").classes("text-xs ui-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 _resolve_runtime_settings(request: Request) -> Settings:
app_settings = getattr(request.app.state, "settings", None)
if isinstance(app_settings, Settings):
return app_settings
return get_settings()
def register_page() -> None: # noqa: PLR0915 def register_page() -> None: # noqa: PLR0915
"""Register people list and CRUD routes.""" """Register people list and CRUD routes."""
@@ -129,7 +58,6 @@ def register_page() -> None: # noqa: PLR0915
show_error(exc, title="Load failed", operation="people.list") show_error(exc, title="Load failed", operation="people.list")
return return
# Format person records into read-model rows for the table renderer
rows = [ rows = [
PersonTableRow( PersonTableRow(
id=person.id, id=person.id,
@@ -152,56 +80,33 @@ def register_page() -> None: # noqa: PLR0915
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"): with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
page_header("Create Person Record", subtitle="Full name is required.") page_header("Create Person Record", subtitle="Full name is required.")
with archival_card(extra_classes="gap-3"): form = _render_person_form_fields(
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"): request=request,
full_name_input = ui.input(label="Full name").props("outlined bg-white")
display_name_input = ui.input(label="Display name").props("outlined bg-white")
maiden_name_input = ui.input(label="Maiden name").props("outlined bg-white")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
birth_date_input = ui.input(label="Birth date (YYYY-MM-DD)").props('outlined bg-white type="date"')
birth_date_raw_input = ui.input(label="Birth date (approximate)").props("outlined bg-white")
birth_place_input = ui.input(label="Birth place").props("outlined bg-white")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
death_date_input = ui.input(label="Death date (YYYY-MM-DD)").props('outlined bg-white type="date"')
death_date_raw_input = ui.input(label="Death date (approximate)").props("outlined bg-white")
death_place_input = ui.input(label="Death place").props("outlined bg-white")
biography_input = ui.textarea(label="Biography").props("outlined bg-white autogrow").classes("w-full")
portrait_path_input = ui.input(label="Portrait path").props("outlined bg-white").classes("w-full")
_bind_portrait_file_picker(
portrait_path_input,
settings=_resolve_runtime_settings(request),
person_id=draft_person_id, person_id=draft_person_id,
) )
async def submit_create() -> None: async def submit_create() -> None:
full_name = (full_name_input.value or "").strip() full_name = (form["full_name"].value or "").strip()
if not full_name: if not full_name:
ui.notify("Full name is required.", type="warning") ui.notify("Full name is required.", type="warning")
return return
try: birth_date = _parse_iso_date(form["birth_date"].value)
birth_date = _parse_optional_date(birth_date_input.value, label="Birth date") death_date = _parse_iso_date(form["death_date"].value)
death_date = _parse_optional_date(death_date_input.value, label="Death date")
except ValueError as exc:
ui.notify(str(exc), type="warning")
return
candidate = Person( candidate = Person(
id=draft_person_id, id=draft_person_id,
full_name=full_name, full_name=full_name,
display_name=(display_name_input.value or "").strip() or None, display_name=(form["display_name"].value or "").strip() or None,
maiden_name=(maiden_name_input.value or "").strip() or None, maiden_name=(form["maiden_name"].value or "").strip() or None,
birth_date=birth_date, birth_date=birth_date,
birth_date_raw=(birth_date_raw_input.value or "").strip() or None, birth_date_raw=(form["birth_date_raw"].value or "").strip() or None,
birth_place=(birth_place_input.value or "").strip() or None, birth_place=(form["birth_place"].value or "").strip() or None,
death_date=death_date, death_date=death_date,
death_date_raw=(death_date_raw_input.value or "").strip() or None, death_date_raw=(form["death_date_raw"].value or "").strip() or None,
death_place=(death_place_input.value or "").strip() or None, death_place=(form["death_place"].value or "").strip() or None,
biography=(biography_input.value or "").strip() or None, biography=(form["biography"].value or "").strip() or None,
portrait_path=(portrait_path_input.value or "").strip() or None, portrait_path=(form["portrait_path"].value or "").strip() or None,
) )
try: try:
@@ -223,9 +128,8 @@ def register_page() -> None: # noqa: PLR0915
people_service = DocumentService(session_factory=session_factory) people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people") render_navigation_header(current_path="/people")
try: parsed_person_id = _parse_uuid(person_id)
parsed_person_id = UUID(person_id) if parsed_person_id is None:
except ValueError:
ui.label("Invalid person id").classes("text-h6 text-red-800 p-4") ui.label("Invalid person id").classes("text-h6 text-red-800 p-4")
return return
@@ -238,8 +142,6 @@ def register_page() -> None: # noqa: PLR0915
show_error(exc, title="Load failed", operation="people.read") show_error(exc, title="Load failed", operation="people.read")
return return
portrait_src = _resolve_portrait_src(person.portrait_path)
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"): with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
with section_header_row(): with section_header_row():
page_header(person.full_name, subtitle=f"Person ID: {person.id}") page_header(person.full_name, subtitle=f"Person ID: {person.id}")
@@ -258,54 +160,9 @@ def register_page() -> None: # noqa: PLR0915
) )
with ui.grid().classes("w-full grid-cols-12 gap-4"): with ui.grid().classes("w-full grid-cols-12 gap-4"):
with ui.column().classes("col-span-12 lg:col-span-4"): _render_person_portrait_zone(person)
dark_room_viewer(portrait_src, count_label="Portrait Media") _render_person_biographical_zone(person)
_render_person_biography_zone(person)
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Biographical Record"):
metadata_row("Full Name:", person.full_name)
metadata_row("Display Name:", person.display_name or "Not set")
metadata_row("Maiden Name:", person.maiden_name or "Not set")
metadata_row("Birth Date:", person.birth_date.isoformat() if person.birth_date else "Not set")
metadata_row("Approx. Birth Date:", person.birth_date_raw or "Not set")
metadata_row("Birth Place:", person.birth_place or "Not set")
metadata_row("Death Date:", person.death_date.isoformat() if person.death_date else "Not set")
metadata_row("Approx. Death Date:", person.death_date_raw or "Not set")
metadata_row("Death Place:", person.death_place or "Not set")
with archival_card(title="System Logistics"):
ui.label(f"Created: {person.created_at.isoformat()}").classes("text-[11px] ui-text-muted")
ui.label(f"Updated: {person.updated_at.isoformat()}").classes("text-[11px] ui-text-muted")
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Biography"):
ui.label(person.biography or "No biography recorded.").classes(
"p-2 ui-note-box text-xs w-full"
)
with archival_card(title="Linked Documents"):
if not person.document_people:
render_empty_state("No linked documents yet.", italic=True)
render_empty_state("Link this person from a Document workflow.")
else:
with ui.column().classes("w-full gap-2"):
for link in person.document_people:
document = link.document
if document is None:
continue
with ui.row().classes(
"w-full justify-between items-center ui-row-surface p-2"
):
with ui.column().classes("gap-0"):
ui.label(document.name).classes("text-xs font-semibold ui-text-primary")
ui.label(f"Role: {link.role.value}").classes("text-[10px] ui-text-muted")
ui.button(
"Open",
on_click=lambda _=None, doc_id=document.id: ui.navigate.to(
f"/documents/{doc_id}"
),
icon="open_in_new",
).props("flat dense text-xs").classes("ui-link-primary")
@ui.page("/people/{person_id}/edit") @ui.page("/people/{person_id}/edit")
async def person_edit_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None: async def person_edit_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
@@ -313,9 +170,8 @@ def register_page() -> None: # noqa: PLR0915
people_service = DocumentService(session_factory=session_factory) people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people") render_navigation_header(current_path="/people")
try: parsed_person_id = _parse_uuid(person_id)
parsed_person_id = UUID(person_id) if parsed_person_id is None:
except ValueError:
ui.label("Invalid person id").classes("text-h6 text-red-800 p-4") ui.label("Invalid person id").classes("text-h6 text-red-800 p-4")
return return
@@ -331,72 +187,34 @@ def register_page() -> None: # noqa: PLR0915
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"): with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
page_header("Edit Person Record", subtitle="Full name is required.") page_header("Edit Person Record", subtitle="Full name is required.")
with archival_card(extra_classes="gap-3"): form = _render_person_form_fields(
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"): request=request,
full_name_input = ui.input(label="Full name", value=person.full_name).props("outlined bg-white") person=person,
display_name_input = ui.input(label="Display name", value=person.display_name or "").props(
"outlined bg-white"
)
maiden_name_input = ui.input(label="Maiden name", value=person.maiden_name or "").props("outlined bg-white")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
birth_date_input = ui.input(
label="Birth date (YYYY-MM-DD)",
value=person.birth_date.isoformat() if person.birth_date else "",
).props('outlined bg-white type="date"')
birth_date_raw_input = ui.input(
label="Birth date (approximate)", value=person.birth_date_raw or ""
).props("outlined bg-white")
birth_place_input = ui.input(label="Birth place", value=person.birth_place or "").props("outlined bg-white")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
death_date_input = ui.input(
label="Death date (YYYY-MM-DD)",
value=person.death_date.isoformat() if person.death_date else "",
).props('outlined bg-white type="date"')
death_date_raw_input = ui.input(
label="Death date (approximate)", value=person.death_date_raw or ""
).props("outlined bg-white")
death_place_input = ui.input(label="Death place", value=person.death_place or "").props("outlined bg-white")
biography_input = (
ui.textarea(label="Biography", value=person.biography or "").props("outlined bg-white autogrow").classes("w-full")
)
portrait_path_input = (
ui.input(label="Portrait path", value=person.portrait_path or "").props("outlined bg-white").classes("w-full")
)
_bind_portrait_file_picker(
portrait_path_input,
settings=_resolve_runtime_settings(request),
person_id=person.id, person_id=person.id,
) )
async def submit_edit() -> None: async def submit_edit() -> None:
full_name = (full_name_input.value or "").strip() full_name = (form["full_name"].value or "").strip()
if not full_name: if not full_name:
ui.notify("Full name is required.", type="warning") ui.notify("Full name is required.", type="warning")
return return
try: birth_date = _parse_iso_date(form["birth_date"].value)
birth_date = _parse_optional_date(birth_date_input.value, label="Birth date") death_date = _parse_iso_date(form["death_date"].value)
death_date = _parse_optional_date(death_date_input.value, label="Death date")
except ValueError as exc:
ui.notify(str(exc), type="warning")
return
candidate = Person( candidate = Person(
id=person.id, id=person.id,
full_name=full_name, full_name=full_name,
display_name=(display_name_input.value or "").strip() or None, display_name=(form["display_name"].value or "").strip() or None,
maiden_name=(maiden_name_input.value or "").strip() or None, maiden_name=(form["maiden_name"].value or "").strip() or None,
birth_date=birth_date, birth_date=birth_date,
birth_date_raw=(birth_date_raw_input.value or "").strip() or None, birth_date_raw=(form["birth_date_raw"].value or "").strip() or None,
birth_place=(birth_place_input.value or "").strip() or None, birth_place=(form["birth_place"].value or "").strip() or None,
death_date=death_date, death_date=death_date,
death_date_raw=(death_date_raw_input.value or "").strip() or None, death_date_raw=(form["death_date_raw"].value or "").strip() or None,
death_place=(death_place_input.value or "").strip() or None, death_place=(form["death_place"].value or "").strip() or None,
biography=(biography_input.value or "").strip() or None, biography=(form["biography"].value or "").strip() or None,
portrait_path=(portrait_path_input.value or "").strip() or None, portrait_path=(form["portrait_path"].value or "").strip() or None,
metadata_=person.metadata_, metadata_=person.metadata_,
created_at=person.created_at, created_at=person.created_at,
updated_at=person.updated_at, updated_at=person.updated_at,
@@ -421,9 +239,8 @@ def register_page() -> None: # noqa: PLR0915
people_service = DocumentService(session_factory=session_factory) people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people") render_navigation_header(current_path="/people")
try: parsed_person_id = _parse_uuid(person_id)
parsed_person_id = UUID(person_id) if parsed_person_id is None:
except ValueError:
ui.label("Invalid person id").classes("text-h6 text-red-800 p-4") ui.label("Invalid person id").classes("text-h6 text-red-800 p-4")
return return
@@ -467,10 +284,189 @@ def register_page() -> None: # noqa: PLR0915
ui.navigate.to("/people") ui.navigate.to("/people")
with ui.row().classes("w-full items-center gap-2 mt-2"): with ui.row().classes("w-full items-center gap-2 mt-2"):
destructive_button( destructive_button("Delete person permanently", on_click=submit_delete, icon="delete_forever", variant="solid")
"Delete person permanently",
on_click=submit_delete,
icon="delete_forever",
variant="solid",
)
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props("flat") ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props("flat")
# --- Helper Sub-Components & Form Builders ---
def _render_person_form_fields(
*,
request: Request,
person: Person | None = None,
person_id: UUID,
) -> dict[str, Any]:
with archival_card(extra_classes="gap-3"):
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
full_name_input = ui.input(label="Full name", value=person.full_name if person else "").props("outlined bg-white")
display_name_input = ui.input(label="Display name", value=person.display_name if person and person.display_name else "").props("outlined bg-white")
maiden_name_input = ui.input(label="Maiden name", value=person.maiden_name if person and person.maiden_name else "").props("outlined bg-white")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
birth_date_input = ui.input(
label="Birth date (YYYY-MM-DD)",
value=person.birth_date.isoformat() if person and person.birth_date else "",
).props('outlined bg-white type="date"')
birth_date_raw_input = ui.input(label="Birth date (approximate)", value=person.birth_date_raw if person and person.birth_date_raw else "").props("outlined bg-white")
birth_place_input = ui.input(label="Birth place", value=person.birth_place if person and person.birth_place else "").props("outlined bg-white")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
death_date_input = ui.input(
label="Death date (YYYY-MM-DD)",
value=person.death_date.isoformat() if person and person.death_date else "",
).props('outlined bg-white type="date"')
death_date_raw_input = ui.input(label="Death date (approximate)", value=person.death_date_raw if person and person.death_date_raw else "").props("outlined bg-white")
death_place_input = ui.input(label="Death place", value=person.death_place if person and person.death_place else "").props("outlined bg-white")
biography_input = ui.textarea(label="Biography", value=person.biography if person and person.biography else "").props("outlined bg-white autogrow").classes("w-full")
portrait_path_input = ui.input(label="Portrait path", value=person.portrait_path if person and person.portrait_path else "").props("outlined bg-white").classes("w-full")
_bind_portrait_file_picker(
portrait_path_input,
settings=_resolve_runtime_settings(request),
person_id=person_id,
)
return {
"full_name": full_name_input,
"display_name": display_name_input,
"maiden_name": maiden_name_input,
"birth_date": birth_date_input,
"birth_date_raw": birth_date_raw_input,
"birth_place": birth_place_input,
"death_date": death_date_input,
"death_date_raw": death_date_raw_input,
"death_place": death_place_input,
"biography": biography_input,
"portrait_path": portrait_path_input,
}
def _render_person_portrait_zone(person: Person) -> None:
portrait_src = _resolve_portrait_src(person.portrait_path)
with ui.column().classes("col-span-12 lg:col-span-4"):
dark_room_viewer(portrait_src, count_label="Portrait Media")
def _render_person_biographical_zone(person: Person) -> None:
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Biographical Record"):
metadata_row("Full Name:", person.full_name)
metadata_row("Display Name:", person.display_name or "Not set")
metadata_row("Maiden Name:", person.maiden_name or "Not set")
metadata_row("Birth Date:", person.birth_date.isoformat() if person.birth_date else "Not set")
metadata_row("Approx. Birth Date:", person.birth_date_raw or "Not set")
metadata_row("Birth Place:", person.birth_place or "Not set")
metadata_row("Death Date:", person.death_date.isoformat() if person.death_date else "Not set")
metadata_row("Approx. Death Date:", person.death_date_raw or "Not set")
metadata_row("Death Place:", person.death_place or "Not set")
with archival_card(title="System Logistics"):
ui.label(f"Created: {person.created_at.isoformat()}").classes("text-[11px] ui-text-muted")
ui.label(f"Updated: {person.updated_at.isoformat()}").classes("text-[11px] ui-text-muted")
def _render_person_biography_zone(person: Person) -> None:
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Biography"):
ui.label(person.biography or "No biography recorded.").classes("p-2 ui-note-box text-xs w-full")
with archival_card(title="Linked Documents"):
if not person.document_people:
render_empty_state("No linked documents yet.", italic=True)
render_empty_state("Link this person from a Document workflow.")
else:
with ui.column().classes("w-full gap-2"):
for link in person.document_people:
doc = link.document
if doc is None:
continue
with ui.row().classes("w-full justify-between items-center ui-row-surface p-2"):
with ui.column().classes("gap-0"):
ui.label(doc.name).classes("text-xs font-semibold ui-text-primary")
ui.label(f"Role: {link.role.value}").classes("text-[10px] ui-text-muted")
ui.button(
"Open",
on_click=lambda _=None, doc_id=doc.id: ui.navigate.to(f"/documents/{doc_id}"),
icon="open_in_new",
).props("flat dense text-xs").classes("ui-link-primary")
# --- Utilities & Input Binding Helpers ---
def _bind_portrait_file_picker(portrait_path_input: ui.input, *, settings: Settings, person_id: UUID) -> None:
async def on_portrait_selected(event) -> None:
payload = await event.file.read()
try:
stored_path = store_person_portrait(
person_id=person_id,
filename=event.file.name,
file_bytes=payload,
settings=settings,
)
except UploadError as exc:
ui.notify(str(exc), type="negative")
return
except Exception: # noqa: BLE001
ui.notify("Unable to store portrait image.", type="negative")
return
try:
relative_path = stored_path.resolve().relative_to(settings.upload_dir.resolve()).as_posix()
except ValueError:
relative_path = stored_path.name
portrait_path_input.value = relative_path
ui.notify("Portrait uploaded.", type="positive")
ui.upload(
on_upload=on_portrait_selected,
auto_upload=True,
label="Choose portrait file",
).props('accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"').classes("w-full")
portrait_dir = settings.upload_dir / "persons" / str(person_id)
ui.label(f"Portraits are stored under {portrait_dir}.").classes("text-xs ui-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://", "https://", "data:")):
return normalized
if normalized.startswith("/"):
return normalized
if lowered.startswith("uploads/"):
return f"/{normalized}"
return f"/uploads/{quote(normalized)}"
def _resolve_runtime_settings(request: Request) -> Settings:
app_settings = getattr(request.app.state, "settings", None)
if isinstance(app_settings, Settings):
return app_settings
return get_settings()
def _parse_uuid(value: str | None) -> UUID | None:
if not value:
return None
try:
return UUID(value)
except ValueError:
return None
def _parse_iso_date(value: str | None) -> date | None:
candidate = (value or "").strip()
if not candidate:
return None
try:
return date.fromisoformat(candidate)
except ValueError:
return None
+28 -37
View File
@@ -2,39 +2,35 @@
from __future__ import annotations from __future__ import annotations
import asyncio from collections.abc import AsyncGenerator, Callable
from collections.abc import Callable from datetime import UTC, datetime
from collections.abc import Generator
from datetime import UTC
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any
from uuid import UUID from uuid import UUID
import pytest import pytest
import pytest_asyncio
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlmodel import delete from sqlmodel import delete
from transcription.app import create_app from transcription.app import create_app
from transcription.config import Settings from transcription.config import Settings, SqliteSettings
from transcription.config import SqliteSettings from transcription.db import create_all, initialize_database_runtime, session_scope
from transcription.db import create_all from transcription.db.models import (
from transcription.db import initialize_database_runtime Document,
from transcription.db import session_scope DocumentPerson,
from transcription.db.models import Document Job,
from transcription.db.models import DocumentPerson JobSource,
from transcription.db.models import Job JobSourceStatus,
from transcription.db.models import JobSource JobStatus,
from transcription.db.models import JobSourceStatus Person,
from transcription.db.models import JobStatus Source,
from transcription.db.models import Person )
from transcription.db.models import Source
RevisionSeed = str
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def app_client(tmp_path_factory: pytest.TempPathFactory) -> Generator[tuple[FastAPI, TestClient]]: def app_client(tmp_path_factory: pytest.TempPathFactory) -> AsyncGenerator[tuple[FastAPI, TestClient], None]:
"""Provide a real application and test client backed by in-memory SQLite.""" """Provide a real application and test client backed by in-memory SQLite."""
tmp_path = tmp_path_factory.mktemp("ui") tmp_path = tmp_path_factory.mktemp("ui")
settings = Settings( settings = Settings(
@@ -48,17 +44,17 @@ def app_client(tmp_path_factory: pytest.TempPathFactory) -> Generator[tuple[Fast
app = create_app() app = create_app()
app.state.runtime = initialize_database_runtime(settings=settings) app.state.runtime = initialize_database_runtime(settings=settings)
import asyncio
asyncio.run(create_all(engine=app.state.runtime.engine)) asyncio.run(create_all(engine=app.state.runtime.engine))
with TestClient(app) as client: with TestClient(app) as client:
yield app, client yield app, client
@pytest.fixture(autouse=True) @pytest_asyncio.fixture(autouse=True)
def clear_ui_database(app_client: tuple[FastAPI, TestClient]) -> None: async def clear_ui_database(app_client: tuple[FastAPI, TestClient]) -> None:
"""Reset UI-facing tables before each test for isolation.""" """Reset UI-facing tables asynchronously before each test for isolation."""
app, _ = app_client
async def _clear() -> None:
async with session_scope() as session: async with session_scope() as session:
await session.exec(delete(JobSource)) await session.exec(delete(JobSource))
await session.exec(delete(DocumentPerson)) await session.exec(delete(DocumentPerson))
@@ -68,25 +64,22 @@ def clear_ui_database(app_client: tuple[FastAPI, TestClient]) -> None:
await session.exec(delete(Person)) await session.exec(delete(Person))
await session.commit() await session.commit()
asyncio.run(_clear())
@pytest_asyncio.fixture
@pytest.fixture async def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., AsyncGenerator[UUID, None]]:
def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]: """Return an async factory helper for seeding a Document -> Job -> Source tuple."""
"""Return a helper for inserting a document/job/source/(optional revision) tuple."""
app, _ = app_client app, _ = app_client
fixtures_dir = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid" fixtures_dir = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid"
def _seed( async def _seed(
*, *,
filename: str = "sample.pdf", filename: str = "sample.pdf",
status: JobStatus = JobStatus.TRANSCRIBED, status: JobStatus = JobStatus.TRANSCRIBED,
transcription_text: str | None = "Sample transcript text", transcription_text: str | None = "Sample transcript text",
error_detail: str | None = None, error_detail: str | None = None,
revision_text: RevisionSeed | None = None, revision_text: str | None = None,
source_file: Path | None = None, source_file: Path | None = None,
) -> UUID: ) -> UUID:
async def _insert() -> UUID:
async with session_scope() as session: async with session_scope() as session:
stored_path = app.state.settings.upload_dir / filename stored_path = app.state.settings.upload_dir / filename
stored_path.parent.mkdir(parents=True, exist_ok=True) stored_path.parent.mkdir(parents=True, exist_ok=True)
@@ -136,6 +129,4 @@ def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
await session.commit() await session.commit()
return job.id return job.id
return asyncio.run(_insert())
return _seed return _seed
+179
View File
@@ -0,0 +1,179 @@
"""Action handler tests for Document CRUD mutations."""
from datetime import date
from uuid import uuid4
import pytest
from sqlmodel import select
from transcription.db import session_scope
from transcription.db.models import Document, DocumentPerson, DocumentPersonRole, Job, Person, Source
@pytest.mark.integration
class TestDocumentActionHandlers:
"""Verify POST/mutation routes for Document creation, updates, and deletions."""
@pytest.mark.asyncio
async def test_create_document_success(self, app_client):
_, client = app_client
payload = {
"name": "New Historical Journal",
"document_type": "journal",
"document_date": "1924-05-15",
"document_date_raw": "May 1924",
"location_created": "San Francisco, CA",
"archive_identifier": "HJ-1924-01",
"notes": "Belonged to Hig.",
}
response = client.post("/ui/documents/new", data=payload, follow_redirects=True)
assert response.status_code == 200
assert "New Historical Journal" in response.text
async with session_scope() as session:
doc = (
await session.exec(select(Document).where(Document.name == "New Historical Journal"))
).first()
assert doc is not None
assert doc.document_type == "journal"
assert doc.document_date == date(1924, 5, 15)
assert doc.archive_identifier == "HJ-1924-01"
@pytest.mark.asyncio
async def test_create_document_with_author_link(self, app_client):
_, client = app_client
async with session_scope() as session:
person = Person(full_name="John Isbill")
session.add(person)
await session.commit()
person_id = str(person.id)
payload = {
"name": "Isbill Letter",
"document_type": "letter",
"author_id": person_id,
}
response = client.post("/ui/documents/new", data=payload, follow_redirects=True)
assert response.status_code == 200
assert "Isbill Letter" in response.text
async with session_scope() as session:
doc = (
await session.exec(select(Document).where(Document.name == "Isbill Letter"))
).first()
assert doc is not None
link = (
await session.exec(
select(DocumentPerson).where(
DocumentPerson.document_id == doc.id,
DocumentPerson.role == DocumentPersonRole.AUTHOR,
)
)
).first()
assert link is not None
assert str(link.person_id) == person_id
@pytest.mark.asyncio
async def test_update_document_details_and_author(self, app_client):
_, client = app_client
async with session_scope() as session:
author1 = Person(full_name="Original Author")
author2 = Person(full_name="New Author")
doc = Document(name="Original Title", document_type="letter")
session.add_all([author1, author2, doc])
await session.flush()
session.add(
DocumentPerson(
document_id=doc.id,
person_id=author1.id,
role=DocumentPersonRole.AUTHOR,
)
)
await session.commit()
doc_id = str(doc.id)
new_author_id = str(author2.id)
update_payload = {
"name": "Updated Title",
"document_type": "journal_entry",
"author_id": new_author_id,
}
response = client.post(f"/ui/documents/{doc_id}/edit", data=update_payload, follow_redirects=True)
assert response.status_code == 200
assert "Updated Title" in response.text
async with session_scope() as session:
updated_doc = await session.get(Document, doc_id)
assert updated_doc is not None
assert updated_doc.name == "Updated Title"
assert updated_doc.document_type == "journal_entry"
link = (
await session.exec(
select(DocumentPerson).where(
DocumentPerson.document_id == updated_doc.id,
DocumentPerson.role == DocumentPersonRole.AUTHOR,
)
)
).first()
assert link is not None
assert str(link.person_id) == new_author_id
@pytest.mark.asyncio
async def test_delete_unlinked_document_success(self, app_client):
_, client = app_client
async with session_scope() as session:
doc = Document(name="Temporary Doc", document_type="note")
session.add(doc)
await session.commit()
doc_id = str(doc.id)
response = client.post(f"/ui/documents/{doc_id}/delete", follow_redirects=True)
assert response.status_code == 200
assert "Document deleted" in response.text or "Archival Documents" in response.text
async with session_scope() as session:
deleted_doc = await session.get(Document, doc_id)
assert deleted_doc is None
@pytest.mark.asyncio
async def test_delete_document_blocked_when_dependencies_exist(self, app_client):
_, client = app_client
async with session_scope() as session:
doc = Document(name="Protected Doc", document_type="letter")
session.add(doc)
await session.flush()
source = Source(
document_id=doc.id,
page_number=1,
upload_name="page_001.png",
filename="page_001.png",
file_path="/tmp/page_001.png",
)
session.add(source)
await session.commit()
doc_id = str(doc.id)
response = client.post(f"/ui/documents/{doc_id}/delete", follow_redirects=True)
assert response.status_code == 200
assert "Delete is blocked because related records exist." in response.text
async with session_scope() as session:
doc_still_exists = await session.get(Document, doc_id)
assert doc_still_exists is not None
+111 -301
View File
@@ -1,366 +1,176 @@
"""Tests for the documents page routes.""" """Tests for the documents page routes and action handlers."""
import asyncio
from datetime import UTC
from datetime import date
from datetime import datetime
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, DocumentPerson, DocumentPersonRole, Job, Person, Source
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import Job # --- Helper Fixtures ---
from transcription.db.models import Person
from transcription.db.models import Source
@pytest.fixture
async def seed_person_and_document():
"""Seed a Person and Document linked by DocumentPerson role."""
async with session_scope() as session:
person = Person(full_name="Zenna Cochran")
session.add(person)
await session.flush()
doc = Document(
name="Letter from Hig",
document_type="letter",
archive_identifier="ZC-1924-001",
)
session.add(doc)
await session.flush()
link = DocumentPerson(
document_id=doc.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
)
session.add(link)
await session.commit()
return str(doc.id), str(person.id)
# --- Integration Tests for Documents Route Handlers ---
@pytest.mark.integration @pytest.mark.integration
class TestDocumentsPageRendering: class TestDocumentsPageRendering:
"""Verify document list/detail routes render expected read states.""" """Verify document list, detail, edit, and deletion route behaviors."""
def test_documents_page_renders_empty_state(self, app_client): def test_documents_page_renders_empty_state(self, app_client):
"""GET /ui/documents renders empty-state text when no records exist."""
_, client = app_client _, client = app_client
response = client.get("/ui/documents") response = client.get("/ui/documents")
assert response.status_code == 200 assert response.status_code == 200
assert "Documents" in response.text assert "Archival Documents" in response.text
assert "Create new document" in response.text
assert "No documents in repository yet." in response.text assert "No documents in repository yet." in response.text
def test_document_create_page_renders_fields(self, app_client): @pytest.mark.asyncio
"""GET /ui/documents/new renders document-create form fields.""" async def test_documents_page_lists_seeded_documents(self, app_client):
_, client = app_client
async with session_scope() as session:
doc = Document(name="1924 Postcard", document_type="postcard", archive_identifier="PC-001")
session.add(doc)
await session.commit()
response = client.get("/ui/documents")
assert response.status_code == 200
assert "1924 Postcard" in response.text
assert "postcard" in response.text
assert "PC-001" in response.text
def test_document_create_page_renders_form(self, app_client):
_, client = app_client _, client = app_client
response = client.get("/ui/documents/new") response = client.get("/ui/documents/new")
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 is required." in response.text
assert "Document name" in response.text assert "Document name" in response.text
assert "Document type" in response.text
assert "Author (Person)" 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 "Create new item" in response.text
assert "Create new person" in response.text
assert "Save document" in response.text
def test_documents_page_lists_seeded_documents(self, app_client): @pytest.mark.asyncio
"""GET /ui/documents lists seeded document cards.""" async def test_document_detail_page_renders_bento_grid_and_metadata(
self, app_client, seed_person_and_document
):
_, client = app_client _, client = app_client
doc_id, _ = seed_person_and_document
async def _seed_document() -> None: response = client.get(f"/ui/documents/{doc_id}")
async with session_scope() as session:
session.add(Document(name="Seeded Document", document_type="letter"))
await session.commit()
asyncio.run(_seed_document())
response = client.get("/ui/documents")
assert response.status_code == 200 assert response.status_code == 200
assert "Seeded Document" in response.text assert "Letter from Hig" in response.text
assert "letter" in response.text assert "ZC-1924-001" in response.text
assert "Zenna Cochran" in response.text
def test_document_detail_page_renders_metadata_and_empty_related_sections(self, app_client): assert "Archival Metadata" in response.text
"""GET /ui/documents/{document_id} shows metadata and related empty states."""
_, client = app_client
async def _seed_document() -> str:
async with session_scope() as session:
document = Document(
name="Zenna Letter",
document_type="letter",
document_date=date(1885, 7, 13),
document_date_raw="c. 1885",
location_created="Ohio",
notes="Family archive",
archive_identifier="BOX-1-FOLDER-2",
)
session.add(document)
await session.commit()
await session.refresh(document)
return str(document.id)
document_id = asyncio.run(_seed_document())
response = client.get(f"/ui/documents/{document_id}")
assert response.status_code == 200
assert "Zenna Letter" in response.text
assert "Type: letter" in response.text
assert "Author:" in response.text
assert "Not set" in response.text
assert "Exact Date:" in response.text
assert "1885-07-13" in response.text
assert "Approx. Date:" in response.text
assert "c. 1885" in response.text
assert "Location Created:" in response.text
assert "Ohio" in response.text
assert "Archive Identifier:" in response.text
assert "BOX-1-FOLDER-2" in response.text
assert "Archival Notes:" in response.text
assert "Family archive" in response.text
assert "Created:" in response.text
assert "Updated:" in response.text
assert "No linked people yet." in response.text
assert "0 Source(s) Linked" in response.text
assert "0 Active Jobs" in response.text
assert "+ Add Source" in response.text
assert "+ Add Job" in response.text
assert "Sources" in response.text
assert "Jobs" in response.text
assert "Edit Document" in response.text assert "Edit Document" in response.text
assert "Delete" in response.text
def test_document_detail_page_renders_related_people_sources_and_jobs(self, app_client): @pytest.mark.asyncio
"""GET /ui/documents/{document_id} shows related records when present.""" async def test_document_jobs_page_renders_job_links(self, app_client):
_, client = app_client _, client = app_client
async def _seed_related() -> str:
async with session_scope() as session: async with session_scope() as session:
document = Document(name="Roster", document_type="record") doc = Document(name="Doc With Job", document_type="letter")
person = Person(full_name="Jane Doe") session.add(doc)
session.add(document)
session.add(person)
await session.flush() await session.flush()
session.add( job = Job(document_id=doc.id)
DocumentPerson( session.add(job)
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
)
)
session.add(
Source(
document_id=document.id,
page_number=1,
upload_name="001_page.png",
filename="stored_001_page.png",
file_path="/tmp/stored_001_page.png",
)
)
session.add(
Job(
document_id=document.id,
)
)
await session.commit() await session.commit()
await session.refresh(document) doc_id = str(doc.id)
return str(document.id) job_id = str(job.id)
document_id = asyncio.run(_seed_related()) response = client.get(f"/ui/documents/{doc_id}/jobs")
response = client.get(f"/ui/documents/{document_id}")
assert response.status_code == 200 assert response.status_code == 200
assert "Jane Doe" in response.text assert "Jobs for Doc With Job" in response.text
assert "author" in response.text assert f"Job ID: {job_id}" in response.text
assert "Author:" in response.text
assert "1 Source(s) Linked" in response.text
assert "1 Active Jobs" in response.text
def test_document_jobs_page_filters_to_document_context(self, app_client): @pytest.mark.asyncio
async def test_document_edit_page_prefills_existing_values(
self, app_client, seed_person_and_document
):
_, client = app_client _, client = app_client
doc_id, _ = seed_person_and_document
async def _seed() -> str: response = client.get(f"/ui/documents/{doc_id}/edit")
async with session_scope() as session:
target = Document(name="Target", document_type="letter")
other = Document(name="Other", document_type="record")
session.add(target)
session.add(other)
await session.flush()
session.add(Job(document_id=target.id))
session.add(Job(document_id=other.id))
await session.commit()
await session.refresh(target)
return str(target.id)
document_id = asyncio.run(_seed())
response = client.get(f"/ui/documents/{document_id}/jobs")
assert response.status_code == 200
assert "Jobs for Target" in response.text
assert "Jobs for Other" not in response.text
def test_document_sources_page_filters_to_document_context(self, app_client):
_, client = app_client
async def _seed() -> str:
async with session_scope() as session:
target = Document(name="Target", document_type="letter")
other = Document(name="Other", document_type="record")
session.add(target)
session.add(other)
await session.flush()
session.add(
Source(
document_id=target.id,
page_number=1,
upload_name="target_page.png",
filename="target_stored.png",
file_path="/tmp/target_stored.png",
)
)
session.add(
Source(
document_id=other.id,
page_number=1,
upload_name="other_page.png",
filename="other_stored.png",
file_path="/tmp/other_stored.png",
)
)
await session.commit()
await session.refresh(target)
return str(target.id)
document_id = asyncio.run(_seed())
response = client.get(f"/ui/sources?document_id={document_id}")
assert response.status_code == 200
assert "Sources: Target" in response.text
assert "Back to Document" in response.text
assert "target_page.png" in response.text
assert "other_page.png" not in response.text
def test_document_detail_page_rejects_invalid_id(self, app_client):
"""GET /ui/documents/{document_id} shows validation feedback for malformed IDs."""
_, client = app_client
response = client.get("/ui/documents/not-a-uuid")
assert response.status_code == 200
assert "Invalid document id" in response.text
def test_document_detail_page_handles_missing_document(self, app_client):
"""GET /ui/documents/{document_id} shows not-found state for unknown IDs."""
_, client = app_client
response = client.get(f"/ui/documents/{uuid4()}")
assert response.status_code == 200
assert "Document not found" in response.text
def test_document_edit_page_renders_expected_fields(self, app_client):
"""GET /ui/documents/{document_id}/edit renders editable fields and save controls."""
_, client = app_client
async def _seed_document() -> str:
async with session_scope() as session:
document = Document(
name="Editable Document",
document_type="memo",
document_date_raw="c. 1900",
)
session.add(document)
await session.commit()
await session.refresh(document)
return str(document.id)
document_id = asyncio.run(_seed_document())
response = client.get(f"/ui/documents/{document_id}/edit")
assert response.status_code == 200 assert response.status_code == 200
assert "Edit Document Record" in response.text assert "Edit Document Record" in response.text
assert "Document name and document type are required." in response.text assert "Letter from Hig" in response.text
assert "Document name" in response.text assert "ZC-1924-001" 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 "Create new item" in response.text
assert "Create new person" in response.text
assert "Save changes" in response.text
def test_document_delete_page_shows_confirmation_when_unlinked(self, app_client): @pytest.mark.asyncio
"""GET /ui/documents/{document_id}/delete renders permanent-action confirmation if unlinked.""" async def test_document_delete_page_blocks_deletion_when_dependencies_exist(
self, app_client
):
_, client = app_client _, client = app_client
async def _seed_document() -> str:
async with session_scope() as session: async with session_scope() as session:
document = Document(name="Safe Delete", document_type="letter") doc = Document(name="Doc With Source", document_type="letter")
session.add(document) session.add(doc)
await session.flush()
source = Source(
document_id=doc.id,
page_number=1,
upload_name="page_1.png",
filename="page_1.png",
file_path="/tmp/page_1.png",
)
session.add(source)
await session.commit() await session.commit()
await session.refresh(document) doc_id = str(doc.id)
return str(document.id)
document_id = asyncio.run(_seed_document()) response = client.get(f"/ui/documents/{doc_id}/delete")
response = client.get(f"/ui/documents/{document_id}/delete")
assert response.status_code == 200 assert response.status_code == 200
assert "Delete Document" in response.text assert "Delete Document" in response.text
assert "This action permanently deletes the document." in response.text
assert "Delete document permanently" in response.text
def test_document_delete_page_shows_blocked_state_when_dependencies_exist(self, app_client):
"""GET /ui/documents/{document_id}/delete explains blocked deletion with dependency categories."""
_, client = app_client
async def _seed_related() -> str:
async with session_scope() as session:
document = Document(name="Blocked Delete", document_type="record")
session.add(document)
await session.flush()
session.add(
Source(
document_id=document.id,
page_number=1,
upload_name="001_page.png",
filename="stored_001_page.png",
file_path="/tmp/stored_001_page.png",
)
)
session.add(Job(document_id=document.id))
await session.commit()
await session.refresh(document)
return str(document.id)
document_id = asyncio.run(_seed_related())
response = client.get(f"/ui/documents/{document_id}/delete")
assert response.status_code == 200
assert "Delete is blocked because related records exist." in response.text assert "Delete is blocked because related records exist." in response.text
assert "Dependencies present: Sources, Jobs" in response.text assert "Dependencies present: Sources" in response.text
assert "Go to Jobs" in response.text
def test_job_create_page_preselects_document_query_param(self, app_client): @pytest.mark.asyncio
"""GET /ui/jobs/new?document_id=... includes the selected document in rendered state.""" async def test_document_delete_page_allows_unlinked_document_deletion(self, app_client):
_, client = app_client _, client = app_client
async def _seed_document() -> str:
async with session_scope() as session: async with session_scope() as session:
document = Document( doc = Document(name="Orphan Document", document_type="note")
name="Preselected Document", session.add(doc)
document_type="letter",
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add(document)
await session.commit() await session.commit()
await session.refresh(document) doc_id = str(doc.id)
return str(document.id)
document_id = asyncio.run(_seed_document()) response = client.get(f"/ui/documents/{doc_id}/delete")
response = client.get(f"/ui/jobs/new?document_id={document_id}")
assert response.status_code == 200 assert response.status_code == 200
assert "Preselected Document" in response.text assert "Delete Document" in response.text
assert "Delete document permanently" in response.text
assert "Delete is blocked" not in response.text
+133
View File
@@ -0,0 +1,133 @@
"""Action handler tests for Job CRUD mutations."""
from pathlib import Path
import pytest
from sqlmodel import select
from transcription.db import session_scope
from transcription.db.models import Document, Job, JobSource, JobSourceStatus, JobStatus, Source
@pytest.mark.integration
class TestJobsActionHandlers:
"""Verify POST/mutation routes for Job creation, status changes, and deletions."""
@pytest.mark.asyncio
async def test_create_job_success(self, app_client):
_, client = app_client
async with session_scope() as session:
doc = Document(name="Postcard Batch", document_type="postcard")
session.add(doc)
await session.commit()
doc_id = str(doc.id)
fixture_path = (
Path(__file__).resolve().parents[1]
/ "fixtures"
/ "images"
/ "valid"
/ "small_png.png"
)
with open(fixture_path, "rb") as file_bytes:
files = [("files", ("001_postcard.png", file_bytes, "image/png"))]
data = {
"document_id": doc_id,
"provider": "openai",
"model": "gpt-4o",
"prompt_name": "default_transcription",
}
response = client.post("/ui/jobs/new", data=data, files=files, follow_redirects=True)
assert response.status_code == 200
assert "Job Record:" in response.text or "Execution Logistics" in response.text
async with session_scope() as session:
job = (
await session.exec(select(Job).where(Job.document_id == doc_id))
).first()
assert job is not None
assert job.status == JobStatus.QUEUED
assert job.provider == "openai"
assert job.model == "gpt-4o"
@pytest.mark.asyncio
async def test_cancel_queued_job_success(self, app_client, seed_job):
_, client = app_client
job_id = seed_job(status=JobStatus.QUEUED, filename="queued-job.png")
response = client.post(f"/ui/jobs/{job_id}/cancel", follow_redirects=True)
assert response.status_code == 200
assert "Job cancelled" in response.text or "CANCELLED" in response.text or "FAILED" in response.text
async with session_scope() as session:
cancelled_job = await session.get(Job, job_id)
assert cancelled_job is not None
assert cancelled_job.status in {JobStatus.FAILED, JobStatus.COMPLETED}
@pytest.mark.asyncio
async def test_resubmit_failed_sources_success(self, app_client, seed_job):
_, client = app_client
job_id = seed_job(
filename="failed-page.png",
status=JobStatus.FAILED,
transcription_text=None,
error_detail="Provider API timeout",
)
response = client.post(f"/ui/jobs/{job_id}/resubmit", follow_redirects=True)
assert response.status_code == 200
assert "Resubmitted" in response.text or "QUEUED" in response.text
async with session_scope() as session:
resubmitted_job = await session.get(Job, job_id)
assert resubmitted_job is not None
assert resubmitted_job.status == JobStatus.QUEUED
job_source = (
await session.exec(select(JobSource).where(JobSource.job_id == job_id))
).first()
assert job_source is not None
assert job_source.status == JobSourceStatus.PENDING
@pytest.mark.asyncio
async def test_delete_queued_or_completed_job_success(self, app_client, seed_job):
_, client = app_client
job_id = seed_job(status=JobStatus.COMPLETED, filename="completed-job.png")
response = client.post(f"/ui/jobs/{job_id}/delete", follow_redirects=True)
assert response.status_code == 200
assert "Job deleted" in response.text or "Transcription Pipeline Jobs" in response.text
async with session_scope() as session:
deleted_job = await session.get(Job, job_id)
assert deleted_job is None
@pytest.mark.asyncio
async def test_delete_job_blocked_when_processing(self, app_client):
_, client = app_client
async with session_scope() as session:
doc = Document(name="Active Doc", document_type="letter")
session.add(doc)
await session.flush()
job = Job(document_id=doc.id, status=JobStatus.PROCESSING)
session.add(job)
await session.commit()
job_id = str(job.id)
response = client.post(f"/ui/jobs/{job_id}/delete", follow_redirects=True)
assert response.status_code == 200
assert "Delete is blocked while the job is processing." in response.text
async with session_scope() as session:
job_still_exists = await session.get(Job, job_id)
assert job_still_exists is not None
+104 -102
View File
@@ -1,30 +1,62 @@
"""Tests for the jobs page route.""" """Tests for the jobs page routes and action handlers."""
import asyncio
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, Job, JobSourceStatus, JobStatus
from transcription.db.models import JobStatus
# --- Helper Fixtures ---
@pytest.fixture
async def seed_document_with_unlinked_job():
"""Seed a document and a queued job for testing route actions."""
async with session_scope() as session:
document = Document(name="Test Archival Letter", document_type="letter")
session.add(document)
await session.flush()
job = Job(
document_id=document.id,
status=JobStatus.QUEUED,
provider="openai",
model="gpt-4o",
)
session.add(job)
await session.commit()
return str(document.id), str(job.id)
# --- Integration Tests for Jobs Route Handlers ---
@pytest.mark.integration @pytest.mark.integration
class TestPageRendering: class TestJobsPageRendering:
"""Verify jobs routes render correctly with real app wiring.""" """Verify jobs list, creation, detail, and lifecycle action routes."""
def test_jobs_page_renders_empty_state(self, app_client): def test_jobs_page_renders_empty_state(self, app_client):
"""GET /ui/jobs renders the page and empty-state text when no jobs exist."""
_, client = app_client _, client = app_client
response = client.get("/ui/jobs") response = client.get("/ui/jobs")
assert response.status_code == 200 assert response.status_code == 200
assert "Create job" in response.text assert "Transcription Pipeline Jobs" in response.text
assert "No active or historical processing jobs found." in response.text assert "No active or historical processing jobs found." in response.text
def test_job_create_page_requires_existing_documents(self, app_client): def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job):
"""GET /ui/jobs/new shows guidance when no Documents exist.""" _, client = app_client
job_id = seed_job(filename="seeded-document-page.png")
response = client.get("/ui/jobs")
assert response.status_code == 200
assert str(job_id) in response.text
assert "seeded-document-page.png" in response.text
@pytest.mark.asyncio
async def test_job_create_page_shows_empty_document_warning_when_no_docs(self, app_client):
_, client = app_client _, client = app_client
response = client.get("/ui/jobs/new") response = client.get("/ui/jobs/new")
@@ -32,132 +64,102 @@ class TestPageRendering:
assert response.status_code == 200 assert response.status_code == 200
assert "Create Processing Job" in response.text assert "Create Processing Job" in response.text
assert "No documents available. Create a Document before creating a 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): @pytest.mark.asyncio
"""GET /ui/jobs/new renders document choices when Documents exist.""" async def test_job_create_page_preselects_document_from_query_param(self, app_client):
_, client = app_client _, client = app_client
async def _seed_document() -> None:
async with session_scope() as session: async with session_scope() as session:
session.add(Document(name="Seeded Document")) doc = Document(name="Preselected Journal Entry", document_type="journal")
session.add(doc)
await session.commit() await session.commit()
doc_id = str(doc.id)
asyncio.run(_seed_document()) response = client.get(f"/ui/jobs/new?document_id={doc_id}")
response = client.get("/ui/jobs/new")
assert response.status_code == 200 assert response.status_code == 200
assert "Create Processing Job" in response.text assert "Create Processing Job" in response.text
assert "Seeded Document" in response.text assert "Preselected Journal Entry" in response.text
assert "Files are processed alphabetically by original filename." in response.text
assert "No files uploaded yet." 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): @pytest.mark.asyncio
"""GET /ui/jobs lists seeded jobs from the in-memory database.""" async def test_job_detail_page_renders_logistics_and_links(
self, app_client, seed_document_with_unlinked_job
):
_, client = app_client _, client = app_client
seed_job(filename="sample.pdf", status=JobStatus.TRANSCRIBED, transcription_text="done") _, job_id = seed_document_with_unlinked_job
response = client.get("/ui/jobs")
assert response.status_code == 200
assert "sample.pdf" in response.text
assert "transcribed" in response.text
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
job_id = seed_job(
filename="detail.pdf",
status=JobStatus.TRANSCRIBED,
transcription_text="original text",
)
response = client.get(f"/ui/jobs/{job_id}") response = client.get(f"/ui/jobs/{job_id}")
assert response.status_code == 200 assert response.status_code == 200
assert "Job" in response.text assert f"Job Record: {job_id}" in response.text
assert "Provider:" in response.text assert "Execution Logistics" in response.text
assert "Model:" in response.text assert "openai" in response.text
assert "Prompt:" in response.text assert "gpt-4o" in response.text
assert "Retry Count:" in response.text assert "View Linked Document" in response.text
assert "Last Updated:" in response.text assert "View Linked Sources" in response.text
assert "document links" in response.text.lower()
assert "Sources" in response.text
assert "Jobs" in response.text
assert "Delete Job" in response.text
def test_job_detail_page_rejects_invalid_id(self, app_client): @pytest.mark.asyncio
"""GET /ui/jobs/{job_id} shows validation feedback for malformed IDs.""" async def test_job_cancel_page_renders_confirmation(
self, app_client, seed_document_with_unlinked_job
):
_, client = app_client _, client = app_client
response = client.get("/ui/jobs/not-a-uuid") _, job_id = seed_document_with_unlinked_job
assert response.status_code == 200
assert "Invalid job id" in response.text
def test_job_detail_page_handles_missing_job(self, app_client):
"""GET /ui/jobs/{job_id} shows not-found state for unknown IDs."""
_, client = app_client
missing_id = uuid4()
response = client.get(f"/ui/jobs/{missing_id}")
assert response.status_code == 200
assert "Job not found" in response.text
def test_job_detail_page_shows_cancel_and_resubmit_when_queued(self, app_client, seed_job):
"""GET /ui/jobs/{job_id} exposes cancel/resubmit controls for queued jobs."""
_, client = app_client
job_id = seed_job(
filename="no-revision.pdf",
status=JobStatus.QUEUED,
transcription_text=None,
)
response = client.get(f"/ui/jobs/{job_id}")
assert response.status_code == 200
assert "Cancel" in response.text
assert "Resubmit" in response.text
assert "Delete Job" in response.text
def test_job_cancel_page_renders_confirmation(self, app_client, seed_job):
_, client = app_client
job_id = seed_job(filename="cancel-ready.pdf", status=JobStatus.PROCESSING)
response = client.get(f"/ui/jobs/{job_id}/cancel") response = client.get(f"/ui/jobs/{job_id}/cancel")
assert response.status_code == 200 assert response.status_code == 200
assert "Cancel Processing Job" in response.text assert "Cancel Processing Job" in response.text
assert "Cancel stops processing" in response.text
assert "Cancel job" in response.text assert "Cancel job" in response.text
def test_job_resubmit_page_renders_confirmation(self, app_client, seed_job): @pytest.mark.asyncio
async def test_job_resubmit_page_renders_counts(
self, app_client, seed_job
):
_, client = app_client _, client = app_client
job_id = seed_job(filename="resubmit-ready.pdf", status=JobStatus.FAILED, transcription_text=None) job_id = seed_job(
filename="failed-resubmit.png",
status=JobStatus.FAILED,
transcription_text=None,
error_detail="Timeout",
)
response = client.get(f"/ui/jobs/{job_id}/resubmit") response = client.get(f"/ui/jobs/{job_id}/resubmit")
assert response.status_code == 200 assert response.status_code == 200
assert "Resubmit Job" in response.text assert "Resubmit Job" in response.text
assert "Non-Transcribed Sources:" in response.text
assert "Resubmit now" in response.text assert "Resubmit now" in response.text
def test_job_delete_page_shows_confirmation_when_not_processing(self, app_client, seed_job): @pytest.mark.asyncio
async def test_job_delete_page_blocks_deletion_when_processing(self, app_client):
_, client = app_client _, client = app_client
job_id = seed_job(filename="delete-ready.pdf", status=JobStatus.TRANSCRIBED)
response = client.get(f"/ui/jobs/{job_id}/delete") async with session_scope() as session:
doc = Document(name="Processing Doc", document_type="letter")
assert response.status_code == 200 session.add(doc)
assert "Delete job" in response.text await session.flush()
assert "This action permanently deletes the job." in response.text job = Job(document_id=doc.id, status=JobStatus.PROCESSING)
assert "Delete job permanently" in response.text session.add(job)
await session.commit()
def test_job_delete_page_shows_blocked_state_when_processing(self, app_client, seed_job): job_id = str(job.id)
_, client = app_client
job_id = seed_job(filename="delete-blocked.pdf", status=JobStatus.PROCESSING)
response = client.get(f"/ui/jobs/{job_id}/delete") response = client.get(f"/ui/jobs/{job_id}/delete")
assert response.status_code == 200 assert response.status_code == 200
assert "Delete Processing Job" in response.text
assert "Delete is blocked while the job is processing." in response.text assert "Delete is blocked while the job is processing." in response.text
assert "Wait for processing to complete, then retry delete." in response.text
@pytest.mark.asyncio
async def test_job_delete_page_allows_deletion_for_queued_or_completed_job(
self, app_client, seed_document_with_unlinked_job
):
_, client = app_client
_, job_id = seed_document_with_unlinked_job
response = client.get(f"/ui/jobs/{job_id}/delete")
assert response.status_code == 200
assert "Delete Processing Job" in response.text
assert "Delete job permanently" in response.text
assert "Delete is blocked" not in response.text
+43
View File
@@ -0,0 +1,43 @@
"""Tests for UI entry-points, redirects, and page mounting health-checks."""
import pytest
@pytest.mark.integration
class TestNavigationAndMounts:
"""Verify application entry-point redirects and route mounting."""
@pytest.mark.parametrize(
("url", "expected_status", "expected_redirect"),
[
("/", 307, "/ui/homepage"),
("/ui", 307, "/ui/homepage"),
("/ui/upload", 307, "/ui/jobs/new"),
],
)
def test_entrypoint_redirects(self, app_client, url: str, expected_status: int, expected_redirect: str):
"""Verify root and legacy routes redirect to primary UI views."""
_, client = app_client
response = client.get(url, follow_redirects=False)
assert response.status_code == expected_status
assert response.headers["location"] == expected_redirect
@pytest.mark.parametrize(
"route_path",
[
"/ui/homepage",
"/ui/homepage/edit",
"/ui/documents",
"/ui/people",
"/ui/sources",
"/ui/jobs",
],
)
def test_registered_pages_render_successfully(self, app_client, route_path: str):
"""Smoke test verifying all primary UI routes respond with 200 OK."""
_, client = app_client
response = client.get(route_path)
assert response.status_code == 200
assert "html" in response.headers.get("content-type", "").lower()
+148
View File
@@ -0,0 +1,148 @@
"""Action handler tests for Person CRUD mutations."""
from datetime import date
from uuid import uuid4
import pytest
from sqlmodel import select
from transcription.db import session_scope
from transcription.db.models import Document, DocumentPerson, DocumentPersonRole, Person
@pytest.mark.integration
class TestPeopleActionHandlers:
"""Verify POST/mutation routes for Person creation, updates, and deletions."""
@pytest.mark.asyncio
async def test_create_person_success(self, app_client):
_, client = app_client
payload = {
"full_name": "Mary-Jo Kline",
"display_name": "Mary-Jo",
"maiden_name": "",
"birth_date": "1945-03-12",
"birth_date_raw": "ca. 1945",
"birth_place": "Boston, MA",
"biography": "Editor and scholar in documentary editing.",
}
response = client.post("/ui/people/new", data=payload, follow_redirects=True)
assert response.status_code == 200
assert "Mary-Jo Kline" in response.text
async with session_scope() as session:
person = (
await session.exec(select(Person).where(Person.full_name == "Mary-Jo Kline"))
).first()
assert person is not None
assert person.display_name == "Mary-Jo"
assert person.birth_date == date(1945, 3, 12)
assert person.biography == "Editor and scholar in documentary editing."
@pytest.mark.asyncio
async def test_create_person_validation_missing_full_name(self, app_client):
_, client = app_client
payload = {
"full_name": "",
"display_name": "Anonymous",
}
response = client.post("/ui/people/new", data=payload, follow_redirects=True)
assert response.status_code == 200
assert "Full name is required." in response.text
@pytest.mark.asyncio
async def test_update_person_details_success(self, app_client):
_, client = app_client
async with session_scope() as session:
person = Person(full_name="Original Name", display_name="Orig")
session.add(person)
await session.commit()
person_id = str(person.id)
update_payload = {
"full_name": "Updated Person Name",
"display_name": "Updated Display",
"maiden_name": "Cochran",
"birth_date": "1902-08-20",
"biography": "Updated archival biographical information.",
}
response = client.post(f"/ui/people/{person_id}/edit", data=update_payload, follow_redirects=True)
assert response.status_code == 200
assert "Updated Person Name" in response.text
async with session_scope() as session:
updated_person = await session.get(Person, person_id)
assert updated_person is not None
assert updated_person.full_name == "Updated Person Name"
assert updated_person.display_name == "Updated Display"
assert updated_person.maiden_name == "Cochran"
assert updated_person.birth_date == date(1902, 8, 20)
@pytest.mark.asyncio
async def test_delete_unlinked_person_success(self, app_client):
_, client = app_client
async with session_scope() as session:
person = Person(full_name="Transient Record")
session.add(person)
await session.commit()
person_id = str(person.id)
response = client.post(f"/ui/people/{person_id}/delete", follow_redirects=True)
assert response.status_code == 200
assert "Person deleted" in response.text or "Archival Entities: People" in response.text
async with session_scope() as session:
deleted_person = await session.get(Person, person_id)
assert deleted_person is None
@pytest.mark.asyncio
async def test_delete_person_removes_linked_document_relationship(self, app_client):
_, client = app_client
async with session_scope() as session:
person = Person(full_name="Linked Person to Delete")
doc = Document(name="Historical Letter", document_type="letter")
session.add_all([person, doc])
await session.flush()
link = DocumentPerson(
document_id=doc.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
)
session.add(link)
await session.commit()
person_id = str(person.id)
doc_id = str(doc.id)
response = client.post(f"/ui/people/{person_id}/delete", follow_redirects=True)
assert response.status_code == 200
async with session_scope() as session:
# Person should be deleted
deleted_person = await session.get(Person, person_id)
assert deleted_person is None
# Associated relationship link should also be removed
remaining_links = (
await session.exec(
select(DocumentPerson).where(DocumentPerson.person_id == person_id)
)
).all()
assert len(remaining_links) == 0
# Document itself should remain intact
document = await session.get(Document, doc_id)
assert document is not None
+21 -73
View File
@@ -1,16 +1,12 @@
"""Tests for the people page routes.""" """Tests for the people page routes and action handlers."""
import asyncio
from datetime import date from datetime import date
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
from transcription.db import session_scope from transcription.db import session_scope
from transcription.db.models import Document from transcription.db.models import Document, DocumentPerson, DocumentPersonRole, Person
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import Person
@pytest.mark.integration @pytest.mark.integration
@@ -27,16 +23,14 @@ class TestPeoplePageRendering:
assert "Create new person" in response.text assert "Create new person" in response.text
assert "No person records found in repository." in response.text assert "No person records found in repository." in response.text
def test_people_page_lists_seeded_people(self, app_client): @pytest.mark.asyncio
async def test_people_page_lists_seeded_people(self, app_client):
_, client = app_client _, client = app_client
async def _seed_person() -> None:
async with session_scope() as session: async with session_scope() as session:
session.add(Person(full_name="Ada Lovelace", display_name="Ada")) session.add(Person(full_name="Ada Lovelace", display_name="Ada"))
await session.commit() await session.commit()
asyncio.run(_seed_person())
response = client.get("/ui/people") response = client.get("/ui/people")
assert response.status_code == 200 assert response.status_code == 200
@@ -56,10 +50,10 @@ class TestPeoplePageRendering:
assert "Biography" in response.text assert "Biography" in response.text
assert "Save person" in response.text assert "Save person" in response.text
def test_person_detail_page_renders_metadata_and_empty_links(self, app_client): @pytest.mark.asyncio
async def test_person_detail_page_renders_metadata_and_empty_links(self, app_client):
_, client = app_client _, client = app_client
async def _seed_person() -> str:
async with session_scope() as session: async with session_scope() as session:
person = Person( person = Person(
full_name="Grace Hopper", full_name="Grace Hopper",
@@ -76,10 +70,7 @@ class TestPeoplePageRendering:
) )
session.add(person) session.add(person)
await session.commit() await session.commit()
await session.refresh(person) person_id = str(person.id)
return str(person.id)
person_id = asyncio.run(_seed_person())
response = client.get(f"/ui/people/{person_id}") response = client.get(f"/ui/people/{person_id}")
@@ -97,12 +88,11 @@ class TestPeoplePageRendering:
assert "Created:" in response.text assert "Created:" in response.text
assert "Updated:" in response.text assert "Updated:" in response.text
assert "No linked documents yet." in response.text assert "No linked documents yet." in response.text
assert "Link this person from a Document workflow." in response.text
def test_person_detail_page_resolves_relative_portrait_path_to_uploads_mount(self, app_client): @pytest.mark.asyncio
async def test_person_detail_page_resolves_relative_portrait_path(self, app_client):
_, client = app_client _, client = app_client
async def _seed_person() -> str:
async with session_scope() as session: async with session_scope() as session:
person = Person( person = Person(
full_name="Portrait Person", full_name="Portrait Person",
@@ -110,24 +100,21 @@ class TestPeoplePageRendering:
) )
session.add(person) session.add(person)
await session.commit() await session.commit()
await session.refresh(person) person_id = str(person.id)
return str(person.id)
person_id = asyncio.run(_seed_person())
response = client.get(f"/ui/people/{person_id}") response = client.get(f"/ui/people/{person_id}")
assert response.status_code == 200 assert response.status_code == 200
assert "/uploads/portraits/person/seeded.png" in response.text assert "/uploads/portraits/person/seeded.png" in response.text
def test_person_detail_page_renders_linked_documents(self, app_client): @pytest.mark.asyncio
async def test_person_detail_page_renders_linked_documents(self, app_client):
_, client = app_client _, client = app_client
async def _seed_links() -> str:
async with session_scope() as session: async with session_scope() as session:
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", document_type="letter")
session.add(person) session.add_all([person, document])
session.add(document)
await session.flush() await session.flush()
session.add( session.add(
@@ -138,10 +125,7 @@ class TestPeoplePageRendering:
) )
) )
await session.commit() await session.commit()
await session.refresh(person) person_id = str(person.id)
return str(person.id)
person_id = asyncio.run(_seed_links())
response = client.get(f"/ui/people/{person_id}") response = client.get(f"/ui/people/{person_id}")
@@ -165,39 +149,33 @@ class TestPeoplePageRendering:
assert response.status_code == 200 assert response.status_code == 200
assert "Person not found" in response.text assert "Person not found" in response.text
def test_person_edit_page_renders_expected_fields(self, app_client): @pytest.mark.asyncio
async def test_person_edit_page_renders_expected_fields(self, app_client):
_, client = app_client _, client = app_client
async def _seed_person() -> str:
async with session_scope() as session: async with session_scope() as session:
person = Person(full_name="Editable Person", display_name="EP") person = Person(full_name="Editable Person", display_name="EP")
session.add(person) session.add(person)
await session.commit() await session.commit()
await session.refresh(person) person_id = str(person.id)
return str(person.id)
person_id = asyncio.run(_seed_person())
response = client.get(f"/ui/people/{person_id}/edit") response = client.get(f"/ui/people/{person_id}/edit")
assert response.status_code == 200 assert response.status_code == 200
assert "Edit Person Record" in response.text assert "Edit Person Record" in response.text
assert "Full name is required." in response.text assert "Full name is required." in response.text
assert "Full name" in response.text assert "Editable Person" in response.text
assert "Save changes" in response.text assert "Save changes" in response.text
def test_person_delete_page_shows_confirmation_when_unlinked(self, app_client): @pytest.mark.asyncio
async def test_person_delete_page_shows_confirmation_when_unlinked(self, app_client):
_, client = app_client _, client = app_client
async def _seed_person() -> str:
async with session_scope() as session: async with session_scope() as session:
person = Person(full_name="Safe Delete") person = Person(full_name="Safe Delete")
session.add(person) session.add(person)
await session.commit() await session.commit()
await session.refresh(person) person_id = str(person.id)
return str(person.id)
person_id = asyncio.run(_seed_person())
response = client.get(f"/ui/people/{person_id}/delete") response = client.get(f"/ui/people/{person_id}/delete")
@@ -205,33 +183,3 @@ class TestPeoplePageRendering:
assert "Delete Person Record" in response.text assert "Delete Person Record" in response.text
assert "This action permanently deletes the person record." in response.text assert "This action permanently deletes the person record." in response.text
assert "Delete person permanently" in response.text assert "Delete person permanently" in response.text
def test_person_delete_page_warns_links_will_be_removed_when_linked_documents_exist(self, app_client):
_, client = app_client
async def _seed_links() -> str:
async with session_scope() as session:
person = Person(full_name="Blocked Delete")
document = Document(name="Linked Document", document_type="record")
session.add(person)
session.add(document)
await session.flush()
session.add(
DocumentPerson(
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
)
)
await session.commit()
await session.refresh(person)
return str(person.id)
person_id = asyncio.run(_seed_links())
response = client.get(f"/ui/people/{person_id}/delete")
assert response.status_code == 200
assert "This will also remove 1 linked document relationship(s)." in response.text
assert "Delete person permanently" in response.text
+98
View File
@@ -0,0 +1,98 @@
"""Action handler tests for Source CRUD mutations."""
from pathlib import Path
import pytest
from sqlmodel import select
from transcription.db import session_scope
from transcription.db.models import Document, Job, JobSource, Source
@pytest.mark.integration
class TestSourcesActionHandlers:
"""Verify POST/mutation routes for Source revisions and deletions."""
@pytest.mark.asyncio
async def test_upsert_revision_for_source_success(self, app_client, seed_job):
_, client = app_client
job_id = seed_job(
filename="revision-source.png",
transcription_text="automated raw transcription text",
)
async with session_scope() as session:
job = await session.get(Job, job_id)
assert job is not None
source = (
await session.exec(select(Source).where(Source.document_id == job.document_id))
).first()
assert source is not None
source_id = str(source.id)
payload = {
"revised_text": "Curated human transcription text by editor.",
}
response = client.post(f"/ui/sources/{source_id}", data=payload, follow_redirects=True)
assert response.status_code == 200
assert "Revision saved" in response.text or "Curated human transcription text by editor." in response.text
async with session_scope() as session:
updated_source = await session.get(Source, source_id)
assert updated_source is not None
assert updated_source.revised_text == "Curated human transcription text by editor."
assert updated_source.date_revised is not None
@pytest.mark.asyncio
async def test_delete_unlinked_source_success(self, app_client):
_, client = app_client
async with session_scope() as session:
doc = Document(name="Unlinked Source Doc", document_type="memo")
session.add(doc)
await session.flush()
source = Source(
document_id=doc.id,
page_number=1,
upload_name="orphan_page.png",
filename="orphan_page.png",
file_path="/tmp/orphan_page.png",
)
session.add(source)
await session.commit()
source_id = str(source.id)
response = client.post(f"/ui/sources/{source_id}/delete", follow_redirects=True)
assert response.status_code == 200
assert "Source deleted" in response.text or "Archival Source Media" in response.text
async with session_scope() as session:
deleted_source = await session.get(Source, source_id)
assert deleted_source is None
@pytest.mark.asyncio
async def test_delete_source_blocked_when_job_linked(self, app_client, seed_job):
_, client = app_client
job_id = seed_job(filename="job-linked-source.png", transcription_text="job text")
async with session_scope() as session:
job = await session.get(Job, job_id)
assert job is not None
source = (
await session.exec(select(Source).where(Source.document_id == job.document_id))
).first()
assert source is not None
source_id = str(source.id)
response = client.post(f"/ui/sources/{source_id}/delete", follow_redirects=True)
assert response.status_code == 200
assert "Delete is only available for unlinked sources." in response.text or "linked" in response.text.lower()
async with session_scope() as session:
source_still_exists = await session.get(Source, source_id)
assert source_still_exists is not None