diff --git a/docs/ui/pages/documents.md b/docs/ui/pages/documents.md index bd86f4c..f35b5b2 100644 --- a/docs/ui/pages/documents.md +++ b/docs/ui/pages/documents.md @@ -15,7 +15,7 @@ Documents manages the archival record for each historical artifact independently | `/documents/{document_id}/edit` | Edit metadata and the complete Linked People set. | | `/documents/{document_id}/delete` | Confirm or block deletion. | | `/documents/{document_id}/jobs` | Show Jobs belonging to the Document. | -| `/documents/{document_id}/sources` | Redirect back to Document Detail. | +| `/documents/{document_id}/sources` | Source-image gallery for the Document. | | `/documents/{document_id}/print` | Preview and browser-print the persisted Document. | ## List Behavior @@ -75,10 +75,17 @@ Rules: - The detail workspace shows a Source-style pan/zoom media viewer with **Previous Page** / **Next Page** navigation for document source pages. - The center column is **Editable Revision** for the active source page. - Related People are grouped by role and link to Person Detail. -- **Source Pages & Transcriptions** shows source/job counts and actions for source detail, document jobs, and adding a Job. -- **Edit Document**, **Print**, **Document Details**, and **Delete** are available from the header. +- **Source Pages & Transcriptions** shows source/job counts and actions for source-image gallery, document jobs, and adding a Job. +- **Edit Document**, **Print**, **Document Details**, **View Source Detail**, and **Delete** are available from the header. - Invalid IDs and missing Documents produce explicit states without rendering a partial page. +## Document Source Images Behavior + +- `/documents/{document_id}/sources` shows the current Document's source pages in a thumbnail gallery. +- Each card shows the page number, stored filename, and an **Open Source Detail** action. +- The page includes a **Back to Document** action. +- No source pages displays an explicit empty state. + ## Document Info Behavior - `/documents/{document_id}/info` contains **Archival Metadata** and **System Logistics**. diff --git a/src/transcription/services/maintenance.py b/src/transcription/services/maintenance.py index 1b98c0d..6e65924 100644 --- a/src/transcription/services/maintenance.py +++ b/src/transcription/services/maintenance.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from datetime import UTC from datetime import datetime from pathlib import Path +from typing import Any from uuid import UUID from sqlalchemy import update @@ -50,7 +51,7 @@ class MaintenanceError(AppError): class MaintenanceService(ServiceBase): """Persist and execute background maintenance runs.""" - def __init__(self, session_factory=None, settings=None): + def __init__(self, session_factory: Any = None, settings: Any = None): super().__init__(session_factory=session_factory, settings=settings) self._maintenance_table_ready = False @@ -60,14 +61,22 @@ class MaintenanceService(ServiceBase): limit: int = 100, session: AsyncSession | None = None, ) -> list[MaintenanceRun]: - async with self._session_scope(session) as _session: - await self._ensure_runs_table(session=_session) - query = ( - select(MaintenanceRun) - .order_by(col(MaintenanceRun.created_at).desc(), col(MaintenanceRun.id).desc()) - .limit(limit) - ) - return list((await _session.exec(query)).all()) + try: + async with self._session_scope(session) as _session: + await self._ensure_runs_table(session=_session) + query = ( + select(MaintenanceRun) + .order_by(col(MaintenanceRun.created_at).desc(), col(MaintenanceRun.id).desc()) + .limit(limit) + ) + return list((await _session.exec(query)).all()) + except SQLAlchemyError as exc: + raise MaintenanceError( + "Maintenance runs are unavailable.", + category=ErrorCategory.INFRA_PERSISTENT, + suggestion="Verify database schema access and retry.", + detail=f"Failed to list maintenance runs: {type(exc).__name__}: {exc}", + ) from exc async def enqueue_run( self, @@ -81,43 +90,59 @@ class MaintenanceService(ServiceBase): status=MaintenanceRunStatus.QUEUED, triggered_by=triggered_by, ) - async with self._session_scope(session) as _session: - await self._ensure_runs_table(session=_session) - _session.add(run) - await self._finalize(session=_session, caller_session=session, refresh=(run,)) + try: + async with self._session_scope(session) as _session: + await self._ensure_runs_table(session=_session) + _session.add(run) + await self._finalize(session=_session, caller_session=session, refresh=(run,)) + except SQLAlchemyError as exc: + raise MaintenanceError( + "Maintenance run could not be queued.", + category=ErrorCategory.INFRA_PERSISTENT, + suggestion="Verify database schema access and retry.", + detail=f"Failed to enqueue maintenance run: {type(exc).__name__}: {exc}", + ) from exc return run async def claim_next_queued_run(self, *, session: AsyncSession | None = None) -> MaintenanceRun | None: - async with self._session_scope(session) as _session: - await self._ensure_runs_table(session=_session) - now = _utc_now_naive() - queued_run_id = ( - select(col(MaintenanceRun.id)) - .where(col(MaintenanceRun.status) == MaintenanceRunStatus.QUEUED) - .order_by(col(MaintenanceRun.created_at), col(MaintenanceRun.id)) - .limit(1) - .scalar_subquery() - ) - claim_statement = ( - update(MaintenanceRun) - .where(col(MaintenanceRun.id) == queued_run_id) - .where(col(MaintenanceRun.status) == MaintenanceRunStatus.QUEUED) - .values( - status=MaintenanceRunStatus.PROCESSING, - started_at=now, - updated_at=now, + try: + async with self._session_scope(session) as _session: + await self._ensure_runs_table(session=_session) + now = _utc_now_naive() + queued_run_id = ( + select(col(MaintenanceRun.id)) + .where(col(MaintenanceRun.status) == MaintenanceRunStatus.QUEUED) + .order_by(col(MaintenanceRun.created_at), col(MaintenanceRun.id)) + .limit(1) + .scalar_subquery() ) - .returning(col(MaintenanceRun.id)) - ) - claimed_row = (await _session.exec(claim_statement)).first() - if claimed_row is None: - return None - claimed_run_id = claimed_row if isinstance(claimed_row, UUID) else claimed_row[0] - run = await _session.get(MaintenanceRun, claimed_run_id) - if run is None: - return None - await self._finalize(session=_session, caller_session=session, refresh=(run,)) - return run + claim_statement = ( + update(MaintenanceRun) + .where(col(MaintenanceRun.id) == queued_run_id) + .where(col(MaintenanceRun.status) == MaintenanceRunStatus.QUEUED) + .values( + status=MaintenanceRunStatus.PROCESSING, + started_at=now, + updated_at=now, + ) + .returning(col(MaintenanceRun.id)) + ) + claimed_row = (await _session.exec(claim_statement)).first() + if claimed_row is None: + return None + claimed_run_id = claimed_row if isinstance(claimed_row, UUID) else claimed_row[0] + run = await _session.get(MaintenanceRun, claimed_run_id) + if run is None: + return None + await self._finalize(session=_session, caller_session=session, refresh=(run,)) + return run + except SQLAlchemyError as exc: + raise MaintenanceError( + "Maintenance queue claim failed.", + category=ErrorCategory.INFRA_PERSISTENT, + suggestion="Verify database schema access and retry.", + detail=f"Failed to claim queued maintenance run: {type(exc).__name__}: {exc}", + ) from exc async def _ensure_runs_table(self, *, session: AsyncSession) -> None: if self._maintenance_table_ready: diff --git a/src/transcription/ui/pages/documents_page.py b/src/transcription/ui/pages/documents_page.py index 3525d2c..320bb66 100644 --- a/src/transcription/ui/pages/documents_page.py +++ b/src/transcription/ui/pages/documents_page.py @@ -246,6 +246,7 @@ def register_page() -> None: # noqa: PLR0915 with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"): type_display = document.document_type_ref.label if document.document_type_ref is not None else "Unspecified" + first_source = _resolve_active_source(document, None) with section_header_row(): page_header(document.name, subtitle=f"Type: {type_display} | ID: {document.id}") @@ -266,6 +267,19 @@ def register_page() -> None: # noqa: PLR0915 on_click=lambda: ui.navigate.to(f"/documents/{document.id}/info"), icon="info", ).props("flat").classes("text-xs") + ui.button( + "View Source Detail", + on_click=( + ( + lambda: ui.navigate.to( + f"/sources/{first_source.id}?from=document&document_id={document.id}" + ) + ) + if first_source is not None + else (lambda: ui.notify("No source pages are linked yet.", type="warning")) + ), + icon="description", + ).props("flat").classes("text-xs") destructive_button( "Delete", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/delete"), @@ -320,9 +334,59 @@ def register_page() -> None: # noqa: PLR0915 return RedirectResponse(url=f"/ui/jobs?document_id={document_id}") @ui.page("/documents/{document_id}/sources") - async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse: - _ = session_factory - return RedirectResponse(url=f"/ui/documents/{document_id}") + async def document_sources_page(request: Request, document_id: str, session_factory: SessionFactoryDep) -> None: + document_service = DocumentService(session_factory=session_factory) + render_navigation_header(current_path="/documents") + settings = resolve_runtime_settings(request) + + parsed_doc_id = parsed_record_id(document_id, noun="Document") + if parsed_doc_id is None: + return + + try: + document = await document_service.read_document_detail(document_id=parsed_doc_id) + except DocumentError: + render_record_not_found("Document") + return + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Load failed", operation="documents.sources.read") + return + + ordered_sources = _sorted_document_sources(document) + with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"): + with section_header_row(): + page_header("Source Images", subtitle=f"{document.name} ({len(ordered_sources)} pages)") + ui.button( + "Back to Document", + on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), + icon="arrow_back", + ).props("flat") + + if not ordered_sources: + render_empty_state("No source pages are linked yet.") + return + + with ui.grid().classes("w-full grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3"): + for source in ordered_sources: + source_url = resolve_media_url( + source.file_path, + upload_dir=settings.upload_dir, + base_url=str(request.base_url), + ) + with archival_card(extra_classes="gap-2"): + if source_url is None: + render_empty_state("Image unavailable.", extra_classes="text-xs") + else: + ui.image(source_url).classes("w-full aspect-[3/4] object-contain rounded-sm bg-black/5") + ui.label(f"Page {source.page_number}").classes("text-xs font-semibold") + ui.label(source.filename).classes("text-[11px] ui-text-muted break-all") + ui.button( + "Open Source Detail", + on_click=lambda _=None, source_id=source.id: ui.navigate.to( + f"/sources/{source_id}?from=document&document_id={document.id}" + ), + icon="description", + ).props("flat dense").classes("text-xs self-start") @ui.page("/documents/{document_id}/edit") async def document_edit_page(document_id: str, session_factory: SessionFactoryDep) -> None: @@ -587,10 +651,7 @@ def _render_document_form_fields( def _resolve_active_source(document: Document, requested_source_id: UUID | None) -> Source | None: - ordered = sorted( - document.sources, - key=lambda source: (source.page_number, source.upload_name.casefold(), source.filename.casefold()), - ) + ordered = _sorted_document_sources(document) if not ordered: return None if requested_source_id is None: @@ -601,6 +662,13 @@ def _resolve_active_source(document: Document, requested_source_id: UUID | None) return ordered[0] +def _sorted_document_sources(document: Document) -> list[Source]: + return sorted( + document.sources, + key=lambda source: (source.page_number, source.upload_name.casefold(), source.filename.casefold()), + ) + + def _render_document_detail_viewer_zone( *, document: Document, @@ -779,15 +847,10 @@ def _render_document_processing_card(document: Document) -> None: metadata_row("Source pages:", str(len(document.sources))) metadata_row("Transcription Jobs:", str(len(document.jobs))) with ui.row().classes("w-full gap-2 mt-2 flex-wrap"): - first_source = _resolve_active_source(document, None) ui.button( - "View Source Detail", - on_click=( - (lambda: ui.navigate.to(f"/sources/{first_source.id}")) - if first_source is not None - else (lambda: ui.notify("No source pages are linked yet.", type="warning")) - ), - icon="description", + "View Source Images", + on_click=lambda: ui.navigate.to(f"/documents/{document.id}/sources"), + icon="photo_library", ).props("flat dense text-xs").classes("ui-link-primary") ui.button( "View Transcription Jobs", diff --git a/tests/ui/test_documents_page.py b/tests/ui/test_documents_page.py index c7c4f6b..7662d17 100644 --- a/tests/ui/test_documents_page.py +++ b/tests/ui/test_documents_page.py @@ -250,6 +250,34 @@ class TestDocumentsPageRendering: assert "Create job" not in response.text assert "Refresh" not in response.text + @pytest.mark.asyncio + async def test_document_sources_page_renders_thumbnail_gallery(self, app_client): + _, client = app_client + + async with session_scope() as session: + doc = Document(name="Doc With Sources") + session.add(doc) + await session.flush() + source = Source( + document_id=doc.id, + page_number=1, + upload_name="scan-01.jpg", + filename="scan-01.jpg", + file_path="documents/sample/scan-01.jpg", + file_hash="c" * 64, + file_size_bytes=1, + ) + session.add(source) + await session.commit() + doc_id = str(doc.id) + + response = client.get(f"/ui/documents/{doc_id}/sources") + + assert response.status_code == 200 + assert "Source Images" in response.text + assert "Open Source Detail" in response.text + assert "scan-01.jpg" in response.text + @pytest.mark.asyncio async def test_document_edit_page_prefills_existing_values(self, app_client, seed_person_and_document): _, client = app_client