Files
transcription/src/transcription/ui/pages/people_page.py
T

473 lines
23 KiB
Python

"""People list and detail page registration."""
from __future__ import annotations
from datetime import date
from urllib.parse import quote
from uuid import UUID
from fastapi import Request
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,
PersonDeleteBlockedError,
)
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.error_presenter import show_error
from transcription.ui.components.table.people import PersonTableRow, render_people_table
from transcription.ui.components.typography import page_header
from transcription.ui.components.viewers import dark_room_viewer
from transcription.ui.theme import apply_archival_theme
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) -> None:
async def on_portrait_selected(event) -> None:
payload = await event.file.read()
try:
stored_path = store_person_portrait(
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")
ui.label("Portraits are stored under uploads/portraits/person.").classes("text-xs text-[#6B6A65]")
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."""
@ui.page("/people")
async def people_page(session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
with ui.row().classes("w-full items-center justify-between pb-2 border-b border-[#6B6A65]/20"):
page_header("Archival Entities: People")
ui.button(
"Create new person",
on_click=lambda: ui.navigate.to("/people/new"),
icon="person_add",
).classes("bg-[#2D5A4C] text-white")
try:
people = sorted(
await people_service.list_people(),
key=lambda item: item.created_at,
reverse=True,
)
except Exception as exc: # noqa: BLE001
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,
full_name=person.full_name,
display_name=person.display_name or "",
maiden_name=person.maiden_name or "",
birth_date=person.birth_date.isoformat() if person.birth_date else "",
)
for person in people
]
render_people_table(rows)
@ui.page("/people/new")
async def person_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
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))
async def submit_create() -> None:
full_name = (full_name_input.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
candidate = Person(
full_name=full_name,
display_name=(display_name_input.value or "").strip() or None,
maiden_name=(maiden_name_input.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,
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,
)
try:
created = await people_service.create_person(candidate)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Create failed", operation="people.create")
return
ui.notify("Person created", type="positive")
ui.navigate.to(f"/people/{created.id}")
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save person", on_click=submit_create, icon="save").classes("bg-[#2D5A4C] text-white")
ui.button("Cancel", on_click=lambda: ui.navigate.to("/people"), icon="arrow_back").props("flat")
@ui.page("/people/{person_id}")
async def person_detail_page(person_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
try:
parsed_person_id = UUID(person_id)
except ValueError:
ui.label("Invalid person id").classes("text-h6 text-red-800 p-4")
return
try:
person = await people_service.read_person_detail(parsed_person_id)
except DocumentError:
ui.label("Person not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
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 ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"):
page_header(person.full_name, subtitle=f"Person ID: {person.id}")
with ui.row().classes("items-center gap-2"):
ui.button(
"Edit Person",
on_click=lambda: ui.navigate.to(f"/people/{person.id}/edit"),
icon="edit",
).classes("bg-[#2D5A4C] text-white text-xs")
ui.button(
"Delete",
on_click=lambda: ui.navigate.to(f"/people/{person.id}/delete"),
icon="delete",
).props("outlined color=negative text-xs")
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] text-[#6B6A65]")
ui.label(f"Updated: {person.updated_at.isoformat()}").classes("text-[11px] text-[#6B6A65]")
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 bg-[#FAF9F6] border border-[#6B6A65]/20 rounded-sm text-xs text-[#333333] italic w-full"
)
with archival_card(title="Linked Documents"):
if not person.document_people:
ui.label("No linked documents yet.").classes("text-xs text-[#6B6A65] italic")
ui.label("Link this person from a Document workflow.").classes("text-xs text-[#6B6A65]")
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 bg-[#FAF9F6] p-2 border border-[#6B6A65]/20 rounded-sm"
):
with ui.column().classes("gap-0"):
ui.label(document.name).classes("text-xs font-semibold text-[#333333]")
ui.label(f"Role: {link.role.value}").classes("text-[10px] text-[#6B6A65]")
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("text-[#2D5A4C]")
@ui.page("/people/{person_id}/edit")
async def person_edit_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
try:
parsed_person_id = UUID(person_id)
except ValueError:
ui.label("Invalid person id").classes("text-h6 text-red-800 p-4")
return
try:
person = await people_service.read_person_detail(parsed_person_id)
except DocumentError:
ui.label("Person not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.edit.read")
return
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))
async def submit_edit() -> None:
full_name = (full_name_input.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
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,
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,
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,
metadata_=person.metadata_,
created_at=person.created_at,
updated_at=person.updated_at,
)
try:
await people_service.update_person(candidate)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Save failed", operation="people.edit.save")
return
ui.notify("Person updated", type="positive")
ui.navigate.to(f"/people/{person.id}")
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save changes", on_click=submit_edit, icon="save").classes("bg-[#2D5A4C] text-white")
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props("flat")
@ui.page("/people/{person_id}/delete")
async def person_delete_page(person_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
try:
parsed_person_id = UUID(person_id)
except ValueError:
ui.label("Invalid person id").classes("text-h6 text-red-800 p-4")
return
try:
person = await people_service.read_person_detail(parsed_person_id)
except DocumentError:
ui.label("Person not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.delete.read")
return
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
page_header("Delete Person Record")
with archival_card(extra_classes="gap-2"):
ui.label(f"Person: {person.full_name}").classes("text-sm font-semibold text-[#333333]")
if person.document_people:
ui.label("Delete is blocked because linked documents exist.").classes("text-xs text-red-800 font-bold mt-2")
ui.label(f"Linked documents: {len(person.document_people)}").classes("text-xs text-[#6B6A65]")
ui.label("Remove document links first, then retry deletion.").classes("text-xs text-[#6B6A65] italic")
with ui.row().classes("w-full items-center gap-2 mt-4"):
ui.button(
"Back to Person",
on_click=lambda: ui.navigate.to(f"/people/{person.id}"),
icon="arrow_back",
).classes("bg-[#2D5A4C] text-white text-xs")
ui.button("Go to Documents", on_click=lambda: ui.navigate.to("/documents"), icon="description").props(
"flat text-xs"
)
return
ui.label("This action permanently deletes the person record.").classes("text-xs text-red-800 font-medium")
async def submit_delete() -> None:
try:
await people_service.delete_person(person)
except PersonDeleteBlockedError as exc:
ui.notify(exc.message, type="warning")
ui.navigate.to(f"/people/{person.id}/delete")
return
except DocumentError as exc:
if exc.category == ErrorCategory.NOT_FOUND:
ui.notify("Person not found.", type="warning")
ui.navigate.to("/people")
return
show_error(exc, title="Delete failed", operation="people.delete")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Delete failed", operation="people.delete")
return
ui.notify("Person deleted", type="positive")
ui.navigate.to("/people")
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button(
"Delete person permanently",
on_click=submit_delete,
icon="delete_forever",
).props("unelevated color=negative")
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props("flat")