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