From fd3ca60008827696f675644ed680742d2b670b8d Mon Sep 17 00:00:00 2001 From: Jim Lancaster <40281233+zoltan57@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:05:41 -0500 Subject: [PATCH] Revamped the Documents, People, & Jobs too. --- .../ui/components/table/documents.py | 54 +- src/transcription/ui/components/table/jobs.py | 81 ++- .../ui/components/table/people.py | 49 +- src/transcription/ui/pages/documents_page.py | 464 +++++++---------- src/transcription/ui/pages/jobs_page.py | 349 ++++++------- src/transcription/ui/pages/people_page.py | 474 +++++++++--------- tests/ui/conftest.py | 165 +++--- tests/ui/test_documents_actions.py | 179 +++++++ tests/ui/test_documents_page.py | 424 +++++----------- tests/ui/test_jobs_actions.py | 133 +++++ tests/ui/test_jobs_page.py | 210 ++++---- tests/ui/test_navigation_and_mounts.py | 43 ++ tests/ui/test_people_actions.py | 148 ++++++ tests/ui/test_people_page.py | 186 +++---- tests/ui/test_sources_actions.py | 98 ++++ 15 files changed, 1719 insertions(+), 1338 deletions(-) create mode 100644 tests/ui/test_documents_actions.py create mode 100644 tests/ui/test_jobs_actions.py create mode 100644 tests/ui/test_navigation_and_mounts.py create mode 100644 tests/ui/test_people_actions.py create mode 100644 tests/ui/test_sources_actions.py diff --git a/src/transcription/ui/components/table/documents.py b/src/transcription/ui/components/table/documents.py index 43a7066..437271b 100644 --- a/src/transcription/ui/components/table/documents.py +++ b/src/transcription/ui/components/table/documents.py @@ -39,21 +39,61 @@ def _serialize_rows(rows: Sequence[DocumentTableRow]) -> list[dict[str, Any]]: 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: with archival_card(extra_classes="p-8 text-center"): render_empty_state("No documents in repository yet.") return - build_table( + table = build_table( rows=_serialize_rows(rows), 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": "archive_identifier", "label": "Archive Ref", "field": "archive_identifier", "sortable": True, "classes": "font-mono"}, - {"name": "created_at", "label": "Created", "field": "created_at", "sortable": True}, + { + "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": "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", - 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}"), + ) + + # Render document type using a subtle Quasar badge + table.add_slot( + "body-cell-document_type", + r""" + + + {{ props.value }} + + + """, ) \ No newline at end of file diff --git a/src/transcription/ui/components/table/jobs.py b/src/transcription/ui/components/table/jobs.py index 31102a2..aed7c4a 100644 --- a/src/transcription/ui/components/table/jobs.py +++ b/src/transcription/ui/components/table/jobs.py @@ -4,8 +4,7 @@ from __future__ import annotations from collections.abc import Sequence from dataclasses import dataclass -from datetime import UTC -from datetime import datetime +from datetime import UTC, datetime from typing import Any from uuid import UUID @@ -29,7 +28,7 @@ class JobTableRow: def _format_timestamp(value: str) -> str: - """Return a friendly UTC timestamp for table display.""" + """Return a friendly local timestamp for table display.""" try: parsed = datetime.fromisoformat(value) except ValueError: @@ -42,7 +41,7 @@ def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]: return [ { "id": str(row.id), - "status": row.status.upper(), + "status": row.status.lower(), "filename": row.filename, "retry_count": row.retry_count, "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: - """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: with archival_card(extra_classes="p-8 text-center"): render_empty_state("No active or historical processing jobs found.") return - build_table( + table = build_table( rows=_serialize_rows(rows), 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": "filename", "label": "Source Filename", "field": "filename", "sortable": True, "classes": "font-mono"}, - {"name": "retry_count", "label": "Retries", "field": "retry_count", "sortable": True}, - {"name": "date_created", "label": "Created", "field": "date_created", "sortable": True}, - {"name": "date_updated", "label": "Updated", "field": "date_updated", "sortable": True}, + { + "name": "id", + "label": "Job ID", + "field": "id", + "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_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}"), + ) + + # Render job execution status using themed Quasar chips + table.add_slot( + "body-cell-status", + r""" + + + {{ props.value.toUpperCase() }} + + + """, ) \ No newline at end of file diff --git a/src/transcription/ui/components/table/people.py b/src/transcription/ui/components/table/people.py index 269fcd9..aefbfe8 100644 --- a/src/transcription/ui/components/table/people.py +++ b/src/transcription/ui/components/table/people.py @@ -39,21 +39,56 @@ def _serialize_rows(rows: Sequence[PersonTableRow]) -> list[dict[str, Any]]: 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: with archival_card(extra_classes="p-8 text-center"): render_empty_state("No person records found in repository.") return - build_table( + table = build_table( rows=_serialize_rows(rows), 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": "maiden_name", "label": "Maiden Name", "field": "maiden_name", "sortable": True}, - {"name": "birth_date", "label": "Birth Date", "field": "birth_date", "sortable": True}, + { + "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": "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", - 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}"), + ) + + # Custom column template adding an archival entity icon next to person's name + table.add_slot( + "body-cell-full_name", + r""" + +
+ + {{ props.value }} +
+
+ """, ) \ No newline at end of file diff --git a/src/transcription/ui/pages/documents_page.py b/src/transcription/ui/pages/documents_page.py index 134649e..faecbc6 100644 --- a/src/transcription/ui/pages/documents_page.py +++ b/src/transcription/ui/pages/documents_page.py @@ -9,26 +9,25 @@ from fastapi import Request from fastapi.responses import RedirectResponse from nicegui import ui -from transcription.db.models import Document -from transcription.db.models import DocumentPerson -from transcription.db.models import DocumentPersonRole +from transcription.db.models import Document, DocumentPerson, DocumentPersonRole from transcription.errors import ErrorCategory -from transcription.services.documents import DocumentDeleteBlockedError -from transcription.services.documents import DocumentError -from transcription.services.documents import DocumentService +from transcription.services.documents import ( + DocumentDeleteBlockedError, + DocumentError, + DocumentService, +) from transcription.ui.components.app_shell import render_navigation_header from transcription.ui.components.cards import archival_card -from transcription.ui.components.data_display import archival_badge -from transcription.ui.components.data_display import metadata_row +from transcription.ui.components.data_display import archival_badge, metadata_row from transcription.ui.components.error_presenter import show_error -from transcription.ui.components.primitives import destructive_button -from transcription.ui.components.primitives import render_empty_state -from transcription.ui.components.primitives import section_header_row -from transcription.ui.components.table.documents import DocumentTableRow -from transcription.ui.components.table.documents import render_documents_table +from transcription.ui.components.primitives import ( + destructive_button, + render_empty_state, + section_header_row, +) +from transcription.ui.components.table.documents import DocumentTableRow, render_documents_table from transcription.ui.components.viewers import dark_room_viewer -from transcription.ui.theme import apply_archival_theme -from transcription.ui.theme import page_header +from transcription.ui.theme import apply_archival_theme, page_header 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"): 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()) - author_options = ( - {"": "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") + people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold()) + form = _render_document_form_fields(people=people) + requested_doc_id = request.query_params.get("document_id") return_to = request.query_params.get("return_to") async def submit_create() -> None: - candidate_name = (name_input.value or "").strip() + candidate_name = (form["name"].value or "").strip() if not candidate_name: ui.notify("Document name is required.", type="warning") return - parsed_date: date | None = None - candidate_date_text = (date_input.value or "").strip() - if candidate_date_text: - try: - parsed_date = date.fromisoformat(candidate_date_text) - except ValueError: - ui.notify("Exact date must use YYYY-MM-DD.", type="warning") - return + parsed_date = _parse_iso_date(form["date"].value) + if form["date"].value and parsed_date is None: + ui.notify("Exact date must use YYYY-MM-DD.", type="warning") + return candidate = Document( name=candidate_name, - document_type=(document_type_input.value or "").strip() or None, + document_type=(form["type"].value or "").strip() or None, document_date=parsed_date, - document_date_raw=(date_raw_input.value or "").strip() or None, - location_created=(location_input.value or "").strip() or None, - notes=(notes_input.value or "").strip() or None, - archive_identifier=(archive_input.value or "").strip() or None, + document_date_raw=(form["date_raw"].value or "").strip() or None, + location_created=(form["location"].value or "").strip() or None, + notes=(form["notes"].value or "").strip() or None, + archive_identifier=(form["archive"].value or "").strip() or None, ) try: @@ -110,14 +79,13 @@ def register_page() -> None: show_error(exc, title="Create failed", operation="documents.create") return - selected_author = (author_select.value or "").strip() + selected_author = (form["author"].value or "").strip() if selected_author == CREATE_NEW_PERSON_OPTION: ui.navigate.to("/people/new") return if selected_author: - try: - parsed_person_id = UUID(selected_author) - except ValueError: + parsed_author_id = _parse_uuid(selected_author) + if parsed_author_id is None: ui.notify("Selected author is invalid.", type="warning") return @@ -125,7 +93,7 @@ def register_page() -> None: await document_service.create_document_person( DocumentPerson( document_id=created.id, - person_id=parsed_person_id, + person_id=parsed_author_id, role=DocumentPersonRole.AUTHOR, ) ) @@ -168,7 +136,6 @@ def register_page() -> None: show_error(exc, title="Load failed", operation="documents.list") return - # Format documents into read-model rows for the table renderer rows = [ DocumentTableRow( id=doc.id, @@ -187,14 +154,13 @@ def register_page() -> None: document_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/documents") - try: - parsed_document_id = UUID(document_id) - except ValueError: + parsed_doc_id = _parse_uuid(document_id) + if parsed_doc_id is None: ui.label("Invalid document id").classes("text-h6 text-red-800 p-4") return 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: ui.label("Document not found").classes("text-h6 text-red-800 p-4") return @@ -202,18 +168,7 @@ def register_page() -> None: show_error(exc, title="Load failed", operation="documents.read") 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"): - # Header Bar with section_header_row(): 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", ) - # High-Density Bento Grid Layout with ui.grid().classes("w-full grid-cols-12 gap-4"): - # ZONE 1: Source Image Viewer (Cols 1-5) - 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") - - # 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") + _render_bento_viewer_zone(document) + _render_bento_metadata_zone(document) + _render_bento_relations_zone(document) @ui.page("/documents/{document_id}/jobs") 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) render_navigation_header(current_path="/documents") - try: - parsed_document_id = UUID(document_id) - except ValueError: + parsed_doc_id = _parse_uuid(document_id) + if parsed_doc_id is None: ui.label("Invalid document id").classes("text-h6 text-red-800 p-4") return 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: ui.label("Document not found").classes("text-h6 text-red-800 p-4") return @@ -323,16 +214,8 @@ def register_page() -> None: with section_header_row(): page_header(f"Jobs for {document.name}") with ui.row().classes("gap-2"): - ui.button( - "Back to Document", - 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") + ui.button("Back to Document", 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: 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"): archival_badge(job.status.value) ui.label(f"Job ID: {job.id}").classes("text-xs font-mono ui-text-primary") - ui.button( - "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.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") @ui.page("/documents/{document_id}/sources") 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) render_navigation_header(current_path="/documents") - try: - parsed_document_id = UUID(document_id) - except ValueError: + parsed_doc_id = _parse_uuid(document_id) + if parsed_doc_id is None: ui.label("Invalid document id").classes("text-h6 text-red-800 p-4") return 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: ui.label("Document not found").classes("text-h6 text-red-800 p-4") return @@ -380,69 +258,13 @@ def register_page() -> None: 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.") - 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()) - author_options = ( - {"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"} - | {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") + people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold()) + existing_author = next((link for link in document.document_people if link.role == DocumentPersonRole.AUTHOR), None) + form = _render_document_form_fields(document=document, people=people, existing_author_id=existing_author.person_id if existing_author else None) async def submit_edit() -> None: - candidate_name = (name_input.value or "").strip() - candidate_type = (document_type_input.value or "").strip() + candidate_name = (form["name"].value or "").strip() + candidate_type = (form["type"].value or "").strip() if not candidate_name: ui.notify("Document name is required.", type="warning") return @@ -450,24 +272,20 @@ def register_page() -> None: ui.notify("Document type is required.", type="warning") return - parsed_date: date | None = None - candidate_date_text = (date_input.value or "").strip() - if candidate_date_text: - try: - parsed_date = date.fromisoformat(candidate_date_text) - except ValueError: - ui.notify("Exact date must use YYYY-MM-DD.", type="warning") - return + parsed_date = _parse_iso_date(form["date"].value) + if form["date"].value and parsed_date is None: + ui.notify("Exact date must use YYYY-MM-DD.", type="warning") + return candidate = Document( id=document.id, name=candidate_name, document_type=candidate_type, document_date=parsed_date, - document_date_raw=(date_raw_input.value or "").strip() or None, - location_created=(location_input.value or "").strip() or None, - notes=(notes_input.value or "").strip() or None, - archive_identifier=(archive_input.value or "").strip() or None, + document_date_raw=(form["date_raw"].value or "").strip() or None, + location_created=(form["location"].value or "").strip() or None, + notes=(form["notes"].value or "").strip() or None, + archive_identifier=(form["archive"].value or "").strip() or None, created_at=document.created_at, updated_at=document.updated_at, ) @@ -478,13 +296,12 @@ def register_page() -> None: show_error(exc, title="Save failed", operation="documents.edit.save") return - selected_author = (author_select.value or "").strip() + selected_author = (form["author"].value or "").strip() if selected_author == CREATE_NEW_PERSON_OPTION: ui.navigate.to("/people/new") 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: if not selected_author: for link in existing_author_links: @@ -495,11 +312,7 @@ def register_page() -> None: for link in existing_author_links: await document_service.delete_document_person(link) await document_service.create_document_person( - DocumentPerson( - document_id=document.id, - person_id=selected_author_id, - role=DocumentPersonRole.AUTHOR, - ) + DocumentPerson(document_id=document.id, person_id=selected_author_id, role=DocumentPersonRole.AUTHOR) ) except Exception as exc: # noqa: BLE001 show_error(exc, title="Author update failed", operation="documents.edit.link_author") @@ -510,9 +323,7 @@ def register_page() -> None: 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("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props( - "flat" - ) + ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props("flat") @ui.page("/documents/{document_id}/delete") 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) render_navigation_header(current_path="/documents") - try: - parsed_document_id = UUID(document_id) - except ValueError: + parsed_doc_id = _parse_uuid(document_id) + if parsed_doc_id is None: ui.label("Invalid document id").classes("text-h6 text-red-800 p-4") return 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: ui.label("Document not found").classes("text-h6 text-red-800 p-4") return @@ -541,28 +351,15 @@ def register_page() -> None: with archival_card(extra_classes="gap-2"): ui.label(f"Document: {document.name}").classes("text-sm font-semibold ui-text-primary") - has_sources = bool(document.sources) - has_jobs = bool(document.jobs) - - if has_sources or has_jobs: + if document.sources or document.jobs: ui.label("Delete is blocked because related records exist.").classes("text-xs text-red-800 font-bold mt-2") - categories: list[str] = [] - if has_sources: - categories.append("Sources") - if has_jobs: - categories.append("Jobs") - ui.label(f"Dependencies present: {', '.join(categories)}").classes("text-xs ui-text-muted") + deps = [cat for cat, present in [("Sources", bool(document.sources)), ("Jobs", bool(document.jobs))] if present] + ui.label(f"Dependencies present: {', '.join(deps)}").classes("text-xs ui-text-muted") 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"): - ui.button( - "Back to Document", - 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" - ) + ui.button("Back to Document", 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 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") with ui.row().classes("w-full items-center gap-2 mt-2"): - destructive_button( - "Delete document permanently", - on_click=submit_delete, - icon="delete_forever", - variant="solid", - ) - ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props( - "flat" - ) \ No newline at end of file + destructive_button("Delete document permanently", on_click=submit_delete, icon="delete_forever", variant="solid") + ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props("flat") + + +# --- Helper Sub-Components --- + + +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 \ No newline at end of file diff --git a/src/transcription/ui/pages/jobs_page.py b/src/transcription/ui/pages/jobs_page.py index 16ec9e4..c6a954c 100644 --- a/src/transcription/ui/pages/jobs_page.py +++ b/src/transcription/ui/pages/jobs_page.py @@ -8,30 +8,30 @@ from uuid import UUID from fastapi import Request from nicegui import ui -from transcription.db.models import JobSourceStatus -from transcription.db.models import JobStatus +from transcription.db.models import Job, JobSourceStatus, JobStatus from transcription.db.session import session_scope from transcription.services.documents import DocumentService -from transcription.services.jobs import JobDeleteBlockedError -from transcription.services.jobs import JobCancelBlockedError -from transcription.services.jobs import JobResubmitBlockedError -from transcription.services.jobs import JobService +from transcription.services.jobs import ( + JobCancelBlockedError, + JobDeleteBlockedError, + JobResubmitBlockedError, + JobService, +) from transcription.services.store import create_job_for_document from transcription.ui.components.app_shell import render_navigation_header from transcription.ui.components.cards import archival_card -from transcription.ui.components.data_display import archival_badge -from transcription.ui.components.data_display import metadata_row +from transcription.ui.components.data_display import archival_badge, metadata_row from transcription.ui.components.error_presenter import show_error -from transcription.ui.components.primitives import destructive_button -from transcription.ui.components.primitives import render_empty_state -from transcription.ui.components.primitives import section_header_row -from transcription.ui.components.table.jobs import render_jobs_table -from transcription.ui.theme import apply_archival_theme -from transcription.ui.theme import page_header +from transcription.ui.components.primitives import ( + destructive_button, + render_empty_state, + section_header_row, +) +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 ...db.session import SessionFactoryDep -from ..components.table.jobs import JobTableRow def register_page() -> None: # noqa: PLR0915 @@ -80,27 +80,15 @@ def register_page() -> None: # noqa: PLR0915 documents = await documents_service.list_documents() if not documents: - 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") + _render_no_documents_card() return uploaded_files: list[tuple[str, bytes]] = [] with archival_card(extra_classes="gap-3"): - document_options = {str(document.id): document.name for document in documents} - document_select = ( - ui.select(document_options, label="Target Document").props("outlined bg-white").classes("w-full") - ) + document_options = {str(doc.id): doc.name for doc in documents} + document_select = ui.select(document_options, label="Target Document").props("outlined bg-white").classes("w-full") + requested_document_id = request.query_params.get("document_id") if requested_document_id in document_options: 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") prompt_input = ui.input(label="Prompt").props("outlined bg-white") - 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() + _render_upload_section(uploaded_files) async def submit_create() -> None: - selected_document = document_select.value - if not selected_document: + if not document_select.value: ui.notify("Document is required.", type="warning") return if not uploaded_files: @@ -176,7 +109,7 @@ def register_page() -> None: # noqa: PLR0915 return try: - document_id = UUID(str(selected_document)) + document_id = UUID(str(document_select.value)) except ValueError: ui.notify("Selected document id is invalid.", type="warning") return @@ -200,9 +133,7 @@ def register_page() -> None: # noqa: PLR0915 ui.navigate.to(f"/jobs/{result.job_id}") 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-btn-primary" - ) + ui.button("Submit for transcription", on_click=submit_create, icon="play_arrow").classes("ui-btn-primary") ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat") @ui.page("/jobs/{job_id}") @@ -211,9 +142,8 @@ def register_page() -> None: # noqa: PLR0915 jobs_service = JobService(session_factory=session_factory) render_navigation_header(current_path="/jobs") - try: - parsed_job_id = UUID(job_id) - except ValueError: + parsed_job_id = _parse_uuid(job_id) + if parsed_job_id is None: ui.label("Invalid job id").classes("text-h6 text-red-800 p-4") return @@ -224,52 +154,11 @@ def register_page() -> None: # noqa: PLR0915 return with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"): - 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", - ) + _render_job_detail_header(job) with ui.grid().classes("w-full grid-cols-1 md:grid-cols-2 gap-4"): - 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()) - - 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") + _render_job_logistics(job) + _render_job_document_links(job) @ui.page("/jobs/{job_id}/cancel") 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) render_navigation_header(current_path="/jobs") - try: - parsed_job_id = UUID(job_id) - except ValueError: + parsed_job_id = _parse_uuid(job_id) + if parsed_job_id is None: ui.label("Invalid job id").classes("text-h6 text-red-800 p-4") return @@ -318,12 +206,7 @@ def register_page() -> None: # noqa: PLR0915 ui.navigate.to(f"/jobs/{job.id}") with ui.row().classes("w-full items-center gap-2 mt-2"): - destructive_button( - "Cancel job", - on_click=submit_cancel, - icon="stop_circle", - variant="solid", - ) + destructive_button("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.page("/jobs/{job_id}/resubmit") @@ -332,9 +215,8 @@ def register_page() -> None: # noqa: PLR0915 jobs_service = JobService(session_factory=session_factory) render_navigation_header(current_path="/jobs") - try: - parsed_job_id = UUID(job_id) - except ValueError: + parsed_job_id = _parse_uuid(job_id) + if parsed_job_id is None: ui.label("Invalid job id").classes("text-h6 text-red-800 p-4") return @@ -344,7 +226,7 @@ def register_page() -> None: # noqa: PLR0915 ui.label("Job not found").classes("text-h6 text-red-800 p-4") 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"): 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") metadata_row("Current Status:", job.status.value) 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( - "text-xs ui-text-muted" - ) + ui.label( + "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: try: @@ -385,9 +267,8 @@ def register_page() -> None: # noqa: PLR0915 jobs_service = JobService(session_factory=session_factory) render_navigation_header(current_path="/jobs") - try: - parsed_job_id = UUID(job_id) - except ValueError: + parsed_job_id = _parse_uuid(job_id) + if parsed_job_id is None: ui.label("Invalid job id").classes("text-h6 text-red-800 p-4") 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") if job.status == JobStatus.PROCESSING: - ui.label("Delete is blocked while the job is processing.").classes( - "text-xs text-red-800 font-bold mt-2" - ) + ui.label("Delete is blocked while the job is processing.").classes("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") with ui.row().classes("w-full items-center gap-2 mt-4"): - ui.button( - "Back to Job", - on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), - icon="arrow_back", - ).classes("ui-btn-primary text-xs") + ui.button("Back to Job", 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( "flat text-xs" ) @@ -441,10 +318,140 @@ def register_page() -> None: # noqa: PLR0915 ui.navigate.to("/jobs") with ui.row().classes("w-full items-center gap-2 mt-2"): + destructive_button("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") + + +# --- 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( - "Delete job permanently", - on_click=submit_delete, - icon="delete_forever", - variant="solid", + "Cancel", + on_click=lambda: ui.navigate.to(f"/jobs/{job.id}/cancel"), + icon="stop_circle", + extra_classes="text-xs", ) - ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat") \ No newline at end of file + + 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 \ No newline at end of file diff --git a/src/transcription/ui/pages/people_page.py b/src/transcription/ui/pages/people_page.py index 1922900..60fb1c2 100644 --- a/src/transcription/ui/pages/people_page.py +++ b/src/transcription/ui/pages/people_page.py @@ -4,8 +4,7 @@ from __future__ import annotations from datetime import date from urllib.parse import quote -from uuid import UUID -from uuid import uuid4 +from uuid import UUID, uuid4 from fastapi import Request from nicegui import ui @@ -13,94 +12,24 @@ from nicegui import ui from transcription.config import Settings, get_settings from transcription.db.models import Person from transcription.errors import ErrorCategory -from transcription.services.documents import ( - DocumentError, - DocumentService, -) +from transcription.services.documents import DocumentError, DocumentService from transcription.services.store import UploadError, store_person_portrait from transcription.ui.components.app_shell import render_navigation_header from transcription.ui.components.cards import archival_card -from transcription.ui.components.data_display import 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.primitives import destructive_button -from transcription.ui.components.primitives import render_empty_state -from transcription.ui.components.primitives import section_header_row +from transcription.ui.components.primitives import ( + destructive_button, + render_empty_state, + section_header_row, +) from transcription.ui.components.table.people import PersonTableRow, render_people_table from transcription.ui.components.viewers import dark_room_viewer -from transcription.ui.theme import apply_archival_theme -from transcription.ui.theme import page_header +from transcription.ui.theme import apply_archival_theme, page_header 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 """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") return - # Format person records into read-model rows for the table renderer rows = [ PersonTableRow( 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"): page_header("Create Person Record", subtitle="Full name is required.") - 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").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, - ) + form = _render_person_form_fields( + request=request, + person_id=draft_person_id, + ) 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: ui.notify("Full name is required.", type="warning") return - try: - birth_date = _parse_optional_date(birth_date_input.value, label="Birth date") - death_date = _parse_optional_date(death_date_input.value, label="Death date") - except ValueError as exc: - ui.notify(str(exc), type="warning") - return + birth_date = _parse_iso_date(form["birth_date"].value) + death_date = _parse_iso_date(form["death_date"].value) candidate = Person( id=draft_person_id, full_name=full_name, - display_name=(display_name_input.value or "").strip() or None, - maiden_name=(maiden_name_input.value or "").strip() or None, + display_name=(form["display_name"].value or "").strip() or None, + maiden_name=(form["maiden_name"].value or "").strip() or None, birth_date=birth_date, - birth_date_raw=(birth_date_raw_input.value or "").strip() or None, - birth_place=(birth_place_input.value or "").strip() or None, + birth_date_raw=(form["birth_date_raw"].value or "").strip() or None, + birth_place=(form["birth_place"].value or "").strip() or None, death_date=death_date, - death_date_raw=(death_date_raw_input.value or "").strip() or None, - death_place=(death_place_input.value or "").strip() or None, - biography=(biography_input.value or "").strip() or None, - portrait_path=(portrait_path_input.value or "").strip() or None, + death_date_raw=(form["death_date_raw"].value or "").strip() or None, + death_place=(form["death_place"].value or "").strip() or None, + biography=(form["biography"].value or "").strip() or None, + portrait_path=(form["portrait_path"].value or "").strip() or None, ) try: @@ -223,9 +128,8 @@ def register_page() -> None: # noqa: PLR0915 people_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/people") - try: - parsed_person_id = UUID(person_id) - except ValueError: + parsed_person_id = _parse_uuid(person_id) + if parsed_person_id is None: ui.label("Invalid person id").classes("text-h6 text-red-800 p-4") return @@ -238,8 +142,6 @@ def register_page() -> None: # noqa: PLR0915 show_error(exc, title="Load failed", operation="people.read") 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 section_header_row(): 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.column().classes("col-span-12 lg:col-span-4"): - dark_room_viewer(portrait_src, count_label="Portrait Media") - - 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") + _render_person_portrait_zone(person) + _render_person_biographical_zone(person) + _render_person_biography_zone(person) @ui.page("/people/{person_id}/edit") 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) render_navigation_header(current_path="/people") - try: - parsed_person_id = UUID(person_id) - except ValueError: + parsed_person_id = _parse_uuid(person_id) + if parsed_person_id is None: ui.label("Invalid person id").classes("text-h6 text-red-800 p-4") 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"): page_header("Edit Person Record", subtitle="Full name is required.") - 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).props("outlined bg-white") - 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, - ) + form = _render_person_form_fields( + request=request, + person=person, + person_id=person.id, + ) 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: ui.notify("Full name is required.", type="warning") return - try: - birth_date = _parse_optional_date(birth_date_input.value, label="Birth date") - death_date = _parse_optional_date(death_date_input.value, label="Death date") - except ValueError as exc: - ui.notify(str(exc), type="warning") - return + birth_date = _parse_iso_date(form["birth_date"].value) + death_date = _parse_iso_date(form["death_date"].value) candidate = Person( id=person.id, full_name=full_name, - display_name=(display_name_input.value or "").strip() or None, - maiden_name=(maiden_name_input.value or "").strip() or None, + display_name=(form["display_name"].value or "").strip() or None, + maiden_name=(form["maiden_name"].value or "").strip() or None, birth_date=birth_date, - birth_date_raw=(birth_date_raw_input.value or "").strip() or None, - birth_place=(birth_place_input.value or "").strip() or None, + birth_date_raw=(form["birth_date_raw"].value or "").strip() or None, + birth_place=(form["birth_place"].value or "").strip() or None, death_date=death_date, - death_date_raw=(death_date_raw_input.value or "").strip() or None, - death_place=(death_place_input.value or "").strip() or None, - biography=(biography_input.value or "").strip() or None, - portrait_path=(portrait_path_input.value or "").strip() or None, + death_date_raw=(form["death_date_raw"].value or "").strip() or None, + death_place=(form["death_place"].value or "").strip() or None, + biography=(form["biography"].value or "").strip() or None, + portrait_path=(form["portrait_path"].value or "").strip() or None, metadata_=person.metadata_, created_at=person.created_at, updated_at=person.updated_at, @@ -421,9 +239,8 @@ def register_page() -> None: # noqa: PLR0915 people_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/people") - try: - parsed_person_id = UUID(person_id) - except ValueError: + parsed_person_id = _parse_uuid(person_id) + if parsed_person_id is None: ui.label("Invalid person id").classes("text-h6 text-red-800 p-4") return @@ -467,10 +284,189 @@ def register_page() -> None: # noqa: PLR0915 ui.navigate.to("/people") with ui.row().classes("w-full items-center gap-2 mt-2"): - destructive_button( - "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") \ No newline at end of file + destructive_button("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") + + +# --- 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 \ No newline at end of file diff --git a/tests/ui/conftest.py b/tests/ui/conftest.py index aa4a507..7256c9a 100644 --- a/tests/ui/conftest.py +++ b/tests/ui/conftest.py @@ -2,39 +2,35 @@ from __future__ import annotations -import asyncio -from collections.abc import Callable -from collections.abc import Generator -from datetime import UTC -from datetime import datetime +from collections.abc import AsyncGenerator, Callable +from datetime import UTC, datetime from pathlib import Path +from typing import Any from uuid import UUID import pytest +import pytest_asyncio from fastapi import FastAPI from fastapi.testclient import TestClient from sqlmodel import delete from transcription.app import create_app -from transcription.config import Settings -from transcription.config import SqliteSettings -from transcription.db import create_all -from transcription.db import initialize_database_runtime -from transcription.db import session_scope -from transcription.db.models import Document -from transcription.db.models import DocumentPerson -from transcription.db.models import Job -from transcription.db.models import JobSource -from transcription.db.models import JobSourceStatus -from transcription.db.models import JobStatus -from transcription.db.models import Person -from transcription.db.models import Source - -RevisionSeed = str +from transcription.config import Settings, SqliteSettings +from transcription.db import create_all, initialize_database_runtime, session_scope +from transcription.db.models import ( + Document, + DocumentPerson, + Job, + JobSource, + JobSourceStatus, + JobStatus, + Person, + Source, +) @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.""" tmp_path = tmp_path_factory.mktemp("ui") settings = Settings( @@ -48,94 +44,89 @@ def app_client(tmp_path_factory: pytest.TempPathFactory) -> Generator[tuple[Fast app = create_app() app.state.runtime = initialize_database_runtime(settings=settings) + + import asyncio asyncio.run(create_all(engine=app.state.runtime.engine)) + with TestClient(app) as client: yield app, client -@pytest.fixture(autouse=True) -def clear_ui_database(app_client: tuple[FastAPI, TestClient]) -> None: - """Reset UI-facing tables before each test for isolation.""" - app, _ = app_client - - async def _clear() -> None: - async with session_scope() as session: - await session.exec(delete(JobSource)) - await session.exec(delete(DocumentPerson)) - await session.exec(delete(Source)) - await session.exec(delete(Job)) - await session.exec(delete(Document)) - await session.exec(delete(Person)) - await session.commit() - - asyncio.run(_clear()) +@pytest_asyncio.fixture(autouse=True) +async def clear_ui_database(app_client: tuple[FastAPI, TestClient]) -> None: + """Reset UI-facing tables asynchronously before each test for isolation.""" + async with session_scope() as session: + await session.exec(delete(JobSource)) + await session.exec(delete(DocumentPerson)) + await session.exec(delete(Source)) + await session.exec(delete(Job)) + await session.exec(delete(Document)) + await session.exec(delete(Person)) + await session.commit() -@pytest.fixture -def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]: - """Return a helper for inserting a document/job/source/(optional revision) tuple.""" +@pytest_asyncio.fixture +async def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., AsyncGenerator[UUID, None]]: + """Return an async factory helper for seeding a Document -> Job -> Source tuple.""" app, _ = app_client fixtures_dir = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid" - def _seed( + async def _seed( *, filename: str = "sample.pdf", status: JobStatus = JobStatus.TRANSCRIBED, transcription_text: str | None = "Sample transcript text", error_detail: str | None = None, - revision_text: RevisionSeed | None = None, + revision_text: str | None = None, source_file: Path | None = None, ) -> UUID: - async def _insert() -> UUID: - async with session_scope() as session: - stored_path = app.state.settings.upload_dir / filename - stored_path.parent.mkdir(parents=True, exist_ok=True) - source_path = source_file or fixtures_dir / "small_png.png" - stored_path.write_bytes(source_path.read_bytes()) + async with session_scope() as session: + stored_path = app.state.settings.upload_dir / filename + stored_path.parent.mkdir(parents=True, exist_ok=True) + source_path = source_file or fixtures_dir / "small_png.png" + stored_path.write_bytes(source_path.read_bytes()) - document = Document(name=filename) - session.add(document) - await session.flush() + document = Document(name=filename) + session.add(document) + await session.flush() - job = Job( - document_id=document.id, - status=status, - retry_count=0, - provider="openrouter", - model="google/gemini-2.5-flash", - prompt_name="transcribe_document.md", - ) - session.add(job) - await session.flush() + job = Job( + document_id=document.id, + status=status, + retry_count=0, + provider="openrouter", + model="google/gemini-2.5-flash", + prompt_name="transcribe_document.md", + ) + session.add(job) + await session.flush() - source = Source( - document_id=document.id, - upload_name=filename, - filename=filename, - file_path=str(stored_path), - ) - session.add(source) - await session.flush() + source = Source( + document_id=document.id, + upload_name=filename, + filename=filename, + file_path=str(stored_path), + ) + session.add(source) + await session.flush() - if transcription_text is not None or error_detail is not None: - session.add( - JobSource( - job_id=job.id, - source_id=source.id, - status=JobSourceStatus.TRANSCRIBED if transcription_text is not None else JobSourceStatus.FAILED, - raw_transcription=transcription_text, - error_detail=error_detail, - ) + if transcription_text is not None or error_detail is not None: + session.add( + JobSource( + job_id=job.id, + source_id=source.id, + status=JobSourceStatus.TRANSCRIBED if transcription_text is not None else JobSourceStatus.FAILED, + raw_transcription=transcription_text, + error_detail=error_detail, ) + ) - if revision_text is not None: - source.revised_text = revision_text - source.date_revised = datetime.now(UTC) - session.add(source) + if revision_text is not None: + source.revised_text = revision_text + source.date_revised = datetime.now(UTC) + session.add(source) - await session.commit() - return job.id + await session.commit() + return job.id - return asyncio.run(_insert()) - - return _seed + return _seed \ No newline at end of file diff --git a/tests/ui/test_documents_actions.py b/tests/ui/test_documents_actions.py new file mode 100644 index 0000000..998e641 --- /dev/null +++ b/tests/ui/test_documents_actions.py @@ -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 \ No newline at end of file diff --git a/tests/ui/test_documents_page.py b/tests/ui/test_documents_page.py index ab01d4f..577656f 100644 --- a/tests/ui/test_documents_page.py +++ b/tests/ui/test_documents_page.py @@ -1,366 +1,176 @@ -"""Tests for the documents page routes.""" - -import asyncio -from datetime import UTC -from datetime import date -from datetime import datetime -from uuid import uuid4 +"""Tests for the documents page routes and action handlers.""" import pytest +from sqlmodel import select from transcription.db import session_scope -from transcription.db.models import Document -from transcription.db.models import DocumentPerson -from transcription.db.models import DocumentPersonRole -from transcription.db.models import Job -from transcription.db.models import Person -from transcription.db.models import Source +from transcription.db.models import Document, DocumentPerson, DocumentPersonRole, Job, Person, Source + + +# --- Helper Fixtures --- + + +@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 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): - """GET /ui/documents renders empty-state text when no records exist.""" _, client = app_client response = client.get("/ui/documents") assert response.status_code == 200 - assert "Documents" in response.text - assert "Create new document" in response.text + assert "Archival Documents" in response.text assert "No documents in repository yet." in response.text - def test_document_create_page_renders_fields(self, app_client): - """GET /ui/documents/new renders document-create form fields.""" + @pytest.mark.asyncio + 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 response = client.get("/ui/documents/new") assert response.status_code == 200 assert "Create Document" in response.text - assert "Document name is required." in response.text assert "Document name" in response.text - assert "Document type" in response.text assert "Author (Person)" in response.text - assert "Exact date (YYYY-MM-DD)" in response.text - assert "Approximate date" in response.text - assert "Document location" in response.text - assert "Archive identifier" in response.text - assert "Notes" in response.text - assert "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): - """GET /ui/documents lists seeded document cards.""" + @pytest.mark.asyncio + async def test_document_detail_page_renders_bento_grid_and_metadata( + self, app_client, seed_person_and_document + ): _, client = app_client + doc_id, _ = seed_person_and_document - async def _seed_document() -> None: - 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") + response = client.get(f"/ui/documents/{doc_id}") assert response.status_code == 200 - assert "Seeded Document" in response.text - assert "letter" in response.text - - def test_document_detail_page_renders_metadata_and_empty_related_sections(self, app_client): - """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 "Letter from Hig" in response.text + assert "ZC-1924-001" in response.text + assert "Zenna Cochran" in response.text + assert "Archival Metadata" 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): - """GET /ui/documents/{document_id} shows related records when present.""" + @pytest.mark.asyncio + async def test_document_jobs_page_renders_job_links(self, app_client): _, client = app_client - async def _seed_related() -> str: - async with session_scope() as session: - document = Document(name="Roster", document_type="record") - person = Person(full_name="Jane Doe") - session.add(document) - session.add(person) - await session.flush() + async with session_scope() as session: + doc = Document(name="Doc With Job", document_type="letter") + session.add(doc) + await session.flush() - session.add( - DocumentPerson( - 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.refresh(document) - return str(document.id) + job = Job(document_id=doc.id) + session.add(job) + await session.commit() + doc_id = str(doc.id) + job_id = str(job.id) - document_id = asyncio.run(_seed_related()) - - response = client.get(f"/ui/documents/{document_id}") + response = client.get(f"/ui/documents/{doc_id}/jobs") assert response.status_code == 200 - assert "Jane Doe" in response.text - assert "author" in response.text - assert "Author:" in response.text - assert "1 Source(s) Linked" in response.text - assert "1 Active Jobs" in response.text + assert "Jobs for Doc With Job" in response.text + assert f"Job ID: {job_id}" 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 + doc_id, _ = seed_person_and_document - 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(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") + response = client.get(f"/ui/documents/{doc_id}/edit") assert response.status_code == 200 assert "Edit Document Record" in response.text - assert "Document name and document type are required." in response.text - assert "Document name" in response.text - assert "Document type" in response.text - assert "Author (Person)" in response.text - assert "Exact date (YYYY-MM-DD)" in response.text - assert "Approximate date" in response.text - assert "Document location" in response.text - 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 + assert "Letter from Hig" in response.text + assert "ZC-1924-001" in response.text - def test_document_delete_page_shows_confirmation_when_unlinked(self, app_client): - """GET /ui/documents/{document_id}/delete renders permanent-action confirmation if unlinked.""" + @pytest.mark.asyncio + async def test_document_delete_page_blocks_deletion_when_dependencies_exist( + self, app_client + ): _, client = app_client - async def _seed_document() -> str: - async with session_scope() as session: - document = Document(name="Safe Delete", document_type="letter") - session.add(document) - await session.commit() - await session.refresh(document) - return str(document.id) + async with session_scope() as session: + doc = Document(name="Doc With Source", document_type="letter") + session.add(doc) + await session.flush() - document_id = asyncio.run(_seed_document()) + 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() + doc_id = str(doc.id) - response = client.get(f"/ui/documents/{document_id}/delete") + response = client.get(f"/ui/documents/{doc_id}/delete") assert response.status_code == 200 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 "Dependencies present: Sources, Jobs" in response.text - assert "Go to Jobs" in response.text + assert "Dependencies present: Sources" in response.text - def test_job_create_page_preselects_document_query_param(self, app_client): - """GET /ui/jobs/new?document_id=... includes the selected document in rendered state.""" + @pytest.mark.asyncio + async def test_document_delete_page_allows_unlinked_document_deletion(self, app_client): _, client = app_client - async def _seed_document() -> str: - async with session_scope() as session: - document = Document( - name="Preselected Document", - document_type="letter", - created_at=datetime.now(UTC), - updated_at=datetime.now(UTC), - ) - session.add(document) - await session.commit() - await session.refresh(document) - return str(document.id) + async with session_scope() as session: + doc = Document(name="Orphan Document", document_type="note") + session.add(doc) + await session.commit() + doc_id = str(doc.id) - document_id = asyncio.run(_seed_document()) - - response = client.get(f"/ui/jobs/new?document_id={document_id}") + response = client.get(f"/ui/documents/{doc_id}/delete") 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 \ No newline at end of file diff --git a/tests/ui/test_jobs_actions.py b/tests/ui/test_jobs_actions.py new file mode 100644 index 0000000..342fe2f --- /dev/null +++ b/tests/ui/test_jobs_actions.py @@ -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 \ No newline at end of file diff --git a/tests/ui/test_jobs_page.py b/tests/ui/test_jobs_page.py index a064050..72a32b4 100644 --- a/tests/ui/test_jobs_page.py +++ b/tests/ui/test_jobs_page.py @@ -1,30 +1,62 @@ -"""Tests for the jobs page route.""" - -import asyncio -from uuid import uuid4 +"""Tests for the jobs page routes and action handlers.""" import pytest +from sqlmodel import select from transcription.db import session_scope -from transcription.db.models import Document -from transcription.db.models import JobStatus +from transcription.db.models import Document, Job, JobSourceStatus, 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 -class TestPageRendering: - """Verify jobs routes render correctly with real app wiring.""" +class TestJobsPageRendering: + """Verify jobs list, creation, detail, and lifecycle action routes.""" 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 + response = client.get("/ui/jobs") 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 - def test_job_create_page_requires_existing_documents(self, app_client): - """GET /ui/jobs/new shows guidance when no Documents exist.""" + def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job): + _, 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 response = client.get("/ui/jobs/new") @@ -32,132 +64,102 @@ class TestPageRendering: assert response.status_code == 200 assert "Create Processing Job" in response.text assert "No documents available. Create a Document before creating a Job." in response.text - assert "Create document" in response.text - def test_job_create_page_lists_available_documents(self, app_client): - """GET /ui/jobs/new renders document choices when Documents exist.""" + @pytest.mark.asyncio + async def test_job_create_page_preselects_document_from_query_param(self, app_client): _, client = app_client - async def _seed_document() -> None: - async with session_scope() as session: - session.add(Document(name="Seeded Document")) - await session.commit() + async with session_scope() as session: + doc = Document(name="Preselected Journal Entry", document_type="journal") + session.add(doc) + await session.commit() + doc_id = str(doc.id) - asyncio.run(_seed_document()) - - response = client.get("/ui/jobs/new") + response = client.get(f"/ui/jobs/new?document_id={doc_id}") assert response.status_code == 200 assert "Create Processing Job" in response.text - assert "Seeded Document" in response.text - assert "Files are processed alphabetically by original filename." in response.text - assert "No files uploaded yet." in response.text - assert "Select source files or a folder" in response.text + assert "Preselected Journal Entry" in response.text - def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job): - """GET /ui/jobs lists seeded jobs from the in-memory database.""" + @pytest.mark.asyncio + async def test_job_detail_page_renders_logistics_and_links( + self, app_client, seed_document_with_unlinked_job + ): _, client = app_client - seed_job(filename="sample.pdf", status=JobStatus.TRANSCRIBED, transcription_text="done") - - 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", - ) + _, job_id = seed_document_with_unlinked_job response = client.get(f"/ui/jobs/{job_id}") assert response.status_code == 200 - assert "Job" in response.text - assert "Provider:" in response.text - assert "Model:" in response.text - assert "Prompt:" in response.text - assert "Retry Count:" in response.text - assert "Last Updated:" in response.text - assert "document links" in response.text.lower() - assert "Sources" in response.text - assert "Jobs" in response.text - assert "Delete Job" in response.text + assert f"Job Record: {job_id}" in response.text + assert "Execution Logistics" in response.text + assert "openai" in response.text + assert "gpt-4o" in response.text + assert "View Linked Document" in response.text + assert "View Linked Sources" in response.text - def test_job_detail_page_rejects_invalid_id(self, app_client): - """GET /ui/jobs/{job_id} shows validation feedback for malformed IDs.""" + @pytest.mark.asyncio + async def test_job_cancel_page_renders_confirmation( + self, app_client, seed_document_with_unlinked_job + ): _, client = app_client - response = client.get("/ui/jobs/not-a-uuid") - - 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) + _, job_id = seed_document_with_unlinked_job response = client.get(f"/ui/jobs/{job_id}/cancel") assert response.status_code == 200 assert "Cancel Processing Job" in response.text + assert "Cancel stops processing" 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 - 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") assert response.status_code == 200 assert "Resubmit Job" in response.text + assert "Non-Transcribed Sources:" 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 - job_id = seed_job(filename="delete-ready.pdf", status=JobStatus.TRANSCRIBED) - response = client.get(f"/ui/jobs/{job_id}/delete") - - assert response.status_code == 200 - assert "Delete job" in response.text - assert "This action permanently deletes the job." in response.text - assert "Delete job permanently" in response.text - - def test_job_delete_page_shows_blocked_state_when_processing(self, app_client, seed_job): - _, client = app_client - job_id = seed_job(filename="delete-blocked.pdf", status=JobStatus.PROCESSING) + async with session_scope() as session: + doc = Document(name="Processing 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.get(f"/ui/jobs/{job_id}/delete") 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 "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 \ No newline at end of file diff --git a/tests/ui/test_navigation_and_mounts.py b/tests/ui/test_navigation_and_mounts.py new file mode 100644 index 0000000..2391d1c --- /dev/null +++ b/tests/ui/test_navigation_and_mounts.py @@ -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() \ No newline at end of file diff --git a/tests/ui/test_people_actions.py b/tests/ui/test_people_actions.py new file mode 100644 index 0000000..20bc59c --- /dev/null +++ b/tests/ui/test_people_actions.py @@ -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 \ No newline at end of file diff --git a/tests/ui/test_people_page.py b/tests/ui/test_people_page.py index 3362ab8..18fe25d 100644 --- a/tests/ui/test_people_page.py +++ b/tests/ui/test_people_page.py @@ -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 uuid import uuid4 import pytest from transcription.db import session_scope -from transcription.db.models import Document -from transcription.db.models import DocumentPerson -from transcription.db.models import DocumentPersonRole -from transcription.db.models import Person +from transcription.db.models import Document, DocumentPerson, DocumentPersonRole, Person @pytest.mark.integration @@ -27,15 +23,13 @@ class TestPeoplePageRendering: assert "Create new person" 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 - async def _seed_person() -> None: - async with session_scope() as session: - session.add(Person(full_name="Ada Lovelace", display_name="Ada")) - await session.commit() - - asyncio.run(_seed_person()) + async with session_scope() as session: + session.add(Person(full_name="Ada Lovelace", display_name="Ada")) + await session.commit() response = client.get("/ui/people") @@ -56,30 +50,27 @@ class TestPeoplePageRendering: assert "Biography" 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 - async def _seed_person() -> str: - async with session_scope() as session: - person = Person( - full_name="Grace Hopper", - display_name="Grace", - maiden_name="Murray", - birth_date=date(1906, 12, 9), - birth_date_raw="1906", - birth_place="New York", - death_date=date(1992, 1, 1), - death_date_raw="1992", - death_place="Arlington", - biography="Computer pioneer", - portrait_path="/images/grace.jpg", - ) - session.add(person) - await session.commit() - await session.refresh(person) - return str(person.id) - - person_id = asyncio.run(_seed_person()) + async with session_scope() as session: + person = Person( + full_name="Grace Hopper", + display_name="Grace", + maiden_name="Murray", + birth_date=date(1906, 12, 9), + birth_date_raw="1906", + birth_place="New York", + death_date=date(1992, 1, 1), + death_date_raw="1992", + death_place="Arlington", + biography="Computer pioneer", + portrait_path="/images/grace.jpg", + ) + session.add(person) + await session.commit() + person_id = str(person.id) response = client.get(f"/ui/people/{person_id}") @@ -97,51 +88,44 @@ class TestPeoplePageRendering: assert "Created:" in response.text assert "Updated:" 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 - async def _seed_person() -> str: - async with session_scope() as session: - person = Person( - full_name="Portrait Person", - portrait_path="portraits/person/seeded.png", - ) - session.add(person) - await session.commit() - await session.refresh(person) - return str(person.id) + async with session_scope() as session: + person = Person( + full_name="Portrait Person", + portrait_path="portraits/person/seeded.png", + ) + session.add(person) + await session.commit() + person_id = str(person.id) - person_id = asyncio.run(_seed_person()) response = client.get(f"/ui/people/{person_id}") assert response.status_code == 200 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 - async def _seed_links() -> str: - async with session_scope() as session: - person = Person(full_name="Linked Person") - document = Document(name="Linked Document", document_type="letter") - session.add(person) - session.add(document) - await session.flush() + async with session_scope() as session: + person = Person(full_name="Linked Person") + document = Document(name="Linked Document", document_type="letter") + session.add_all([person, document]) + await session.flush() - session.add( - DocumentPerson( - document_id=document.id, - person_id=person.id, - role=DocumentPersonRole.AUTHOR, - ) + 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()) + ) + await session.commit() + person_id = str(person.id) response = client.get(f"/ui/people/{person_id}") @@ -165,73 +149,37 @@ class TestPeoplePageRendering: assert response.status_code == 200 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 - async def _seed_person() -> str: - async with session_scope() as session: - person = Person(full_name="Editable Person", display_name="EP") - session.add(person) - await session.commit() - await session.refresh(person) - return str(person.id) - - person_id = asyncio.run(_seed_person()) + async with session_scope() as session: + person = Person(full_name="Editable Person", display_name="EP") + session.add(person) + await session.commit() + person_id = str(person.id) response = client.get(f"/ui/people/{person_id}/edit") assert response.status_code == 200 assert "Edit Person Record" 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 - 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 - async def _seed_person() -> str: - async with session_scope() as session: - person = Person(full_name="Safe Delete") - session.add(person) - await session.commit() - await session.refresh(person) - return str(person.id) - - person_id = asyncio.run(_seed_person()) + async with session_scope() as session: + person = Person(full_name="Safe Delete") + session.add(person) + await session.commit() + person_id = str(person.id) response = client.get(f"/ui/people/{person_id}/delete") assert response.status_code == 200 assert "Delete Person Record" in response.text assert "This action permanently deletes the person record." 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 + assert "Delete person permanently" in response.text \ No newline at end of file diff --git a/tests/ui/test_sources_actions.py b/tests/ui/test_sources_actions.py new file mode 100644 index 0000000..3cef0dc --- /dev/null +++ b/tests/ui/test_sources_actions.py @@ -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 \ No newline at end of file