UI update complete?

This commit is contained in:
Jim Lancaster
2026-08-02 13:33:09 -05:00
parent ed6f9dfe25
commit 9653060c2a
26 changed files with 2523 additions and 72 deletions
+4 -9
View File
@@ -20,10 +20,8 @@ from .config import Settings
from .config import configure_logging
from .config import get_settings
from .db import create_all
from .db import dispose_database_runtime
from .db import initialize_database_runtime
from .db.engine import get_database_url
from .db.engine import resolve_engine
from .db.session import dispose_session_factory
from .services import ServiceBundle
from .services.jobs import JobService
from .ui import register_pages
@@ -41,7 +39,7 @@ async def _lifespan(app: FastAPI):
app.state.runtime = initialize_database_runtime(settings=settings)
if settings.should_bootstrap_schema:
await create_all(engine=resolve_engine(settings=settings))
await create_all(engine=app.state.runtime.engine)
settings.upload_dir.mkdir(parents=True, exist_ok=True)
settings.prompt_dir.mkdir(parents=True, exist_ok=True)
@@ -49,10 +47,7 @@ async def _lifespan(app: FastAPI):
await _recover_stale_processing_jobs(app)
async with AsyncExitStack() as stack:
stack.push_async_callback(
dispose_session_factory,
database_url=get_database_url(settings),
)
stack.push_async_callback(dispose_database_runtime)
stop_event, worker_notifier = await stack.enter_async_context(
worker_consumer_lifespan(
session_factory=app.state.runtime.session_factory,
@@ -95,7 +90,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
@app.get("/ui", include_in_schema=False)
async def ui_redirect() -> RedirectResponse:
return RedirectResponse(url="/ui/upload", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
return RedirectResponse(url="/ui/jobs", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
@app.get("/healthz")
def health() -> dict[str, str]:
+12
View File
@@ -151,6 +151,18 @@ class Job(SQLModel, table=True):
return "unknown"
@property
def error_detail(self) -> str | None:
"""Return the first available source-level error detail for the job."""
if not self.job_sources:
return None
for job_source in self.job_sources:
if job_source.error_detail:
return job_source.error_detail
return None
class Source(SQLModel, table=True):
"""A document source image or PDF page."""
+107 -2
View File
@@ -1,6 +1,8 @@
import logging
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import UTC
from datetime import datetime
from pathlib import Path
from uuid import UUID
@@ -35,6 +37,14 @@ class DocumentAlreadyExistsError(DocumentError):
"""Raised when a document with the same name already exists in the database."""
class DocumentDeleteBlockedError(DocumentError):
"""Raised when a document delete is blocked by dependent records."""
class PersonDeleteBlockedError(DocumentError):
"""Raised when a person delete is blocked by linked documents."""
@dataclass(frozen=True)
class UploadJobResult:
"""Summary of created upload records."""
@@ -102,6 +112,7 @@ class DocumentService(ServiceBase):
async def update_document(self, document: Document, *, session: AsyncSession | None = None) -> Document:
"""Update an existing document in the database."""
async with self._session_scope(session) as _session:
document.updated_at = datetime.now(UTC)
merged = await _session.merge(document)
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
return merged
@@ -109,7 +120,36 @@ class DocumentService(ServiceBase):
async def delete_document(self, document: Document, *, session: AsyncSession | None = None) -> None:
"""Delete a document from the database."""
async with self._session_scope(session) as _session:
await _session.delete(document)
existing = await _session.get(
Document,
document.id,
options=(
selectinload(Document.jobs), # pyright: ignore[reportArgumentType]
selectinload(Document.sources), # pyright: ignore[reportArgumentType]
),
)
if existing is None:
raise DocumentError(
f"Document with id {document.id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the document id and retry.",
)
has_jobs = bool(existing.jobs)
has_sources = bool(existing.sources)
if has_jobs or has_sources:
blocked_by: list[str] = []
if has_sources:
blocked_by.append("Sources")
if has_jobs:
blocked_by.append("Jobs")
raise DocumentDeleteBlockedError(
f"Document delete blocked by related records: {', '.join(blocked_by)}",
category=ErrorCategory.VALIDATION,
suggestion="Remove related Sources and Jobs first, then retry deletion.",
)
await _session.delete(existing)
await self._finalize(session=_session, caller_session=session)
async def create_person(self, person: Person, *, session: AsyncSession | None = None) -> Person:
@@ -131,9 +171,31 @@ class DocumentService(ServiceBase):
)
return person
async def read_person_detail(self, person_id: UUID, *, session: AsyncSession | None = None) -> Person:
"""Read a person with eagerly loaded document links for UI detail rendering."""
async with self._session_scope(session) as _session:
query = (
select(Person)
.options(
selectinload(Person.document_people).selectinload(DocumentPerson.document), # pyright: ignore[reportArgumentType]
)
.where(Person.id == person_id)
.execution_options(populate_existing=True)
)
person = (await _session.exec(query)).first()
if person is None:
raise DocumentError(
f"Person with id {person_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the person id and retry.",
)
return person
async def update_person(self, person: Person, *, session: AsyncSession | None = None) -> Person:
"""Update an existing person in the database."""
async with self._session_scope(session) as _session:
person.updated_at = datetime.now(UTC)
merged = await _session.merge(person)
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
return merged
@@ -141,7 +203,28 @@ class DocumentService(ServiceBase):
async def delete_person(self, person: Person, *, session: AsyncSession | None = None) -> None:
"""Delete a person from the database."""
async with self._session_scope(session) as _session:
await _session.delete(person)
existing = await _session.get(
Person,
person.id,
options=(
selectinload(Person.document_people), # pyright: ignore[reportArgumentType]
),
)
if existing is None:
raise DocumentError(
f"Person with id {person.id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the person id and retry.",
)
if existing.document_people:
raise PersonDeleteBlockedError(
"Person delete blocked by linked documents",
category=ErrorCategory.VALIDATION,
suggestion="Remove linked DocumentPerson records first, then retry deletion.",
)
await _session.delete(existing)
await self._finalize(session=_session, caller_session=session)
async def create_document_person(
@@ -215,6 +298,28 @@ class DocumentService(ServiceBase):
result = await _session.exec(select(Document))
return result.all()
async def read_document_detail(self, document_id: UUID, *, session: AsyncSession | None = None) -> Document:
"""Read a document with eagerly loaded relations for UI detail rendering."""
async with self._session_scope(session) as _session:
query = (
select(Document)
.options(
selectinload(Document.jobs), # pyright: ignore[reportArgumentType]
selectinload(Document.sources), # pyright: ignore[reportArgumentType]
selectinload(Document.document_people).selectinload(DocumentPerson.person), # pyright: ignore[reportArgumentType]
)
.where(Document.id == document_id)
.execution_options(populate_existing=True)
)
document = (await _session.exec(query)).first()
if document is None:
raise DocumentError(
f"Document with id {document_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the document id and retry.",
)
return document
async def list_people(self, *, session: AsyncSession | None = None) -> Sequence[Person]:
"""List all people in the database."""
async with self._session_scope(session) as _session:
+37
View File
@@ -7,6 +7,8 @@ from sqlalchemy.orm import selectinload
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..errors import AppError
from ..errors import ErrorCategory
from ..db.models import Job
from ..db.models import JobSource
from ..db.models import JobStatus
@@ -14,6 +16,10 @@ from ..db.models import Source
from .base import ServiceBase
class JobDeleteBlockedError(AppError):
"""Raised when a job delete operation is blocked by lifecycle policy."""
class JobService(ServiceBase):
"""Thin service class for managing jobs in the database."""
@@ -183,3 +189,34 @@ class JobService(ServiceBase):
await self._finalize(session=_session, caller_session=session, refresh=stale_jobs)
return len(stale_jobs)
async def delete_job_with_guardrails(self, *, job_id: UUID, session: AsyncSession | None = None) -> None:
"""Delete a job with lifecycle guardrails and dependent cleanup policy.
Policy:
- Block when the job is actively processing.
- Otherwise remove related JobSource rows, then delete the job.
"""
async with self._session_scope(session) as _session:
query = (
select(Job)
.options(selectinload(Job.job_sources)) # pyright: ignore[reportArgumentType]
.where(Job.id == job_id)
.execution_options(populate_existing=True)
)
job = (await _session.exec(query)).first()
if job is None:
raise ValueError(f"Job with id {job_id} not found")
if job.status == JobStatus.PROCESSING:
raise JobDeleteBlockedError(
"Job delete blocked while status is processing",
category=ErrorCategory.VALIDATION,
suggestion="Wait for processing to complete, or move the job out of processing before deleting.",
)
for job_source in list(job.job_sources):
await _session.delete(job_source)
await _session.delete(job)
await self._finalize(session=_session, caller_session=session)
+132 -1
View File
@@ -1,9 +1,13 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
import logging
from pathlib import Path
from uuid import UUID
from uuid import uuid4
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from transcription.config import Settings
@@ -27,6 +31,15 @@ class UploadError(AppError):
"""Raised when uploaded content cannot be persisted safely."""
@dataclass(frozen=True)
class JobCreateResult:
"""Summary of explicit Job create records."""
document_id: UUID
job_id: UUID
source_ids: tuple[UUID, ...]
async def create_upload_job(
*,
filename: str,
@@ -65,6 +78,66 @@ async def create_upload_job(
)
async def create_job_for_document(
*,
document_id: UUID,
uploads: Sequence[tuple[str, bytes]],
session: AsyncSession,
provider: str | None = None,
model: str | None = None,
prompt_name: str | None = None,
settings: Settings | None = None,
) -> JobCreateResult:
"""Create a queued job for an existing document with one or more uploaded sources."""
if not uploads:
raise UploadError(
"At least one upload is required to create a job",
category=ErrorCategory.VALIDATION,
suggestion="Upload one or more files and try again.",
)
runtime_settings = settings or get_settings()
sorted_uploads = sorted(uploads, key=lambda item: Path(item[0]).name.casefold())
stored_uploads: list[tuple[str, Path]] = []
for filename, file_bytes in sorted_uploads:
stored_uploads.append(
(
filename,
store_file(
filename=filename,
file_bytes=file_bytes,
settings=runtime_settings,
),
)
)
try:
job, source_ids = await _create_job_for_document_records(
session=session,
document_id=document_id,
stored_uploads=stored_uploads,
provider=provider,
model=model,
prompt_name=prompt_name,
)
except Exception as exc:
for _, stored_path in stored_uploads:
_best_effort_delete(stored_path)
raise UploadError(
"Failed to create job records from uploads",
category=ErrorCategory.INFRA_TRANSIENT,
suggestion="Retry creation. If this keeps happening, verify database availability.",
retriable=True,
) from exc
logger.info("Created explicit job document_id=%s job_id=%s sources=%s", document_id, job.id, len(source_ids))
return JobCreateResult(
document_id=document_id,
job_id=job.id,
source_ids=tuple(source_ids),
)
async def _create_upload_records(
*,
session: AsyncSession,
@@ -105,6 +178,63 @@ async def _create_upload_records(
return document, job
async def _create_job_for_document_records(
*,
session: AsyncSession,
document_id: UUID,
stored_uploads: Sequence[tuple[str, Path]],
provider: str | None,
model: str | None,
prompt_name: str | None,
) -> tuple[Job, list[UUID]]:
document = await session.get(Document, document_id)
if document is None:
raise UploadError(
f"Document with id {document_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Select an existing document and retry.",
)
existing_sources = (
await session.exec(select(Source).where(Source.document_id == document_id))
).all()
next_page_number = (max((source.page_number for source in existing_sources), default=0) + 1)
job = Job(
document_id=document_id,
provider=(provider or None),
model=(model or None),
prompt_name=(prompt_name or None),
)
session.add(job)
await session.flush()
source_ids: list[UUID] = []
for page_offset, (original_filename, stored_path) in enumerate(stored_uploads):
source = Source(
document_id=document_id,
page_number=next_page_number + page_offset,
upload_name=Path(original_filename).name,
filename=stored_path.name,
file_path=str(stored_path),
)
session.add(source)
await session.flush()
source_ids.append(source.id)
session.add(
JobSource(
job_id=job.id,
source_id=source.id,
status=JobSourceStatus.PENDING,
)
)
await session.commit()
await session.refresh(job)
return job, source_ids
def _best_effort_delete(path: Path) -> None:
try:
if path.exists():
@@ -164,4 +294,5 @@ def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
def _build_stored_filename(filename: str) -> str:
safe_name = Path(filename).name
return f"{uuid4()}_{safe_name}"
suffix = Path(safe_name).suffix.lower()
return f"{uuid4()}{suffix}"
@@ -51,6 +51,10 @@ class TranscriptionNotFoundError(TranscriptionError):
"""Raised when a transcription-related resource is not found."""
class SourceDeleteBlockedError(TranscriptionError):
"""Raised when source deletion is blocked by dependency policy."""
class TranscriptionService(ServiceBase):
"""Service class for job transcription output and page-level source revisions."""
@@ -164,6 +168,56 @@ class TranscriptionService(ServiceBase):
await _session.delete(job_source)
await self._finalize(session=_session, caller_session=session)
async def delete_source_from_job_context(
self,
*,
job_id: UUID,
source_id: UUID,
session: AsyncSession | None = None,
) -> None:
"""Delete a source from an active job context with dependency guardrails.
Policy:
- Allowed when exactly one JobSource link exists and it points at ``job_id``.
- Blocked when additional JobSource links exist (history/shared dependencies).
"""
async with self._session_scope(session) as _session:
source = await _session.get(
Source,
source_id,
options=(
selectinload(Source.job_sources), # pyright: ignore[reportArgumentType]
),
)
if source is None:
raise TranscriptionNotFoundError(
f"Source with id {source_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the source id and retry.",
)
linked_job_sources = list(source.job_sources)
matching_links = [job_source for job_source in linked_job_sources if job_source.job_id == job_id]
if not matching_links:
raise TranscriptionNotFoundError(
f"Source {source_id} is not linked to job {job_id}",
category=ErrorCategory.NOT_FOUND,
suggestion="Open the source from its linked job context and retry.",
)
if len(linked_job_sources) > len(matching_links):
raise SourceDeleteBlockedError(
"Source delete blocked by related job history",
category=ErrorCategory.VALIDATION,
suggestion="Remove additional JobSource links first, then retry deletion.",
)
for job_source in matching_links:
await _session.delete(job_source)
await _session.delete(source)
await self._finalize(session=_session, caller_session=session)
async def list_job_sources(
self,
*,
+11 -1
View File
@@ -70,7 +70,17 @@ async def process_queued_job(
source_job = await services.jobs.read_job(job_id=job.id, session=session)
source = _resolve_primary_source(source_job)
assert source is not None, f"Job {job.id} has no associated source record."
if source is None:
candidate_sources = await services.transcriptions.list_sources(document_id=job.document_id, session=session)
source = next(iter(sorted(candidate_sources, key=lambda item: item.page_number)), None)
if source is None:
error = AppError(
f"Job {job.id} has no associated source record.",
category=ErrorCategory.VALIDATION,
suggestion="Attach at least one source to the job and retry.",
)
return await _finalize_failed(job=job, services=services, error=error, session=session)
started_at = asyncio.get_running_loop().time()
try:
+4
View File
@@ -3,7 +3,9 @@
from fastapi import FastAPI
from nicegui import ui
from transcription.ui.pages.documents_page import register_page as register_documents_page
from transcription.ui.pages.jobs_page import register_page as register_jobs_page
from transcription.ui.pages.people_page import register_page as register_people_page
from transcription.ui.pages.upload_page import register_page as register_upload_page
from transcription.ui.resources import read_css
@@ -23,5 +25,7 @@ def register_pages(app: FastAPI) -> None:
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
_register_global_styles(app)
register_upload_page()
register_documents_page()
register_people_page()
register_jobs_page()
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False)
+7 -2
View File
@@ -7,7 +7,8 @@ from nicegui import ui
from transcription.ui.resources import read_css
NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
("Upload", "/upload", "upload_file"),
("Documents", "/documents", "description"),
("People", "/people", "group"),
("Jobs", "/jobs", "work_history"),
)
@@ -15,6 +16,10 @@ NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
def _is_active_path(*, current_path: str, item_path: str) -> bool:
if item_path == "/jobs":
return current_path == "/jobs" or current_path.startswith("/jobs/")
if item_path == "/documents":
return current_path == "/documents" or current_path.startswith("/documents/")
if item_path == "/people":
return current_path == "/people" or current_path.startswith("/people/")
return current_path == item_path
@@ -34,7 +39,7 @@ def _render_nav_button(*, label: str, path: str, icon: str, current_path: str) -
def _normalize_path(current_path: str | None) -> str:
normalized = (current_path or "").strip()
if not normalized:
return "/upload"
return "/jobs"
return normalized.rstrip("/") or "/"
@@ -0,0 +1,373 @@
"""Documents list and detail page registration."""
from __future__ import annotations
from datetime import date
from uuid import UUID
from nicegui import ui
from transcription.db.models import Document
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.ui.components.app_shell import render_navigation_header
from transcription.ui.components.error_presenter import show_error
from ...db.session import SessionFactoryDep
def register_page() -> None:
"""Register documents list and detail routes."""
@ui.page("/documents")
async def documents_page(session_factory: SessionFactoryDep) -> None:
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
ui.label("Documents").classes("text-h5 text-weight-medium")
try:
documents = sorted(
await document_service.list_documents(),
key=lambda item: item.created_at,
reverse=True,
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.list")
return
if not documents:
ui.label("No documents yet.").classes("text-body1 vibe-text-muted")
return
with ui.column().classes("w-full gap-2"):
for document in documents:
with ui.card().classes("w-full"):
with ui.row().classes("w-full items-center justify-between"):
with ui.column().classes("gap-1"):
ui.label(document.name).classes("text-subtitle1 text-weight-medium")
ui.label(f"Type: {document.document_type or 'unspecified'}").classes("text-body2")
ui.button(
"Open",
on_click=lambda _=None, document_id=document.id: ui.navigate.to(f"/documents/{document_id}"),
icon="open_in_new",
).props("flat")
@ui.page("/documents/{document_id}")
async def document_detail_page(document_id: str, session_factory: SessionFactoryDep) -> None:
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
try:
parsed_document_id = UUID(document_id)
except ValueError:
ui.label("Invalid document id").classes("text-h6 text-negative")
return
try:
document = await document_service.read_document_detail(document_id=parsed_document_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-negative")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.read")
return
ui.label(document.name).classes("text-h5 text-weight-medium")
ui.label(f"Document type: {document.document_type or 'unspecified'}").classes("text-subtitle1")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Edit document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"), icon="edit").props(
'unelevated color="primary"'
)
ui.button(
"Delete document",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/delete"),
icon="delete",
).props("outline color=negative")
with ui.column().classes("w-full gap-1"):
ui.label(f"Exact date: {document.document_date.isoformat() if document.document_date else 'not set'}")
ui.label(f"Approximate date: {document.document_date_raw or 'not set'}")
ui.label(f"Location created: {document.location_created or 'not set'}")
ui.label(f"Archive identifier: {document.archive_identifier or 'not set'}")
ui.label(f"Notes: {document.notes or 'not set'}")
ui.label(f"Created at (read-only): {document.created_at.isoformat()}").classes("text-body2")
ui.label(f"Updated at (read-only): {document.updated_at.isoformat()}").classes("text-body2")
ui.separator()
ui.label("Related people").classes("text-subtitle1 text-weight-medium")
if not document.document_people:
ui.label("No linked people yet.").classes("text-body2 vibe-text-muted")
else:
for link in document.document_people:
person = link.person
person_label = person.full_name if person is not None else "Unknown person"
ui.label(f"{person_label} ({link.role.value})").classes("text-body2")
ui.separator()
ui.label("Sources").classes("text-subtitle1 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
ui.button(
"View sources",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/sources"),
icon="description",
).props("flat")
if not document.sources:
ui.label("No sources added yet.").classes("text-body2 vibe-text-muted")
ui.button(
"Add sources",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="upload_file",
).props('unelevated color="primary"')
else:
for source in sorted(document.sources, key=lambda item: item.page_number):
ui.label(f"Page {source.page_number}: {source.upload_name}").classes("text-body2")
ui.separator()
ui.label("Jobs").classes("text-subtitle1 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
ui.button("View jobs", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"), icon="work_history").props(
"flat"
)
if not document.jobs:
ui.label("No jobs created yet.").classes("text-body2 vibe-text-muted")
ui.button(
"Create job",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="add",
).props('unelevated color="primary"')
else:
with ui.column().classes("w-full gap-1"):
for job in sorted(document.jobs, key=lambda item: item.date_created, reverse=True):
with ui.row().classes("w-full items-center justify-between"):
ui.label(f"{job.status.value} - {job.id}").classes("text-body2")
ui.button("Open", on_click=lambda _=None, job_id=job.id: ui.navigate.to(f"/jobs/{job_id}"), icon="open_in_new").props("flat")
@ui.page("/documents/{document_id}/jobs")
async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> None:
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
try:
parsed_document_id = UUID(document_id)
except ValueError:
ui.label("Invalid document id").classes("text-h6 text-negative")
return
try:
document = await document_service.read_document_detail(document_id=parsed_document_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-negative")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.jobs")
return
ui.label(f"Jobs for {document.name}").classes("text-h5 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
ui.button("Create job", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="add").props(
'unelevated color="primary"'
)
if not document.jobs:
ui.label("No jobs created yet.").classes("text-body2 vibe-text-muted")
return
for job in sorted(document.jobs, key=lambda item: item.date_created, reverse=True):
with ui.card().classes("w-full"):
with ui.row().classes("w-full items-center justify-between"):
ui.label(f"{job.status.value} - {job.id}").classes("text-body2")
ui.button("Open", on_click=lambda _=None, job_id=job.id: ui.navigate.to(f"/jobs/{job_id}"), icon="open_in_new").props(
"flat"
)
@ui.page("/documents/{document_id}/sources")
async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> None:
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
try:
parsed_document_id = UUID(document_id)
except ValueError:
ui.label("Invalid document id").classes("text-h6 text-negative")
return
try:
document = await document_service.read_document_detail(document_id=parsed_document_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-negative")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.sources")
return
ui.label(f"Sources for {document.name}").classes("text-h5 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
ui.button("Add sources", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="upload_file").props(
'unelevated color="primary"'
)
if not document.sources:
ui.label("No sources added yet.").classes("text-body2 vibe-text-muted")
return
for source in sorted(document.sources, key=lambda item: item.page_number):
with ui.card().classes("w-full"):
ui.label(f"Page {source.page_number}: {source.upload_name}").classes("text-body2")
ui.label(f"Stored filename: {source.filename}").classes("text-body2")
@ui.page("/documents/{document_id}/edit")
async def document_edit_page(document_id: str, session_factory: SessionFactoryDep) -> None:
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
try:
parsed_document_id = UUID(document_id)
except ValueError:
ui.label("Invalid document id").classes("text-h6 text-negative")
return
try:
document = await document_service.read_document_detail(document_id=parsed_document_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-negative")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.edit.read")
return
ui.label("Edit document").classes("text-h5 text-weight-medium")
ui.label("Document name and document type are required.").classes("text-body2 vibe-text-muted")
name_input = ui.input(label="Document name", value=document.name).props("outlined")
document_type_input = ui.input(label="Document type", value=document.document_type or "").props("outlined")
date_input = ui.input(
label="Exact date (YYYY-MM-DD)",
value=document.document_date.isoformat() if document.document_date else "",
).props("outlined")
date_raw_input = ui.input(label="Approximate date", value=document.document_date_raw or "").props("outlined")
location_input = ui.input(label="Document location", value=document.location_created or "").props("outlined")
archive_input = ui.input(label="Archive identifier", value=document.archive_identifier or "").props("outlined")
notes_input = ui.textarea(label="Notes", value=document.notes or "").props("outlined autogrow")
async def submit_edit() -> None:
candidate_name = (name_input.value or "").strip()
candidate_type = (document_type_input.value or "").strip()
if not candidate_name:
ui.notify("Document name is required.", type="warning")
return
if not candidate_type:
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
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,
created_at=document.created_at,
updated_at=document.updated_at,
)
try:
await document_service.update_document(candidate)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Save failed", operation="documents.edit.save")
return
ui.notify("Document updated", type="positive")
ui.navigate.to(f"/documents/{document.id}")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Save changes", on_click=submit_edit, icon="save").props('unelevated color="primary"')
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
@ui.page("/documents/{document_id}/delete")
async def document_delete_page(document_id: str, session_factory: SessionFactoryDep) -> None:
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
try:
parsed_document_id = UUID(document_id)
except ValueError:
ui.label("Invalid document id").classes("text-h6 text-negative")
return
try:
document = await document_service.read_document_detail(document_id=parsed_document_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-negative")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.delete.read")
return
ui.label("Delete document").classes("text-h5 text-weight-medium")
ui.label(f"Document: {document.name}").classes("text-subtitle1")
has_sources = bool(document.sources)
has_jobs = bool(document.jobs)
if has_sources or has_jobs:
ui.label("Delete is blocked because related records exist.").classes("text-negative text-weight-medium")
categories: list[str] = []
if has_sources:
categories.append("Sources")
if has_jobs:
categories.append("Jobs")
ui.label(f"Dependencies present: {', '.join(categories)}").classes("text-body2")
ui.label("Remove related records first, then retry deletion.").classes("text-body2 vibe-text-muted")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history")
return
ui.label("This action permanently deletes the document.").classes("text-negative")
async def submit_delete() -> None:
try:
await document_service.delete_document(document)
except DocumentDeleteBlockedError as exc:
ui.notify(exc.message, type="warning")
ui.navigate.to(f"/documents/{document.id}/delete")
return
except DocumentError as exc:
if exc.category == ErrorCategory.NOT_FOUND:
ui.notify("Document not found.", type="warning")
ui.navigate.to("/documents")
return
show_error(exc, title="Delete failed", operation="documents.delete")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Delete failed", operation="documents.delete")
return
ui.notify("Document deleted", type="positive")
ui.navigate.to("/documents")
with ui.row().classes("w-full items-center gap-2"):
ui.button(
"Delete document permanently",
on_click=submit_delete,
icon="delete_forever",
).props('unelevated color="negative"')
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
+239 -2
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
from fastapi import Request
from pathlib import Path
from uuid import UUID
from nicegui import ui
@@ -9,11 +11,17 @@ from nicegui import ui
from transcription.db.models import Job
from transcription.db.models import JobStatus
from transcription.db.models import Source
from transcription.db.session import session_scope
from transcription.services.documents import DocumentService
from transcription.services.jobs import JobDeleteBlockedError
from transcription.services.jobs import JobService
from transcription.services.transcription import SourceDeleteBlockedError
from transcription.services.store import create_job_for_document
from transcription.services.transcription import TranscriptionService
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.table.jobs import render_jobs_table
from transcription.worker import resolve_worker_notifier
from ...db.session import SessionFactoryDep
from ..components.document_panzoom import render_document_panzoom
@@ -45,9 +53,134 @@ def register_page() -> None: # noqa: PLR0915
]
render_jobs_table(jobs)
ui.button("Refresh", on_click=render_table.refresh, icon="refresh")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Create job", on_click=lambda: ui.navigate.to("/jobs/new"), icon="add").props(
'unelevated color="primary"'
)
ui.button("Refresh", on_click=render_table.refresh, icon="refresh")
await render_table()
@ui.page("/jobs/new")
async def job_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
documents_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
ui.label("Create job").classes("text-h5 text-weight-medium")
documents = await documents_service.list_documents()
if not documents:
ui.label("No documents available. Create a Document before creating a Job.").classes(
"text-body1 text-warning"
)
with ui.row():
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back")
return
uploaded_files: list[tuple[str, bytes]] = []
document_options = {str(document.id): document.name for document in documents}
document_select = ui.select(document_options, label="Document").props("outlined")
requested_document_id = request.query_params.get("document_id")
if requested_document_id in document_options:
document_select.value = requested_document_id
provider_input = ui.input(label="Provider").props("outlined")
model_input = ui.input(label="Model").props("outlined")
prompt_input = ui.input(label="Prompt").props("outlined")
ui.label("Source files").classes("text-subtitle1 text-weight-medium")
ui.label(
"Files are processed alphabetically by original filename. Use leading numbers such as 001, 002, 003 to control order."
).classes("text-body2 vibe-text-muted")
@ui.refreshable
def render_upload_list() -> None:
if not uploaded_files:
ui.label("No files uploaded yet.").classes("text-body2 vibe-text-muted")
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"):
for index, (filename, _) in ordered_uploads:
with ui.row().classes("w-full items-center justify-between"):
ui.label(Path(filename).name).classes("text-body2")
ui.button(icon="delete", on_click=lambda idx=index: remove_file(idx)).props("flat round dense")
with ui.row().classes("w-full justify-end"):
ui.button("Clear files", on_click=clear_files, icon="clear_all").props("flat")
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",
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf" multiple')
ui.upload(
on_upload=on_upload,
auto_upload=True,
label="Upload folder",
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf" webkitdirectory directory multiple')
render_upload_list()
async def submit_create() -> None:
selected_document = document_select.value
if not selected_document:
ui.notify("Document is required.", type="warning")
return
if not uploaded_files:
ui.notify("At least one source file is required.", type="warning")
return
try:
document_id = UUID(str(selected_document))
except ValueError:
ui.notify("Selected document id is invalid.", type="warning")
return
try:
async with session_scope(session_factory=session_factory) as session:
result = await create_job_for_document(
document_id=document_id,
uploads=uploaded_files,
provider=(provider_input.value or None),
model=(model_input.value or None),
prompt_name=(prompt_input.value or None),
session=session,
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Create job failed", operation="jobs.create")
return
resolve_worker_notifier(request.app.state).notify()
ui.notify(f"Created job {result.job_id}", type="positive")
ui.navigate.to(f"/jobs/{result.job_id}")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Submit for transcription", on_click=submit_create, icon="play_arrow").props(
'unelevated color="primary"'
)
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back")
@ui.page("/jobs/{job_id}")
async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None: # noqa: PLR0915
jobs_service = JobService(session_factory=session_factory)
@@ -85,6 +218,20 @@ def register_page() -> None: # noqa: PLR0915
case _:
ui.label(f"{job.status.value}").classes("text-subtitle1 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
ui.button(
"Delete job",
on_click=lambda: ui.navigate.to(f"/jobs/{parsed_job_id}/delete"),
icon="delete",
).props("outline color=negative")
with ui.column().classes("gap-1"):
ui.label(f"Provider: {job.provider or 'pending'}").classes("text-body2")
ui.label(f"Model: {job.model or 'pending'}").classes("text-body2")
ui.label(f"Prompt: {job.prompt_name or 'pending'}").classes("text-body2")
ui.label(f"Retry count: {job.retry_count}").classes("text-body2")
ui.label(f"Last updated: {job.date_updated.isoformat()}").classes("text-body2")
render_original_transcription_card(job=job)
@ui.refreshable
@@ -102,6 +249,26 @@ def register_page() -> None: # noqa: PLR0915
editor = ui.textarea(label="Revision text", value=default_revision_text).props("autogrow outlined")
editor.classes("w-full")
async def delete_source() -> None:
try:
await transcription_service.delete_source_from_job_context(
job_id=parsed_job_id,
source_id=refreshed_source.id,
)
except SourceDeleteBlockedError as exc:
ui.notify(exc.message, type="warning")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Delete source failed", operation="jobs.delete_source")
return
ui.notify("Source deleted", type="positive")
ui.navigate.to(f"/jobs/{parsed_job_id}")
async def confirm_delete_source() -> None:
delete_dialog.close()
await delete_source()
async def save_revision() -> None:
candidate = (editor.value or "").strip()
if not candidate:
@@ -120,7 +287,22 @@ def register_page() -> None: # noqa: PLR0915
ui.notify("Revision saved", type="positive")
await render_revision_panel.refresh()
with ui.row().classes("w-full justify-end"):
with ui.row().classes("w-full justify-end gap-2"):
with ui.dialog() as delete_dialog, ui.card().classes("min-w-[22rem]"):
ui.label("Delete source").classes("text-subtitle1 text-weight-medium")
ui.label("This permanently deletes the source from this job context.").classes("text-body2")
ui.label("If related job history exists, deletion may be blocked.").classes(
"text-body2 vibe-text-muted"
)
with ui.row().classes("w-full justify-end gap-2"):
ui.button("Cancel", on_click=delete_dialog.close)
ui.button(
"Delete source",
on_click=confirm_delete_source,
icon="delete_forever",
).props('unelevated color="negative"')
ui.button("Delete source", on_click=delete_dialog.open, icon="delete").props("outline color=negative")
ui.button(
"Create revision" if current_revision_text is None else "Update revision",
on_click=save_revision,
@@ -138,6 +320,61 @@ def register_page() -> None: # noqa: PLR0915
await render_revision_panel()
@ui.page("/jobs/{job_id}/delete")
async def job_delete_page(job_id: str, session_factory: SessionFactoryDep) -> None:
jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
try:
parsed_job_id = UUID(job_id)
except ValueError:
ui.label("Invalid job id").classes("text-h6 text-negative")
return
try:
job = await jobs_service.read_job(job_id=parsed_job_id)
except ValueError:
ui.label("Job not found").classes("text-h6 text-negative")
return
ui.label("Delete job").classes("text-h5 text-weight-medium")
ui.label(f"Job: {job.id}").classes("text-subtitle1")
if job.status == JobStatus.PROCESSING:
ui.label("Delete is blocked while the job is processing.").classes("text-negative text-weight-medium")
ui.label("Wait for processing to complete, then retry delete.").classes("text-body2 vibe-text-muted")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Back to Job", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back")
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history")
return
ui.label("This action permanently deletes the job.").classes("text-negative")
if job.job_sources:
ui.label("Related JobSource links will be removed as part of delete.").classes("text-body2")
async def submit_delete() -> None:
try:
await jobs_service.delete_job_with_guardrails(job_id=job.id)
except JobDeleteBlockedError as exc:
ui.notify(exc.message, type="warning")
return
except ValueError:
ui.notify("Job not found.", type="warning")
ui.navigate.to("/jobs")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Delete job failed", operation="jobs.delete")
return
ui.notify("Job deleted", type="positive")
ui.navigate.to("/jobs")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Delete job permanently", on_click=submit_delete, icon="delete_forever").props(
'unelevated color="negative"'
)
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back")
def _resolve_primary_source(job: Job) -> Source | None:
if not job.job_sources:
+356
View File
@@ -0,0 +1,356 @@
"""People list and detail page registration."""
from __future__ import annotations
from datetime import date
from uuid import UUID
from nicegui import ui
from transcription.db.models import Person
from transcription.errors import ErrorCategory
from transcription.services.documents import DocumentError
from transcription.services.documents import DocumentService
from transcription.services.documents import PersonDeleteBlockedError
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.error_presenter import show_error
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 register_page() -> None: # noqa: PLR0915
"""Register people list and CRUD routes."""
@ui.page("/people")
async def people_page(session_factory: SessionFactoryDep) -> None:
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
with ui.row().classes("w-full items-center justify-between"):
ui.label("People").classes("text-h5 text-weight-medium")
ui.button("Create new person", on_click=lambda: ui.navigate.to("/people/new"), icon="person_add").props(
'unelevated color="primary"'
)
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
if not people:
ui.label("No people yet.").classes("text-body1 vibe-text-muted")
return
with ui.column().classes("w-full gap-2"):
for person in people:
with ui.card().classes("w-full"):
with ui.row().classes("w-full items-center justify-between"):
with ui.column().classes("gap-1"):
ui.label(person.full_name).classes("text-subtitle1 text-weight-medium")
ui.label(f"Display name: {person.display_name or 'not set'}").classes("text-body2")
ui.button(
"Open",
on_click=lambda _=None, person_id=person.id: ui.navigate.to(f"/people/{person_id}"),
icon="open_in_new",
).props("flat")
@ui.page("/people/new")
async def person_create_page(session_factory: SessionFactoryDep) -> None:
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
ui.label("Create person").classes("text-h5 text-weight-medium")
ui.label("Full name is required.").classes("text-body2 vibe-text-muted")
full_name_input = ui.input(label="Full name").props("outlined")
display_name_input = ui.input(label="Display name").props("outlined")
maiden_name_input = ui.input(label="Maiden name").props("outlined")
birth_date_input = ui.input(label="Birth date (YYYY-MM-DD)").props("outlined")
birth_date_raw_input = ui.input(label="Birth date (approximate/raw)").props("outlined")
birth_place_input = ui.input(label="Birth place").props("outlined")
death_date_input = ui.input(label="Death date (YYYY-MM-DD)").props("outlined")
death_date_raw_input = ui.input(label="Death date (approximate/raw)").props("outlined")
death_place_input = ui.input(label="Death place").props("outlined")
biography_input = ui.textarea(label="Biography").props("outlined autogrow")
portrait_path_input = ui.input(label="Portrait path").props("outlined")
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"):
ui.button("Save person", on_click=submit_create, icon="save").props('unelevated color="primary"')
ui.button("Cancel", on_click=lambda: ui.navigate.to("/people"), icon="arrow_back")
@ui.page("/people/{person_id}")
async def person_detail_page(person_id: str, session_factory: SessionFactoryDep) -> None:
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-negative")
return
try:
person = await people_service.read_person_detail(parsed_person_id)
except DocumentError:
ui.label("Person not found").classes("text-h6 text-negative")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.read")
return
ui.label(person.full_name).classes("text-h5 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Edit person", on_click=lambda: ui.navigate.to(f"/people/{person.id}/edit"), icon="edit").props(
'unelevated color="primary"'
)
ui.button(
"Delete person",
on_click=lambda: ui.navigate.to(f"/people/{person.id}/delete"),
icon="delete",
).props("outline color=negative")
with ui.column().classes("w-full gap-1"):
ui.label(f"Display name: {person.display_name or 'not set'}")
ui.label(f"Maiden name: {person.maiden_name or 'not set'}")
ui.label(f"Birth date: {person.birth_date.isoformat() if person.birth_date else 'not set'}")
ui.label(f"Birth date (approximate): {person.birth_date_raw or 'not set'}")
ui.label(f"Birth place: {person.birth_place or 'not set'}")
ui.label(f"Death date: {person.death_date.isoformat() if person.death_date else 'not set'}")
ui.label(f"Death date (approximate): {person.death_date_raw or 'not set'}")
ui.label(f"Death place: {person.death_place or 'not set'}")
ui.label(f"Biography: {person.biography or 'not set'}")
ui.label(f"Portrait path: {person.portrait_path or 'not set'}")
ui.label(f"Created at (read-only): {person.created_at.isoformat()}").classes("text-body2")
ui.label(f"Updated at (read-only): {person.updated_at.isoformat()}").classes("text-body2")
ui.separator()
ui.label("Linked documents").classes("text-subtitle1 text-weight-medium")
if not person.document_people:
ui.label("No linked documents yet.").classes("text-body2 vibe-text-muted")
ui.label("Link this person from a Document workflow.").classes("text-body2 vibe-text-muted")
return
with ui.column().classes("w-full gap-1"):
for link in person.document_people:
document = link.document
if document is None:
continue
with ui.row().classes("w-full items-center justify-between"):
ui.label(f"{document.name} ({link.role.value})").classes("text-body2")
ui.button(
"Open",
on_click=lambda _=None, document_id=document.id: ui.navigate.to(f"/documents/{document_id}"),
icon="open_in_new",
).props("flat")
@ui.page("/people/{person_id}/edit")
async def person_edit_page(person_id: str, session_factory: SessionFactoryDep) -> None:
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-negative")
return
try:
person = await people_service.read_person_detail(parsed_person_id)
except DocumentError:
ui.label("Person not found").classes("text-h6 text-negative")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.edit.read")
return
ui.label("Edit person").classes("text-h5 text-weight-medium")
ui.label("Full name is required.").classes("text-body2 vibe-text-muted")
full_name_input = ui.input(label="Full name", value=person.full_name).props("outlined")
display_name_input = ui.input(label="Display name", value=person.display_name or "").props("outlined")
maiden_name_input = ui.input(label="Maiden name", value=person.maiden_name or "").props("outlined")
birth_date_input = ui.input(
label="Birth date (YYYY-MM-DD)",
value=person.birth_date.isoformat() if person.birth_date else "",
).props("outlined")
birth_date_raw_input = ui.input(label="Birth date (approximate/raw)", value=person.birth_date_raw or "").props(
"outlined"
)
birth_place_input = ui.input(label="Birth place", value=person.birth_place or "").props("outlined")
death_date_input = ui.input(
label="Death date (YYYY-MM-DD)",
value=person.death_date.isoformat() if person.death_date else "",
).props("outlined")
death_date_raw_input = ui.input(label="Death date (approximate/raw)", value=person.death_date_raw or "").props(
"outlined"
)
death_place_input = ui.input(label="Death place", value=person.death_place or "").props("outlined")
biography_input = ui.textarea(label="Biography", value=person.biography or "").props("outlined autogrow")
portrait_path_input = ui.input(label="Portrait path", value=person.portrait_path or "").props("outlined")
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"):
ui.button("Save changes", on_click=submit_edit, icon="save").props('unelevated color="primary"')
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back")
@ui.page("/people/{person_id}/delete")
async def person_delete_page(person_id: str, session_factory: SessionFactoryDep) -> None:
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-negative")
return
try:
person = await people_service.read_person_detail(parsed_person_id)
except DocumentError:
ui.label("Person not found").classes("text-h6 text-negative")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.delete.read")
return
ui.label("Delete person").classes("text-h5 text-weight-medium")
ui.label(f"Person: {person.full_name}").classes("text-subtitle1")
if person.document_people:
ui.label("Delete is blocked because linked documents exist.").classes("text-negative text-weight-medium")
ui.label(f"Linked documents: {len(person.document_people)}").classes("text-body2")
ui.label("Remove document links first, then retry deletion.").classes("text-body2 vibe-text-muted")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Back to Person", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back")
ui.button("Go to Documents", on_click=lambda: ui.navigate.to("/documents"), icon="description")
return
ui.label("This action permanently deletes the person.").classes("text-negative")
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"):
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")
+5 -16
View File
@@ -3,29 +3,18 @@
from __future__ import annotations
from fastapi import Request
from fastapi.responses import RedirectResponse
from starlette import status
from nicegui import ui
from transcription.db import session_scope
from transcription.services.store import create_upload_job
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.upload import render_upload_widget
from transcription.worker import resolve_worker_notifier
def register_page() -> None:
"""Register the upload page route."""
@ui.page("/upload", title="Upload Document")
def upload_page(request: Request) -> None:
def upload_page(request: Request) -> RedirectResponse:
_ = request
render_navigation_header(current_path="/upload")
async def submit_upload(filename: str, file_bytes: bytes):
async with session_scope() as session:
return await create_upload_job(
filename=filename,
file_bytes=file_bytes,
session=session,
)
notify_worker = resolve_worker_notifier(request.app.state)
render_upload_widget(submitter=submit_upload, notifier=notify_worker)
return RedirectResponse(url="/ui/jobs/new", status_code=status.HTTP_307_TEMPORARY_REDIRECT)