Revamped the Documents, People, & Jobs too.

This commit is contained in:
Jim Lancaster
2026-08-05 13:05:41 -05:00
parent 72bc96ab3a
commit fd3ca60008
15 changed files with 1719 additions and 1338 deletions
@@ -39,21 +39,61 @@ def _serialize_rows(rows: Sequence[DocumentTableRow]) -> list[dict[str, Any]]:
def render_documents_table(rows: Sequence[DocumentTableRow]) -> None:
"""Render documents table and open detail page when clicking a row."""
"""Render documents table with search filtering and custom type chips."""
if not rows:
with archival_card(extra_classes="p-8 text-center"):
render_empty_state("No documents in repository yet.")
return
build_table(
table = build_table(
rows=_serialize_rows(rows),
columns=[
{"name": "name", "label": "Document Title", "field": "name", "sortable": True, "classes": "font-serif font-semibold"},
{"name": "document_type", "label": "Type", "field": "document_type", "sortable": True},
{"name": "archive_identifier", "label": "Archive Ref", "field": "archive_identifier", "sortable": True, "classes": "font-mono"},
{"name": "created_at", "label": "Created", "field": "created_at", "sortable": True},
{
"name": "name",
"label": "Document Title",
"field": "name",
"sortable": True,
"classes": "font-serif font-semibold",
},
{
"name": "document_type",
"label": "Type",
"field": "document_type",
"sortable": True,
},
{
"name": "archive_identifier",
"label": "Archive Ref",
"field": "archive_identifier",
"sortable": True,
"classes": "font-mono text-xs",
},
{
"name": "created_at",
"label": "Created",
"field": "created_at",
"sortable": True,
},
],
default_sort_by="name",
classes="app-table w-full",
search_placeholder="Search documents by title, type, or reference...",
on_row_click_id=lambda doc_id: ui.navigate.to(f"/documents/{doc_id}"),
)
# Render document type using a subtle Quasar badge
table.add_slot(
"body-cell-document_type",
r"""
<q-td :props="props">
<q-chip
dense
square
size="sm"
color="primary"
text-color="white"
>
{{ props.value }}
</q-chip>
</q-td>
""",
)
+68 -13
View File
@@ -4,8 +4,7 @@ from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import UTC
from datetime import datetime
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
@@ -29,7 +28,7 @@ class JobTableRow:
def _format_timestamp(value: str) -> str:
"""Return a friendly UTC timestamp for table display."""
"""Return a friendly local timestamp for table display."""
try:
parsed = datetime.fromisoformat(value)
except ValueError:
@@ -42,7 +41,7 @@ def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]:
return [
{
"id": str(row.id),
"status": row.status.upper(),
"status": row.status.lower(),
"filename": row.filename,
"retry_count": row.retry_count,
"date_created": _format_timestamp(row.date_created),
@@ -55,24 +54,80 @@ def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]:
def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
"""Render jobs table and open a detail page when clicking a row."""
"""Render jobs table with search filtering and custom status chips."""
if not rows:
with archival_card(extra_classes="p-8 text-center"):
render_empty_state("No active or historical processing jobs found.")
return
build_table(
table = build_table(
rows=_serialize_rows(rows),
columns=[
{"name": "id", "label": "Job ID", "field": "id", "sortable": True, "classes": "font-mono"},
{"name": "status", "label": "Status", "field": "status", "sortable": True, "classes": "font-semibold ui-link-primary"},
{"name": "filename", "label": "Source Filename", "field": "filename", "sortable": True, "classes": "font-mono"},
{"name": "retry_count", "label": "Retries", "field": "retry_count", "sortable": True},
{"name": "date_created", "label": "Created", "field": "date_created", "sortable": True},
{"name": "date_updated", "label": "Updated", "field": "date_updated", "sortable": True},
{
"name": "id",
"label": "Job ID",
"field": "id",
"sortable": True,
"classes": "font-mono text-xs",
},
{
"name": "status",
"label": "Status",
"field": "status",
"sortable": True,
"classes": "font-mono",
},
{
"name": "filename",
"label": "Source Filename",
"field": "filename",
"sortable": True,
"classes": "font-mono text-xs",
},
{
"name": "retry_count",
"label": "Retries",
"field": "retry_count",
"sortable": True,
},
{
"name": "date_created",
"label": "Created",
"field": "created_sort",
"sortable": True,
},
{
"name": "date_updated",
"label": "Updated",
"field": "updated_sort",
"sortable": True,
},
],
default_sort_by="created_sort",
default_descending=True,
classes="app-table w-full",
search_placeholder="Search jobs by ID, filename, or status...",
on_row_click_id=lambda job_id: ui.navigate.to(f"/jobs/{job_id}"),
)
# Render job execution status using themed Quasar chips
table.add_slot(
"body-cell-status",
r"""
<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:
"""Render people table and open detail page when clicking a row."""
"""Render people table with search filtering and custom typography."""
if not rows:
with archival_card(extra_classes="p-8 text-center"):
render_empty_state("No person records found in repository.")
return
build_table(
table = build_table(
rows=_serialize_rows(rows),
columns=[
{"name": "full_name", "label": "Full Name", "field": "full_name", "sortable": True, "classes": "font-serif font-semibold"},
{"name": "display_name", "label": "Display Name", "field": "display_name", "sortable": True},
{"name": "maiden_name", "label": "Maiden Name", "field": "maiden_name", "sortable": True},
{"name": "birth_date", "label": "Birth Date", "field": "birth_date", "sortable": True},
{
"name": "full_name",
"label": "Full Name",
"field": "full_name",
"sortable": True,
"classes": "font-serif font-semibold",
},
{
"name": "display_name",
"label": "Display Name",
"field": "display_name",
"sortable": True,
},
{
"name": "maiden_name",
"label": "Maiden Name",
"field": "maiden_name",
"sortable": True,
},
{
"name": "birth_date",
"label": "Birth Date",
"field": "birth_date",
"sortable": True,
"classes": "font-mono text-xs",
},
],
default_sort_by="full_name",
classes="app-table w-full",
search_placeholder="Search people by name or birth date...",
on_row_click_id=lambda person_id: ui.navigate.to(f"/people/{person_id}"),
)
# Custom column template adding an archival entity icon next to person's name
table.add_slot(
"body-cell-full_name",
r"""
<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>
""",
)
+180 -284
View File
@@ -9,26 +9,25 @@ from fastapi import Request
from fastapi.responses import RedirectResponse
from nicegui import ui
from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import Document, DocumentPerson, DocumentPersonRole
from transcription.errors import ErrorCategory
from transcription.services.documents import DocumentDeleteBlockedError
from transcription.services.documents import DocumentError
from transcription.services.documents import DocumentService
from transcription.services.documents import (
DocumentDeleteBlockedError,
DocumentError,
DocumentService,
)
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card
from transcription.ui.components.data_display import archival_badge
from transcription.ui.components.data_display import metadata_row
from transcription.ui.components.data_display import archival_badge, metadata_row
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.primitives import destructive_button
from transcription.ui.components.primitives import render_empty_state
from transcription.ui.components.primitives import section_header_row
from transcription.ui.components.table.documents import DocumentTableRow
from transcription.ui.components.table.documents import render_documents_table
from transcription.ui.components.primitives import (
destructive_button,
render_empty_state,
section_header_row,
)
from transcription.ui.components.table.documents import DocumentTableRow, render_documents_table
from transcription.ui.components.viewers import dark_room_viewer
from transcription.ui.theme import apply_archival_theme
from transcription.ui.theme import page_header
from transcription.ui.theme import apply_archival_theme, page_header
from ...db.session import SessionFactoryDep
@@ -47,61 +46,31 @@ def register_page() -> None:
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
page_header("Create Document", subtitle="Document name is required.")
with archival_card(extra_classes="gap-3"):
name_input = ui.input(label="Document name").props("outlined bg-white").classes("w-full")
document_type_input = ui.input(label="Document type").props("outlined bg-white").classes("w-full")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
date_input = ui.input(label="Exact date (YYYY-MM-DD)").props('outlined bg-white type="date"')
date_raw_input = ui.input(label="Approximate date").props("outlined bg-white")
location_input = ui.input(label="Document location").props("outlined bg-white").classes("w-full")
archive_input = ui.input(label="Archive identifier").props("outlined bg-white").classes("w-full")
notes_input = ui.textarea(label="Notes").props("outlined bg-white autogrow").classes("w-full")
people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold())
author_options = (
{"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"}
| {str(person.id): person.full_name for person in people}
)
def on_author_change(event) -> None:
selected = str(event.value or "").strip()
if selected == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
author_select = (
ui.select(author_options, label="Author (Person)", value="", on_change=on_author_change)
.props("outlined bg-white")
.classes("w-full")
)
ui.link("Create new person", "/people/new").classes("text-xs ui-link-primary font-medium")
people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold())
form = _render_document_form_fields(people=people)
requested_doc_id = request.query_params.get("document_id")
return_to = request.query_params.get("return_to")
async def submit_create() -> None:
candidate_name = (name_input.value or "").strip()
candidate_name = (form["name"].value or "").strip()
if not candidate_name:
ui.notify("Document name is required.", type="warning")
return
parsed_date: date | None = None
candidate_date_text = (date_input.value or "").strip()
if candidate_date_text:
try:
parsed_date = date.fromisoformat(candidate_date_text)
except ValueError:
ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
return
parsed_date = _parse_iso_date(form["date"].value)
if form["date"].value and parsed_date is None:
ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
return
candidate = Document(
name=candidate_name,
document_type=(document_type_input.value or "").strip() or None,
document_type=(form["type"].value or "").strip() or None,
document_date=parsed_date,
document_date_raw=(date_raw_input.value or "").strip() or None,
location_created=(location_input.value or "").strip() or None,
notes=(notes_input.value or "").strip() or None,
archive_identifier=(archive_input.value or "").strip() or None,
document_date_raw=(form["date_raw"].value or "").strip() or None,
location_created=(form["location"].value or "").strip() or None,
notes=(form["notes"].value or "").strip() or None,
archive_identifier=(form["archive"].value or "").strip() or None,
)
try:
@@ -110,14 +79,13 @@ def register_page() -> None:
show_error(exc, title="Create failed", operation="documents.create")
return
selected_author = (author_select.value or "").strip()
selected_author = (form["author"].value or "").strip()
if selected_author == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
return
if selected_author:
try:
parsed_person_id = UUID(selected_author)
except ValueError:
parsed_author_id = _parse_uuid(selected_author)
if parsed_author_id is None:
ui.notify("Selected author is invalid.", type="warning")
return
@@ -125,7 +93,7 @@ def register_page() -> None:
await document_service.create_document_person(
DocumentPerson(
document_id=created.id,
person_id=parsed_person_id,
person_id=parsed_author_id,
role=DocumentPersonRole.AUTHOR,
)
)
@@ -168,7 +136,6 @@ def register_page() -> None:
show_error(exc, title="Load failed", operation="documents.list")
return
# Format documents into read-model rows for the table renderer
rows = [
DocumentTableRow(
id=doc.id,
@@ -187,14 +154,13 @@ def register_page() -> None:
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
try:
parsed_document_id = UUID(document_id)
except ValueError:
parsed_doc_id = _parse_uuid(document_id)
if parsed_doc_id is None:
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
return
try:
document = await document_service.read_document_detail(document_id=parsed_document_id)
document = await document_service.read_document_detail(document_id=parsed_doc_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return
@@ -202,18 +168,7 @@ def register_page() -> None:
show_error(exc, title="Load failed", operation="documents.read")
return
author_link = next(
(
item
for item in document.document_people
if item.role == DocumentPersonRole.AUTHOR and item.person is not None
),
None,
)
# Main Bento Grid Wrapper
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
# Header Bar
with section_header_row():
page_header(document.name, subtitle=f"Type: {document.document_type or 'Unspecified'} | ID: {document.id}")
@@ -230,73 +185,10 @@ def register_page() -> None:
extra_classes="text-xs",
)
# High-Density Bento Grid Layout
with ui.grid().classes("w-full grid-cols-12 gap-4"):
# ZONE 1: Source Image Viewer (Cols 1-5)
with ui.column().classes("col-span-12 lg:col-span-5"):
source_path = document.sources[0].file_path if document.sources else None
dark_room_viewer(source_path, count_label=f"{len(document.sources)} Source(s) Linked")
with ui.row().classes("w-full justify-between items-center mt-2"):
ui.button(
"View All Sources",
on_click=lambda: ui.navigate.to(f"/sources?document_id={document.id}"),
icon="description",
).props("flat dense text-xs").classes("ui-link-primary")
ui.button(
"+ Add Source",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="add",
).classes("ui-btn-primary text-xs")
# ZONE 2: Metadata & Archival Attributes (Cols 6-8)
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Archival Metadata"):
metadata_row("Author:", author_link.person.full_name if author_link and author_link.person else "Not set")
metadata_row("Exact Date:", document.document_date.isoformat() if document.document_date else "Not set")
metadata_row("Approx. Date:", document.document_date_raw or "Not set")
metadata_row("Location Created:", document.location_created or "Not set")
metadata_row("Archive Identifier:", document.archive_identifier or "Not set")
with ui.column().classes("w-full mt-2"):
ui.label("Archival Notes:").classes("ui-text-muted text-xs mb-1")
ui.label(document.notes or "No notes added.").classes(
"p-2 ui-note-box text-xs"
)
with archival_card(title="System Logistics"):
ui.label(f"Created: {document.created_at.isoformat()}").classes("text-[11px] ui-text-muted")
ui.label(f"Updated: {document.updated_at.isoformat()}").classes("text-[11px] ui-text-muted")
# ZONE 3: Related Entities & Pipeline Jobs (Cols 9-12)
with ui.column().classes("col-span-12 lg:col-span-3 gap-4"):
with archival_card(title="Related People"):
if not document.document_people:
render_empty_state("No linked people yet.", italic=True)
else:
with ui.column().classes("w-full gap-2"):
for link in document.document_people:
person_label = link.person.full_name if link.person is not None else "Unknown person"
with ui.row().classes(
"w-full justify-between items-center ui-row-surface p-2"
):
ui.label(person_label).classes("text-xs font-semibold ui-text-primary")
archival_badge(link.role.value)
with archival_card(title="Pipeline Jobs"):
with ui.row().classes("w-full justify-between items-center mb-2"):
ui.label(f"{len(document.jobs)} Active Jobs").classes("text-xs ui-link-primary font-bold")
with ui.row().classes("w-full gap-2 mt-2"):
ui.button(
"View Jobs",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"),
icon="work_history",
).props("flat dense text-xs").classes("ui-link-primary")
ui.button(
"+ Add Job",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="add",
).classes("ui-btn-primary text-xs")
_render_bento_viewer_zone(document)
_render_bento_metadata_zone(document)
_render_bento_relations_zone(document)
@ui.page("/documents/{document_id}/jobs")
async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> None:
@@ -304,14 +196,13 @@ def register_page() -> None:
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
try:
parsed_document_id = UUID(document_id)
except ValueError:
parsed_doc_id = _parse_uuid(document_id)
if parsed_doc_id is None:
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
return
try:
document = await document_service.read_document_detail(document_id=parsed_document_id)
document = await document_service.read_document_detail(document_id=parsed_doc_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return
@@ -323,16 +214,8 @@ def register_page() -> None:
with section_header_row():
page_header(f"Jobs for {document.name}")
with ui.row().classes("gap-2"):
ui.button(
"Back to Document",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}"),
icon="arrow_back",
).props("flat")
ui.button(
"Create Job",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="add",
).classes("ui-btn-primary")
ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props("flat")
ui.button("Create Job", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="add").classes("ui-btn-primary")
if not document.jobs:
with archival_card(extra_classes="p-6 text-center"):
@@ -345,11 +228,7 @@ def register_page() -> None:
with ui.row().classes("items-center gap-2"):
archival_badge(job.status.value)
ui.label(f"Job ID: {job.id}").classes("text-xs font-mono ui-text-primary")
ui.button(
"Open Job",
on_click=lambda _=None, job_id=job.id: ui.navigate.to(f"/jobs/{job_id}"),
icon="open_in_new",
).props("flat dense").classes("text-xs ui-link-primary")
ui.button("Open Job", on_click=lambda _=None, jid=job.id: ui.navigate.to(f"/jobs/{jid}"), icon="open_in_new").props("flat dense").classes("text-xs ui-link-primary")
@ui.page("/documents/{document_id}/sources")
async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse:
@@ -362,14 +241,13 @@ def register_page() -> None:
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
try:
parsed_document_id = UUID(document_id)
except ValueError:
parsed_doc_id = _parse_uuid(document_id)
if parsed_doc_id is None:
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
return
try:
document = await document_service.read_document_detail(document_id=parsed_document_id)
document = await document_service.read_document_detail(document_id=parsed_doc_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return
@@ -380,69 +258,13 @@ def register_page() -> None:
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
page_header("Edit Document Record", subtitle="Document name and document type are required.")
with archival_card(extra_classes="gap-3"):
name_input = ui.input(label="Document name", value=document.name).props("outlined bg-white").classes("w-full")
document_type_input = (
ui.input(label="Document type", value=document.document_type or "")
.props("outlined bg-white")
.classes("w-full")
)
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
date_input = ui.input(
label="Exact date (YYYY-MM-DD)",
value=document.document_date.isoformat() if document.document_date else "",
).props('outlined bg-white type="date"')
date_raw_input = (
ui.input(label="Approximate date", value=document.document_date_raw or "")
.props("outlined bg-white")
)
location_input = (
ui.input(label="Document location", value=document.location_created or "")
.props("outlined bg-white")
.classes("w-full")
)
archive_input = (
ui.input(label="Archive identifier", value=document.archive_identifier or "")
.props("outlined bg-white")
.classes("w-full")
)
notes_input = (
ui.textarea(label="Notes", value=document.notes or "").props("outlined bg-white autogrow").classes("w-full")
)
people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold())
author_options = (
{"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"}
| {str(person.id): person.full_name for person in people}
)
existing_author = next(
(item for item in document.document_people if item.role == DocumentPersonRole.AUTHOR),
None,
)
author_value = str(existing_author.person_id) if existing_author is not None else ""
def on_author_change(event) -> None:
selected = str(event.value or "").strip()
if selected == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
author_select = (
ui.select(
author_options,
label="Author (Person)",
value=author_value,
on_change=on_author_change,
)
.props("outlined bg-white")
.classes("w-full")
)
ui.link("Create new person", "/people/new").classes("text-xs ui-link-primary font-medium")
people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold())
existing_author = next((link for link in document.document_people if link.role == DocumentPersonRole.AUTHOR), None)
form = _render_document_form_fields(document=document, people=people, existing_author_id=existing_author.person_id if existing_author else None)
async def submit_edit() -> None:
candidate_name = (name_input.value or "").strip()
candidate_type = (document_type_input.value or "").strip()
candidate_name = (form["name"].value or "").strip()
candidate_type = (form["type"].value or "").strip()
if not candidate_name:
ui.notify("Document name is required.", type="warning")
return
@@ -450,24 +272,20 @@ def register_page() -> None:
ui.notify("Document type is required.", type="warning")
return
parsed_date: date | None = None
candidate_date_text = (date_input.value or "").strip()
if candidate_date_text:
try:
parsed_date = date.fromisoformat(candidate_date_text)
except ValueError:
ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
return
parsed_date = _parse_iso_date(form["date"].value)
if form["date"].value and parsed_date is None:
ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
return
candidate = Document(
id=document.id,
name=candidate_name,
document_type=candidate_type,
document_date=parsed_date,
document_date_raw=(date_raw_input.value or "").strip() or None,
location_created=(location_input.value or "").strip() or None,
notes=(notes_input.value or "").strip() or None,
archive_identifier=(archive_input.value or "").strip() or None,
document_date_raw=(form["date_raw"].value or "").strip() or None,
location_created=(form["location"].value or "").strip() or None,
notes=(form["notes"].value or "").strip() or None,
archive_identifier=(form["archive"].value or "").strip() or None,
created_at=document.created_at,
updated_at=document.updated_at,
)
@@ -478,13 +296,12 @@ def register_page() -> None:
show_error(exc, title="Save failed", operation="documents.edit.save")
return
selected_author = (author_select.value or "").strip()
selected_author = (form["author"].value or "").strip()
if selected_author == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
return
existing_author_links = [
link for link in document.document_people if link.role == DocumentPersonRole.AUTHOR
]
existing_author_links = [link for link in document.document_people if link.role == DocumentPersonRole.AUTHOR]
try:
if not selected_author:
for link in existing_author_links:
@@ -495,11 +312,7 @@ def register_page() -> None:
for link in existing_author_links:
await document_service.delete_document_person(link)
await document_service.create_document_person(
DocumentPerson(
document_id=document.id,
person_id=selected_author_id,
role=DocumentPersonRole.AUTHOR,
)
DocumentPerson(document_id=document.id, person_id=selected_author_id, role=DocumentPersonRole.AUTHOR)
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Author update failed", operation="documents.edit.link_author")
@@ -510,9 +323,7 @@ def register_page() -> None:
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save changes", on_click=submit_edit, icon="save").classes("ui-btn-primary")
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props(
"flat"
)
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props("flat")
@ui.page("/documents/{document_id}/delete")
async def document_delete_page(document_id: str, session_factory: SessionFactoryDep) -> None:
@@ -520,14 +331,13 @@ def register_page() -> None:
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
try:
parsed_document_id = UUID(document_id)
except ValueError:
parsed_doc_id = _parse_uuid(document_id)
if parsed_doc_id is None:
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
return
try:
document = await document_service.read_document_detail(document_id=parsed_document_id)
document = await document_service.read_document_detail(document_id=parsed_doc_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return
@@ -541,28 +351,15 @@ def register_page() -> None:
with archival_card(extra_classes="gap-2"):
ui.label(f"Document: {document.name}").classes("text-sm font-semibold ui-text-primary")
has_sources = bool(document.sources)
has_jobs = bool(document.jobs)
if has_sources or has_jobs:
if document.sources or document.jobs:
ui.label("Delete is blocked because related records exist.").classes("text-xs text-red-800 font-bold mt-2")
categories: list[str] = []
if has_sources:
categories.append("Sources")
if has_jobs:
categories.append("Jobs")
ui.label(f"Dependencies present: {', '.join(categories)}").classes("text-xs ui-text-muted")
deps = [cat for cat, present in [("Sources", bool(document.sources)), ("Jobs", bool(document.jobs))] if present]
ui.label(f"Dependencies present: {', '.join(deps)}").classes("text-xs ui-text-muted")
ui.label("Remove related records first, then retry deletion.").classes("text-xs ui-text-muted italic")
with ui.row().classes("w-full items-center gap-2 mt-4"):
ui.button(
"Back to Document",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}"),
icon="arrow_back",
).classes("ui-btn-primary text-xs")
ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
"flat text-xs"
)
ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").classes("ui-btn-primary text-xs")
ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props("flat text-xs")
return
ui.label("This action permanently deletes the document.").classes("text-xs text-red-800 font-medium")
@@ -589,12 +386,111 @@ def register_page() -> None:
ui.navigate.to("/documents")
with ui.row().classes("w-full items-center gap-2 mt-2"):
destructive_button(
"Delete document permanently",
on_click=submit_delete,
icon="delete_forever",
variant="solid",
)
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props(
"flat"
)
destructive_button("Delete document permanently", on_click=submit_delete, icon="delete_forever", variant="solid")
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props("flat")
# --- Helper Sub-Components ---
def _render_document_form_fields(
*, document: Document | None = None, people: list[Any], existing_author_id: UUID | None = None
) -> dict[str, Any]:
with archival_card(extra_classes="gap-3"):
name_input = ui.input(label="Document name", value=document.name if document else "").props("outlined bg-white").classes("w-full")
type_input = ui.input(label="Document type", value=document.document_type if document and document.document_type else "").props("outlined bg-white").classes("w-full")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
date_input = ui.input(
label="Exact date (YYYY-MM-DD)",
value=document.document_date.isoformat() if document and document.document_date else "",
).props('outlined bg-white type="date"')
date_raw_input = ui.input(label="Approximate date", value=document.document_date_raw if document and document.document_date_raw else "").props("outlined bg-white")
location_input = ui.input(label="Document location", value=document.location_created if document and document.location_created else "").props("outlined bg-white").classes("w-full")
archive_input = ui.input(label="Archive identifier", value=document.archive_identifier if document and document.archive_identifier else "").props("outlined bg-white").classes("w-full")
notes_input = ui.textarea(label="Notes", value=document.notes if document and document.notes else "").props("outlined bg-white autogrow").classes("w-full")
author_options = {"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"} | {str(p.id): p.full_name for p in people}
author_select = ui.select(author_options, label="Author (Person)", value=str(existing_author_id) if existing_author_id else "").props("outlined bg-white").classes("w-full")
return {
"name": name_input,
"type": type_input,
"date": date_input,
"date_raw": date_raw_input,
"location": location_input,
"archive": archive_input,
"notes": notes_input,
"author": author_select,
}
def _render_bento_viewer_zone(document: Document) -> None:
with ui.column().classes("col-span-12 lg:col-span-5"):
source_path = document.sources[0].file_path if document.sources else None
dark_room_viewer(source_path, count_label=f"{len(document.sources)} Source(s) Linked")
with ui.row().classes("w-full justify-between items-center mt-2"):
ui.button("View All Sources", on_click=lambda: ui.navigate.to(f"/sources?document_id={document.id}"), icon="description").props("flat dense text-xs").classes("ui-link-primary")
ui.button("+ Add Source", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="add").classes("ui-btn-primary text-xs")
def _render_bento_metadata_zone(document: Document) -> None:
author_link = next((item for item in document.document_people if item.role == DocumentPersonRole.AUTHOR and item.person is not None), None)
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Archival Metadata"):
metadata_row("Author:", author_link.person.full_name if author_link and author_link.person else "Not set")
metadata_row("Exact Date:", document.document_date.isoformat() if document.document_date else "Not set")
metadata_row("Approx. Date:", document.document_date_raw or "Not set")
metadata_row("Location Created:", document.location_created or "Not set")
metadata_row("Archive Identifier:", document.archive_identifier or "Not set")
with ui.column().classes("w-full mt-2"):
ui.label("Archival Notes:").classes("ui-text-muted text-xs mb-1")
ui.label(document.notes or "No notes added.").classes("p-2 ui-note-box text-xs")
with archival_card(title="System Logistics"):
ui.label(f"Created: {document.created_at.isoformat()}").classes("text-[11px] ui-text-muted")
ui.label(f"Updated: {document.updated_at.isoformat()}").classes("text-[11px] ui-text-muted")
def _render_bento_relations_zone(document: Document) -> None:
with ui.column().classes("col-span-12 lg:col-span-3 gap-4"):
with archival_card(title="Related People"):
if not document.document_people:
render_empty_state("No linked people yet.", italic=True)
else:
with ui.column().classes("w-full gap-2"):
for link in document.document_people:
person_label = link.person.full_name if link.person is not None else "Unknown person"
with ui.row().classes("w-full justify-between items-center ui-row-surface p-2"):
ui.label(person_label).classes("text-xs font-semibold ui-text-primary")
archival_badge(link.role.value)
with archival_card(title="Pipeline Jobs"):
with ui.row().classes("w-full justify-between items-center mb-2"):
ui.label(f"{len(document.jobs)} Active Jobs").classes("text-xs ui-link-primary font-bold")
with ui.row().classes("w-full gap-2 mt-2"):
ui.button("View Jobs", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"), icon="work_history").props("flat dense text-xs").classes("ui-link-primary")
ui.button("+ Add Job", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="add").classes("ui-btn-primary text-xs")
def _parse_uuid(value: str | None) -> UUID | None:
if not value:
return None
try:
return UUID(value)
except ValueError:
return None
def _parse_iso_date(value: str | None) -> date | None:
candidate = (value or "").strip()
if not candidate:
return None
try:
return date.fromisoformat(candidate)
except ValueError:
return None
+178 -171
View File
@@ -8,30 +8,30 @@ from uuid import UUID
from fastapi import Request
from nicegui import ui
from transcription.db.models import JobSourceStatus
from transcription.db.models import JobStatus
from transcription.db.models import Job, JobSourceStatus, JobStatus
from transcription.db.session import session_scope
from transcription.services.documents import DocumentService
from transcription.services.jobs import JobDeleteBlockedError
from transcription.services.jobs import JobCancelBlockedError
from transcription.services.jobs import JobResubmitBlockedError
from transcription.services.jobs import JobService
from transcription.services.jobs import (
JobCancelBlockedError,
JobDeleteBlockedError,
JobResubmitBlockedError,
JobService,
)
from transcription.services.store import create_job_for_document
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card
from transcription.ui.components.data_display import archival_badge
from transcription.ui.components.data_display import metadata_row
from transcription.ui.components.data_display import archival_badge, metadata_row
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.primitives import destructive_button
from transcription.ui.components.primitives import render_empty_state
from transcription.ui.components.primitives import section_header_row
from transcription.ui.components.table.jobs import render_jobs_table
from transcription.ui.theme import apply_archival_theme
from transcription.ui.theme import page_header
from transcription.ui.components.primitives import (
destructive_button,
render_empty_state,
section_header_row,
)
from transcription.ui.components.table.jobs import JobTableRow, render_jobs_table
from transcription.ui.theme import apply_archival_theme, page_header
from transcription.worker import resolve_worker_notifier
from ...db.session import SessionFactoryDep
from ..components.table.jobs import JobTableRow
def register_page() -> None: # noqa: PLR0915
@@ -80,27 +80,15 @@ def register_page() -> None: # noqa: PLR0915
documents = await documents_service.list_documents()
if not documents:
with archival_card(extra_classes="p-6 text-center"):
render_empty_state(
"No documents available. Create a Document before creating a Job.",
extra_classes="text-red-800 font-medium mb-4",
)
with ui.row().classes("justify-center gap-2"):
ui.button(
"Create document",
on_click=lambda: ui.navigate.to("/documents/new?return_to=jobs_new"),
icon="note_add",
).classes("ui-btn-primary")
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
_render_no_documents_card()
return
uploaded_files: list[tuple[str, bytes]] = []
with archival_card(extra_classes="gap-3"):
document_options = {str(document.id): document.name for document in documents}
document_select = (
ui.select(document_options, label="Target Document").props("outlined bg-white").classes("w-full")
)
document_options = {str(doc.id): doc.name for doc in documents}
document_select = ui.select(document_options, label="Target Document").props("outlined bg-white").classes("w-full")
requested_document_id = request.query_params.get("document_id")
if requested_document_id in document_options:
document_select.value = requested_document_id
@@ -110,65 +98,10 @@ def register_page() -> None: # noqa: PLR0915
model_input = ui.input(label="Model").props("outlined bg-white")
prompt_input = ui.input(label="Prompt").props("outlined bg-white")
with archival_card(title="Source Files"):
ui.label(
"Files are processed alphabetically by original filename. Use leading numbers such as 001, 002, 003 to control order."
).classes("text-xs ui-text-muted mb-2")
@ui.refreshable
def render_upload_list() -> None:
if not uploaded_files:
render_empty_state("No files uploaded yet.", italic=True)
return
def remove_file(index: int) -> None:
if 0 <= index < len(uploaded_files):
removed_name, _ = uploaded_files.pop(index)
ui.notify(f"Removed {removed_name}", type="info")
render_upload_list.refresh()
def clear_files() -> None:
uploaded_files.clear()
ui.notify("Cleared queued files", type="info")
render_upload_list.refresh()
ordered_uploads = sorted(
enumerate(uploaded_files),
key=lambda item: Path(item[1][0]).name.casefold(),
)
with ui.column().classes("gap-1 w-full mt-2"):
for index, (filename, _) in ordered_uploads:
with ui.row().classes(
"w-full items-center justify-between ui-row-surface p-2"
):
ui.label(Path(filename).name).classes("text-xs font-mono ui-text-primary")
ui.button(icon="delete", on_click=lambda idx=index: remove_file(idx)).props(
"flat round dense color=negative text-xs"
)
with ui.row().classes("w-full justify-end mt-2"):
ui.button("Clear files", on_click=clear_files, icon="clear_all").props("flat dense").classes(
"text-xs text-red-800"
)
async def on_upload(event) -> None:
payload = await event.file.read()
uploaded_files.append((event.file.name, payload))
ui.notify(f"Added {event.file.name}", type="positive")
render_upload_list.refresh()
ui.upload(
on_upload=on_upload,
auto_upload=True,
label="Select source files or a folder",
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf" webkitdirectory directory multiple').classes("w-full")
render_upload_list()
_render_upload_section(uploaded_files)
async def submit_create() -> None:
selected_document = document_select.value
if not selected_document:
if not document_select.value:
ui.notify("Document is required.", type="warning")
return
if not uploaded_files:
@@ -176,7 +109,7 @@ def register_page() -> None: # noqa: PLR0915
return
try:
document_id = UUID(str(selected_document))
document_id = UUID(str(document_select.value))
except ValueError:
ui.notify("Selected document id is invalid.", type="warning")
return
@@ -200,9 +133,7 @@ def register_page() -> None: # noqa: PLR0915
ui.navigate.to(f"/jobs/{result.job_id}")
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Submit for transcription", on_click=submit_create, icon="play_arrow").classes(
"ui-btn-primary"
)
ui.button("Submit for transcription", on_click=submit_create, icon="play_arrow").classes("ui-btn-primary")
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
@ui.page("/jobs/{job_id}")
@@ -211,9 +142,8 @@ def register_page() -> None: # noqa: PLR0915
jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
try:
parsed_job_id = UUID(job_id)
except ValueError:
parsed_job_id = _parse_uuid(job_id)
if parsed_job_id is None:
ui.label("Invalid job id").classes("text-h6 text-red-800 p-4")
return
@@ -224,52 +154,11 @@ def register_page() -> None: # noqa: PLR0915
return
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
with section_header_row(classes="justify-between items-center"):
page_header(f"Job Record: {job.id}")
with ui.row().classes("items-center gap-2"):
archival_badge(job.status.value.upper())
if job.status in {JobStatus.QUEUED, JobStatus.PROCESSING}:
destructive_button(
"Cancel",
on_click=lambda: ui.navigate.to(f"/jobs/{job.id}/cancel"),
icon="stop_circle",
extra_classes="text-xs",
)
if job.status != JobStatus.TRANSCRIBED:
ui.button("Resubmit", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}/resubmit"), icon="replay").props(
"outlined"
).classes("text-xs")
destructive_button(
"Delete Job",
on_click=lambda: ui.navigate.to(f"/jobs/{job.id}/delete"),
icon="delete",
extra_classes="text-xs",
)
_render_job_detail_header(job)
with ui.grid().classes("w-full grid-cols-1 md:grid-cols-2 gap-4"):
with archival_card(title="Execution Logistics"):
metadata_row("Provider:", job.provider or "pending")
metadata_row("Model:", job.model or "pending")
metadata_row("Prompt:", job.prompt_name or "pending")
metadata_row("Retry Count:", str(job.retry_count))
metadata_row("Last Updated:", job.date_updated.isoformat())
with archival_card(title="Document Links"):
ui.label("Navigate to related archival records:").classes("text-xs ui-text-muted mb-3")
with ui.column().classes("w-full gap-2"):
ui.button(
"View Linked Document",
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}"),
icon="description",
).classes("ui-btn-primary text-xs w-full")
ui.button(
"View Linked Sources",
on_click=lambda: ui.navigate.to(f"/sources?job_id={job.id}"),
icon="description",
).props("flat text-xs").classes("ui-link-primary w-full")
_render_job_logistics(job)
_render_job_document_links(job)
@ui.page("/jobs/{job_id}/cancel")
async def job_cancel_page(job_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
@@ -277,9 +166,8 @@ def register_page() -> None: # noqa: PLR0915
jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
try:
parsed_job_id = UUID(job_id)
except ValueError:
parsed_job_id = _parse_uuid(job_id)
if parsed_job_id is None:
ui.label("Invalid job id").classes("text-h6 text-red-800 p-4")
return
@@ -318,12 +206,7 @@ def register_page() -> None: # noqa: PLR0915
ui.navigate.to(f"/jobs/{job.id}")
with ui.row().classes("w-full items-center gap-2 mt-2"):
destructive_button(
"Cancel job",
on_click=submit_cancel,
icon="stop_circle",
variant="solid",
)
destructive_button("Cancel job", on_click=submit_cancel, icon="stop_circle", variant="solid")
ui.button("Back to Job", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat")
@ui.page("/jobs/{job_id}/resubmit")
@@ -332,9 +215,8 @@ def register_page() -> None: # noqa: PLR0915
jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
try:
parsed_job_id = UUID(job_id)
except ValueError:
parsed_job_id = _parse_uuid(job_id)
if parsed_job_id is None:
ui.label("Invalid job id").classes("text-h6 text-red-800 p-4")
return
@@ -344,7 +226,7 @@ def register_page() -> None: # noqa: PLR0915
ui.label("Job not found").classes("text-h6 text-red-800 p-4")
return
non_transcribed_count = sum(1 for job_source in job.job_sources if job_source.status != JobSourceStatus.TRANSCRIBED)
non_transcribed_count = sum(1 for js in job.job_sources if js.status != JobSourceStatus.TRANSCRIBED)
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
page_header("Resubmit Job")
@@ -353,9 +235,9 @@ def register_page() -> None: # noqa: PLR0915
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary")
metadata_row("Current Status:", job.status.value)
metadata_row("Non-Transcribed Sources:", str(non_transcribed_count))
ui.label("Resubmit queues all non-transcribed linked sources. New results overwrite prior page-level results.").classes(
"text-xs ui-text-muted"
)
ui.label(
"Resubmit queues all non-transcribed linked sources. New results overwrite prior page-level results."
).classes("text-xs ui-text-muted")
async def submit_resubmit() -> None:
try:
@@ -385,9 +267,8 @@ def register_page() -> None: # noqa: PLR0915
jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
try:
parsed_job_id = UUID(job_id)
except ValueError:
parsed_job_id = _parse_uuid(job_id)
if parsed_job_id is None:
ui.label("Invalid job id").classes("text-h6 text-red-800 p-4")
return
@@ -404,16 +285,12 @@ def register_page() -> None: # noqa: PLR0915
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary")
if job.status == JobStatus.PROCESSING:
ui.label("Delete is blocked while the job is processing.").classes(
"text-xs text-red-800 font-bold mt-2"
)
ui.label("Delete is blocked while the job is processing.").classes("text-xs text-red-800 font-bold mt-2")
ui.label("Wait for processing to complete, then retry delete.").classes("text-xs ui-text-muted italic")
with ui.row().classes("w-full items-center gap-2 mt-4"):
ui.button(
"Back to Job",
on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"),
icon="arrow_back",
).classes("ui-btn-primary text-xs")
ui.button("Back to Job", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").classes(
"ui-btn-primary text-xs"
)
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
"flat text-xs"
)
@@ -441,10 +318,140 @@ def register_page() -> None: # noqa: PLR0915
ui.navigate.to("/jobs")
with ui.row().classes("w-full items-center gap-2 mt-2"):
destructive_button("Delete job permanently", on_click=submit_delete, icon="delete_forever", variant="solid")
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat")
# --- Helper Sub-Components ---
def _render_no_documents_card() -> None:
with archival_card(extra_classes="p-6 text-center"):
render_empty_state(
"No documents available. Create a Document before creating a Job.",
extra_classes="text-red-800 font-medium mb-4",
)
with ui.row().classes("justify-center gap-2"):
ui.button(
"Create document",
on_click=lambda: ui.navigate.to("/documents/new?return_to=jobs_new"),
icon="note_add",
).classes("ui-btn-primary")
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
def _render_upload_section(uploaded_files: list[tuple[str, bytes]]) -> None:
with archival_card(title="Source Files"):
ui.label(
"Files are processed alphabetically by original filename. Use leading numbers such as 001, 002, 003 to control order."
).classes("text-xs ui-text-muted mb-2")
@ui.refreshable
def render_upload_list() -> None:
if not uploaded_files:
render_empty_state("No files uploaded yet.", italic=True)
return
def remove_file(index: int) -> None:
if 0 <= index < len(uploaded_files):
removed_name, _ = uploaded_files.pop(index)
ui.notify(f"Removed {removed_name}", type="info")
render_upload_list.refresh()
def clear_files() -> None:
uploaded_files.clear()
ui.notify("Cleared queued files", type="info")
render_upload_list.refresh()
ordered_uploads = sorted(
enumerate(uploaded_files),
key=lambda item: Path(item[1][0]).name.casefold(),
)
with ui.column().classes("gap-1 w-full mt-2"):
for index, (filename, _) in ordered_uploads:
with ui.row().classes("w-full items-center justify-between ui-row-surface p-2"):
ui.label(Path(filename).name).classes("text-xs font-mono ui-text-primary")
ui.button(icon="delete", on_click=lambda idx=index: remove_file(idx)).props(
"flat round dense color=negative text-xs"
)
with ui.row().classes("w-full justify-end mt-2"):
ui.button("Clear files", on_click=clear_files, icon="clear_all").props("flat dense").classes(
"text-xs text-red-800"
)
async def on_upload(event) -> None:
payload = await event.file.read()
uploaded_files.append((event.file.name, payload))
ui.notify(f"Added {event.file.name}", type="positive")
render_upload_list.refresh()
ui.upload(
on_upload=on_upload,
auto_upload=True,
label="Select source files or a folder",
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf" webkitdirectory directory multiple').classes("w-full")
render_upload_list()
def _render_job_detail_header(job: Job) -> None:
with section_header_row(classes="justify-between items-center"):
page_header(f"Job Record: {job.id}")
with ui.row().classes("items-center gap-2"):
archival_badge(job.status.value.upper())
if job.status in {JobStatus.QUEUED, JobStatus.PROCESSING}:
destructive_button(
"Delete job permanently",
on_click=submit_delete,
icon="delete_forever",
variant="solid",
"Cancel",
on_click=lambda: ui.navigate.to(f"/jobs/{job.id}/cancel"),
icon="stop_circle",
extra_classes="text-xs",
)
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat")
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
+235 -239
View File
@@ -4,8 +4,7 @@ from __future__ import annotations
from datetime import date
from urllib.parse import quote
from uuid import UUID
from uuid import uuid4
from uuid import UUID, uuid4
from fastapi import Request
from nicegui import ui
@@ -13,94 +12,24 @@ from nicegui import ui
from transcription.config import Settings, get_settings
from transcription.db.models import Person
from transcription.errors import ErrorCategory
from transcription.services.documents import (
DocumentError,
DocumentService,
)
from transcription.services.documents import DocumentError, DocumentService
from transcription.services.store import UploadError, store_person_portrait
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card
from transcription.ui.components.data_display import metadata_row
from transcription.ui.components.data_display import archival_badge, metadata_row
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.primitives import destructive_button
from transcription.ui.components.primitives import render_empty_state
from transcription.ui.components.primitives import section_header_row
from transcription.ui.components.primitives import (
destructive_button,
render_empty_state,
section_header_row,
)
from transcription.ui.components.table.people import PersonTableRow, render_people_table
from transcription.ui.components.viewers import dark_room_viewer
from transcription.ui.theme import apply_archival_theme
from transcription.ui.theme import page_header
from transcription.ui.theme import apply_archival_theme, page_header
from ...db.session import SessionFactoryDep
def _parse_optional_date(value: str | None, *, label: str) -> date | None:
candidate = (value or "").strip()
if not candidate:
return None
try:
return date.fromisoformat(candidate)
except ValueError as exc:
raise ValueError(f"{label} must use YYYY-MM-DD.") from exc
def _bind_portrait_file_picker(portrait_path_input: ui.input, *, settings: Settings, person_id: UUID) -> None:
async def on_portrait_selected(event) -> None:
payload = await event.file.read()
try:
stored_path = store_person_portrait(
person_id=person_id,
filename=event.file.name,
file_bytes=payload,
settings=settings,
)
except UploadError as exc:
ui.notify(str(exc), type="negative")
return
except Exception: # noqa: BLE001
ui.notify("Unable to store portrait image.", type="negative")
return
try:
relative_path = stored_path.resolve().relative_to(settings.upload_dir.resolve()).as_posix()
except ValueError:
relative_path = stored_path.name
portrait_path_input.value = relative_path
ui.notify("Portrait uploaded.", type="positive")
ui.upload(
on_upload=on_portrait_selected,
auto_upload=True,
label="Choose portrait file",
).props('accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"').classes("w-full")
portrait_dir = settings.upload_dir / "persons" / str(person_id)
ui.label(f"Portraits are stored under {portrait_dir}.").classes("text-xs ui-text-muted")
def _resolve_portrait_src(path: str | None) -> str | None:
candidate = (path or "").strip()
if not candidate:
return None
normalized = candidate.replace("\\", "/")
lowered = normalized.casefold()
if lowered.startswith("http://") or lowered.startswith("https://") or lowered.startswith("data:"):
return normalized
if normalized.startswith("/"):
return normalized
if lowered.startswith("uploads/"):
return f"/{normalized}"
return f"/uploads/{quote(normalized)}"
def _resolve_runtime_settings(request: Request) -> Settings:
app_settings = getattr(request.app.state, "settings", None)
if isinstance(app_settings, Settings):
return app_settings
return get_settings()
def register_page() -> None: # noqa: PLR0915
"""Register people list and CRUD routes."""
@@ -129,7 +58,6 @@ def register_page() -> None: # noqa: PLR0915
show_error(exc, title="Load failed", operation="people.list")
return
# Format person records into read-model rows for the table renderer
rows = [
PersonTableRow(
id=person.id,
@@ -152,56 +80,33 @@ def register_page() -> None: # noqa: PLR0915
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
page_header("Create Person Record", subtitle="Full name is required.")
with archival_card(extra_classes="gap-3"):
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
full_name_input = ui.input(label="Full name").props("outlined bg-white")
display_name_input = ui.input(label="Display name").props("outlined bg-white")
maiden_name_input = ui.input(label="Maiden name").props("outlined bg-white")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
birth_date_input = ui.input(label="Birth date (YYYY-MM-DD)").props('outlined bg-white type="date"')
birth_date_raw_input = ui.input(label="Birth date (approximate)").props("outlined bg-white")
birth_place_input = ui.input(label="Birth place").props("outlined bg-white")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
death_date_input = ui.input(label="Death date (YYYY-MM-DD)").props('outlined bg-white type="date"')
death_date_raw_input = ui.input(label="Death date (approximate)").props("outlined bg-white")
death_place_input = ui.input(label="Death place").props("outlined bg-white")
biography_input = ui.textarea(label="Biography").props("outlined bg-white autogrow").classes("w-full")
portrait_path_input = ui.input(label="Portrait path").props("outlined bg-white").classes("w-full")
_bind_portrait_file_picker(
portrait_path_input,
settings=_resolve_runtime_settings(request),
person_id=draft_person_id,
)
form = _render_person_form_fields(
request=request,
person_id=draft_person_id,
)
async def submit_create() -> None:
full_name = (full_name_input.value or "").strip()
full_name = (form["full_name"].value or "").strip()
if not full_name:
ui.notify("Full name is required.", type="warning")
return
try:
birth_date = _parse_optional_date(birth_date_input.value, label="Birth date")
death_date = _parse_optional_date(death_date_input.value, label="Death date")
except ValueError as exc:
ui.notify(str(exc), type="warning")
return
birth_date = _parse_iso_date(form["birth_date"].value)
death_date = _parse_iso_date(form["death_date"].value)
candidate = Person(
id=draft_person_id,
full_name=full_name,
display_name=(display_name_input.value or "").strip() or None,
maiden_name=(maiden_name_input.value or "").strip() or None,
display_name=(form["display_name"].value or "").strip() or None,
maiden_name=(form["maiden_name"].value or "").strip() or None,
birth_date=birth_date,
birth_date_raw=(birth_date_raw_input.value or "").strip() or None,
birth_place=(birth_place_input.value or "").strip() or None,
birth_date_raw=(form["birth_date_raw"].value or "").strip() or None,
birth_place=(form["birth_place"].value or "").strip() or None,
death_date=death_date,
death_date_raw=(death_date_raw_input.value or "").strip() or None,
death_place=(death_place_input.value or "").strip() or None,
biography=(biography_input.value or "").strip() or None,
portrait_path=(portrait_path_input.value or "").strip() or None,
death_date_raw=(form["death_date_raw"].value or "").strip() or None,
death_place=(form["death_place"].value or "").strip() or None,
biography=(form["biography"].value or "").strip() or None,
portrait_path=(form["portrait_path"].value or "").strip() or None,
)
try:
@@ -223,9 +128,8 @@ def register_page() -> None: # noqa: PLR0915
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
try:
parsed_person_id = UUID(person_id)
except ValueError:
parsed_person_id = _parse_uuid(person_id)
if parsed_person_id is None:
ui.label("Invalid person id").classes("text-h6 text-red-800 p-4")
return
@@ -238,8 +142,6 @@ def register_page() -> None: # noqa: PLR0915
show_error(exc, title="Load failed", operation="people.read")
return
portrait_src = _resolve_portrait_src(person.portrait_path)
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
with section_header_row():
page_header(person.full_name, subtitle=f"Person ID: {person.id}")
@@ -258,54 +160,9 @@ def register_page() -> None: # noqa: PLR0915
)
with ui.grid().classes("w-full grid-cols-12 gap-4"):
with ui.column().classes("col-span-12 lg:col-span-4"):
dark_room_viewer(portrait_src, count_label="Portrait Media")
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Biographical Record"):
metadata_row("Full Name:", person.full_name)
metadata_row("Display Name:", person.display_name or "Not set")
metadata_row("Maiden Name:", person.maiden_name or "Not set")
metadata_row("Birth Date:", person.birth_date.isoformat() if person.birth_date else "Not set")
metadata_row("Approx. Birth Date:", person.birth_date_raw or "Not set")
metadata_row("Birth Place:", person.birth_place or "Not set")
metadata_row("Death Date:", person.death_date.isoformat() if person.death_date else "Not set")
metadata_row("Approx. Death Date:", person.death_date_raw or "Not set")
metadata_row("Death Place:", person.death_place or "Not set")
with archival_card(title="System Logistics"):
ui.label(f"Created: {person.created_at.isoformat()}").classes("text-[11px] ui-text-muted")
ui.label(f"Updated: {person.updated_at.isoformat()}").classes("text-[11px] ui-text-muted")
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Biography"):
ui.label(person.biography or "No biography recorded.").classes(
"p-2 ui-note-box text-xs w-full"
)
with archival_card(title="Linked Documents"):
if not person.document_people:
render_empty_state("No linked documents yet.", italic=True)
render_empty_state("Link this person from a Document workflow.")
else:
with ui.column().classes("w-full gap-2"):
for link in person.document_people:
document = link.document
if document is None:
continue
with ui.row().classes(
"w-full justify-between items-center ui-row-surface p-2"
):
with ui.column().classes("gap-0"):
ui.label(document.name).classes("text-xs font-semibold ui-text-primary")
ui.label(f"Role: {link.role.value}").classes("text-[10px] ui-text-muted")
ui.button(
"Open",
on_click=lambda _=None, doc_id=document.id: ui.navigate.to(
f"/documents/{doc_id}"
),
icon="open_in_new",
).props("flat dense text-xs").classes("ui-link-primary")
_render_person_portrait_zone(person)
_render_person_biographical_zone(person)
_render_person_biography_zone(person)
@ui.page("/people/{person_id}/edit")
async def person_edit_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
@@ -313,9 +170,8 @@ def register_page() -> None: # noqa: PLR0915
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
try:
parsed_person_id = UUID(person_id)
except ValueError:
parsed_person_id = _parse_uuid(person_id)
if parsed_person_id is None:
ui.label("Invalid person id").classes("text-h6 text-red-800 p-4")
return
@@ -331,72 +187,34 @@ def register_page() -> None: # noqa: PLR0915
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
page_header("Edit Person Record", subtitle="Full name is required.")
with archival_card(extra_classes="gap-3"):
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
full_name_input = ui.input(label="Full name", value=person.full_name).props("outlined bg-white")
display_name_input = ui.input(label="Display name", value=person.display_name or "").props(
"outlined bg-white"
)
maiden_name_input = ui.input(label="Maiden name", value=person.maiden_name or "").props("outlined bg-white")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
birth_date_input = ui.input(
label="Birth date (YYYY-MM-DD)",
value=person.birth_date.isoformat() if person.birth_date else "",
).props('outlined bg-white type="date"')
birth_date_raw_input = ui.input(
label="Birth date (approximate)", value=person.birth_date_raw or ""
).props("outlined bg-white")
birth_place_input = ui.input(label="Birth place", value=person.birth_place or "").props("outlined bg-white")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
death_date_input = ui.input(
label="Death date (YYYY-MM-DD)",
value=person.death_date.isoformat() if person.death_date else "",
).props('outlined bg-white type="date"')
death_date_raw_input = ui.input(
label="Death date (approximate)", value=person.death_date_raw or ""
).props("outlined bg-white")
death_place_input = ui.input(label="Death place", value=person.death_place or "").props("outlined bg-white")
biography_input = (
ui.textarea(label="Biography", value=person.biography or "").props("outlined bg-white autogrow").classes("w-full")
)
portrait_path_input = (
ui.input(label="Portrait path", value=person.portrait_path or "").props("outlined bg-white").classes("w-full")
)
_bind_portrait_file_picker(
portrait_path_input,
settings=_resolve_runtime_settings(request),
person_id=person.id,
)
form = _render_person_form_fields(
request=request,
person=person,
person_id=person.id,
)
async def submit_edit() -> None:
full_name = (full_name_input.value or "").strip()
full_name = (form["full_name"].value or "").strip()
if not full_name:
ui.notify("Full name is required.", type="warning")
return
try:
birth_date = _parse_optional_date(birth_date_input.value, label="Birth date")
death_date = _parse_optional_date(death_date_input.value, label="Death date")
except ValueError as exc:
ui.notify(str(exc), type="warning")
return
birth_date = _parse_iso_date(form["birth_date"].value)
death_date = _parse_iso_date(form["death_date"].value)
candidate = Person(
id=person.id,
full_name=full_name,
display_name=(display_name_input.value or "").strip() or None,
maiden_name=(maiden_name_input.value or "").strip() or None,
display_name=(form["display_name"].value or "").strip() or None,
maiden_name=(form["maiden_name"].value or "").strip() or None,
birth_date=birth_date,
birth_date_raw=(birth_date_raw_input.value or "").strip() or None,
birth_place=(birth_place_input.value or "").strip() or None,
birth_date_raw=(form["birth_date_raw"].value or "").strip() or None,
birth_place=(form["birth_place"].value or "").strip() or None,
death_date=death_date,
death_date_raw=(death_date_raw_input.value or "").strip() or None,
death_place=(death_place_input.value or "").strip() or None,
biography=(biography_input.value or "").strip() or None,
portrait_path=(portrait_path_input.value or "").strip() or None,
death_date_raw=(form["death_date_raw"].value or "").strip() or None,
death_place=(form["death_place"].value or "").strip() or None,
biography=(form["biography"].value or "").strip() or None,
portrait_path=(form["portrait_path"].value or "").strip() or None,
metadata_=person.metadata_,
created_at=person.created_at,
updated_at=person.updated_at,
@@ -421,9 +239,8 @@ def register_page() -> None: # noqa: PLR0915
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
try:
parsed_person_id = UUID(person_id)
except ValueError:
parsed_person_id = _parse_uuid(person_id)
if parsed_person_id is None:
ui.label("Invalid person id").classes("text-h6 text-red-800 p-4")
return
@@ -467,10 +284,189 @@ def register_page() -> None: # noqa: PLR0915
ui.navigate.to("/people")
with ui.row().classes("w-full items-center gap-2 mt-2"):
destructive_button(
"Delete person permanently",
on_click=submit_delete,
icon="delete_forever",
variant="solid",
)
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props("flat")
destructive_button("Delete person permanently", on_click=submit_delete, icon="delete_forever", variant="solid")
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props("flat")
# --- Helper Sub-Components & Form Builders ---
def _render_person_form_fields(
*,
request: Request,
person: Person | None = None,
person_id: UUID,
) -> dict[str, Any]:
with archival_card(extra_classes="gap-3"):
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
full_name_input = ui.input(label="Full name", value=person.full_name if person else "").props("outlined bg-white")
display_name_input = ui.input(label="Display name", value=person.display_name if person and person.display_name else "").props("outlined bg-white")
maiden_name_input = ui.input(label="Maiden name", value=person.maiden_name if person and person.maiden_name else "").props("outlined bg-white")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
birth_date_input = ui.input(
label="Birth date (YYYY-MM-DD)",
value=person.birth_date.isoformat() if person and person.birth_date else "",
).props('outlined bg-white type="date"')
birth_date_raw_input = ui.input(label="Birth date (approximate)", value=person.birth_date_raw if person and person.birth_date_raw else "").props("outlined bg-white")
birth_place_input = ui.input(label="Birth place", value=person.birth_place if person and person.birth_place else "").props("outlined bg-white")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
death_date_input = ui.input(
label="Death date (YYYY-MM-DD)",
value=person.death_date.isoformat() if person and person.death_date else "",
).props('outlined bg-white type="date"')
death_date_raw_input = ui.input(label="Death date (approximate)", value=person.death_date_raw if person and person.death_date_raw else "").props("outlined bg-white")
death_place_input = ui.input(label="Death place", value=person.death_place if person and person.death_place else "").props("outlined bg-white")
biography_input = ui.textarea(label="Biography", value=person.biography if person and person.biography else "").props("outlined bg-white autogrow").classes("w-full")
portrait_path_input = ui.input(label="Portrait path", value=person.portrait_path if person and person.portrait_path else "").props("outlined bg-white").classes("w-full")
_bind_portrait_file_picker(
portrait_path_input,
settings=_resolve_runtime_settings(request),
person_id=person_id,
)
return {
"full_name": full_name_input,
"display_name": display_name_input,
"maiden_name": maiden_name_input,
"birth_date": birth_date_input,
"birth_date_raw": birth_date_raw_input,
"birth_place": birth_place_input,
"death_date": death_date_input,
"death_date_raw": death_date_raw_input,
"death_place": death_place_input,
"biography": biography_input,
"portrait_path": portrait_path_input,
}
def _render_person_portrait_zone(person: Person) -> None:
portrait_src = _resolve_portrait_src(person.portrait_path)
with ui.column().classes("col-span-12 lg:col-span-4"):
dark_room_viewer(portrait_src, count_label="Portrait Media")
def _render_person_biographical_zone(person: Person) -> None:
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Biographical Record"):
metadata_row("Full Name:", person.full_name)
metadata_row("Display Name:", person.display_name or "Not set")
metadata_row("Maiden Name:", person.maiden_name or "Not set")
metadata_row("Birth Date:", person.birth_date.isoformat() if person.birth_date else "Not set")
metadata_row("Approx. Birth Date:", person.birth_date_raw or "Not set")
metadata_row("Birth Place:", person.birth_place or "Not set")
metadata_row("Death Date:", person.death_date.isoformat() if person.death_date else "Not set")
metadata_row("Approx. Death Date:", person.death_date_raw or "Not set")
metadata_row("Death Place:", person.death_place or "Not set")
with archival_card(title="System Logistics"):
ui.label(f"Created: {person.created_at.isoformat()}").classes("text-[11px] ui-text-muted")
ui.label(f"Updated: {person.updated_at.isoformat()}").classes("text-[11px] ui-text-muted")
def _render_person_biography_zone(person: Person) -> None:
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Biography"):
ui.label(person.biography or "No biography recorded.").classes("p-2 ui-note-box text-xs w-full")
with archival_card(title="Linked Documents"):
if not person.document_people:
render_empty_state("No linked documents yet.", italic=True)
render_empty_state("Link this person from a Document workflow.")
else:
with ui.column().classes("w-full gap-2"):
for link in person.document_people:
doc = link.document
if doc is None:
continue
with ui.row().classes("w-full justify-between items-center ui-row-surface p-2"):
with ui.column().classes("gap-0"):
ui.label(doc.name).classes("text-xs font-semibold ui-text-primary")
ui.label(f"Role: {link.role.value}").classes("text-[10px] ui-text-muted")
ui.button(
"Open",
on_click=lambda _=None, doc_id=doc.id: ui.navigate.to(f"/documents/{doc_id}"),
icon="open_in_new",
).props("flat dense text-xs").classes("ui-link-primary")
# --- Utilities & Input Binding Helpers ---
def _bind_portrait_file_picker(portrait_path_input: ui.input, *, settings: Settings, person_id: UUID) -> None:
async def on_portrait_selected(event) -> None:
payload = await event.file.read()
try:
stored_path = store_person_portrait(
person_id=person_id,
filename=event.file.name,
file_bytes=payload,
settings=settings,
)
except UploadError as exc:
ui.notify(str(exc), type="negative")
return
except Exception: # noqa: BLE001
ui.notify("Unable to store portrait image.", type="negative")
return
try:
relative_path = stored_path.resolve().relative_to(settings.upload_dir.resolve()).as_posix()
except ValueError:
relative_path = stored_path.name
portrait_path_input.value = relative_path
ui.notify("Portrait uploaded.", type="positive")
ui.upload(
on_upload=on_portrait_selected,
auto_upload=True,
label="Choose portrait file",
).props('accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"').classes("w-full")
portrait_dir = settings.upload_dir / "persons" / str(person_id)
ui.label(f"Portraits are stored under {portrait_dir}.").classes("text-xs ui-text-muted")
def _resolve_portrait_src(path: str | None) -> str | None:
candidate = (path or "").strip()
if not candidate:
return None
normalized = candidate.replace("\\", "/")
lowered = normalized.casefold()
if lowered.startswith(("http://", "https://", "data:")):
return normalized
if normalized.startswith("/"):
return normalized
if lowered.startswith("uploads/"):
return f"/{normalized}"
return f"/uploads/{quote(normalized)}"
def _resolve_runtime_settings(request: Request) -> Settings:
app_settings = getattr(request.app.state, "settings", None)
if isinstance(app_settings, Settings):
return app_settings
return get_settings()
def _parse_uuid(value: str | None) -> UUID | None:
if not value:
return None
try:
return UUID(value)
except ValueError:
return None
def _parse_iso_date(value: str | None) -> date | None:
candidate = (value or "").strip()
if not candidate:
return None
try:
return date.fromisoformat(candidate)
except ValueError:
return None