V6.1 refinements - add link to source image gallery on Document detail page. Fix Maintenance tab actions (again).
Quality Gate / gate (push) Failing after 2m30s

This commit is contained in:
Jim Lancaster
2026-09-01 13:32:46 -05:00
parent 9b6bb9ae66
commit 32b6b5f29a
4 changed files with 183 additions and 60 deletions
+10 -3
View File
@@ -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}/edit` | Edit metadata and the complete Linked People set. |
| `/documents/{document_id}/delete` | Confirm or block deletion. | | `/documents/{document_id}/delete` | Confirm or block deletion. |
| `/documents/{document_id}/jobs` | Show Jobs belonging to the Document. | | `/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. | | `/documents/{document_id}/print` | Preview and browser-print the persisted Document. |
## List Behavior ## 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 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. - The center column is **Editable Revision** for the active source page.
- Related People are grouped by role and link to Person Detail. - 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. - **Source Pages & Transcriptions** shows source/job counts and actions for source-image gallery, document jobs, and adding a Job.
- **Edit Document**, **Print**, **Document Details**, and **Delete** are available from the header. - **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. - 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 ## Document Info Behavior
- `/documents/{document_id}/info` contains **Archival Metadata** and **System Logistics**. - `/documents/{document_id}/info` contains **Archival Metadata** and **System Logistics**.
+26 -1
View File
@@ -7,6 +7,7 @@ from dataclasses import dataclass
from datetime import UTC from datetime import UTC
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any
from uuid import UUID from uuid import UUID
from sqlalchemy import update from sqlalchemy import update
@@ -50,7 +51,7 @@ class MaintenanceError(AppError):
class MaintenanceService(ServiceBase): class MaintenanceService(ServiceBase):
"""Persist and execute background maintenance runs.""" """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) super().__init__(session_factory=session_factory, settings=settings)
self._maintenance_table_ready = False self._maintenance_table_ready = False
@@ -60,6 +61,7 @@ class MaintenanceService(ServiceBase):
limit: int = 100, limit: int = 100,
session: AsyncSession | None = None, session: AsyncSession | None = None,
) -> list[MaintenanceRun]: ) -> list[MaintenanceRun]:
try:
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
await self._ensure_runs_table(session=_session) await self._ensure_runs_table(session=_session)
query = ( query = (
@@ -68,6 +70,13 @@ class MaintenanceService(ServiceBase):
.limit(limit) .limit(limit)
) )
return list((await _session.exec(query)).all()) 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( async def enqueue_run(
self, self,
@@ -81,13 +90,22 @@ class MaintenanceService(ServiceBase):
status=MaintenanceRunStatus.QUEUED, status=MaintenanceRunStatus.QUEUED,
triggered_by=triggered_by, triggered_by=triggered_by,
) )
try:
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
await self._ensure_runs_table(session=_session) await self._ensure_runs_table(session=_session)
_session.add(run) _session.add(run)
await self._finalize(session=_session, caller_session=session, refresh=(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 return run
async def claim_next_queued_run(self, *, session: AsyncSession | None = None) -> MaintenanceRun | None: async def claim_next_queued_run(self, *, session: AsyncSession | None = None) -> MaintenanceRun | None:
try:
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
await self._ensure_runs_table(session=_session) await self._ensure_runs_table(session=_session)
now = _utc_now_naive() now = _utc_now_naive()
@@ -118,6 +136,13 @@ class MaintenanceService(ServiceBase):
return None return None
await self._finalize(session=_session, caller_session=session, refresh=(run,)) await self._finalize(session=_session, caller_session=session, refresh=(run,))
return 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: async def _ensure_runs_table(self, *, session: AsyncSession) -> None:
if self._maintenance_table_ready: if self._maintenance_table_ready:
+78 -15
View File
@@ -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"): 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" 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(): with section_header_row():
page_header(document.name, subtitle=f"Type: {type_display} | ID: {document.id}") 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"), on_click=lambda: ui.navigate.to(f"/documents/{document.id}/info"),
icon="info", icon="info",
).props("flat").classes("text-xs") ).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( destructive_button(
"Delete", "Delete",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/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}") return RedirectResponse(url=f"/ui/jobs?document_id={document_id}")
@ui.page("/documents/{document_id}/sources") @ui.page("/documents/{document_id}/sources")
async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse: async def document_sources_page(request: Request, document_id: str, session_factory: SessionFactoryDep) -> None:
_ = session_factory document_service = DocumentService(session_factory=session_factory)
return RedirectResponse(url=f"/ui/documents/{document_id}") 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") @ui.page("/documents/{document_id}/edit")
async def document_edit_page(document_id: str, session_factory: SessionFactoryDep) -> None: 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: def _resolve_active_source(document: Document, requested_source_id: UUID | None) -> Source | None:
ordered = sorted( ordered = _sorted_document_sources(document)
document.sources,
key=lambda source: (source.page_number, source.upload_name.casefold(), source.filename.casefold()),
)
if not ordered: if not ordered:
return None return None
if requested_source_id is 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] 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( def _render_document_detail_viewer_zone(
*, *,
document: Document, document: Document,
@@ -779,15 +847,10 @@ def _render_document_processing_card(document: Document) -> None:
metadata_row("Source pages:", str(len(document.sources))) metadata_row("Source pages:", str(len(document.sources)))
metadata_row("Transcription Jobs:", str(len(document.jobs))) metadata_row("Transcription Jobs:", str(len(document.jobs)))
with ui.row().classes("w-full gap-2 mt-2 flex-wrap"): with ui.row().classes("w-full gap-2 mt-2 flex-wrap"):
first_source = _resolve_active_source(document, None)
ui.button( ui.button(
"View Source Detail", "View Source Images",
on_click=( on_click=lambda: ui.navigate.to(f"/documents/{document.id}/sources"),
(lambda: ui.navigate.to(f"/sources/{first_source.id}")) icon="photo_library",
if first_source is not None
else (lambda: ui.notify("No source pages are linked yet.", type="warning"))
),
icon="description",
).props("flat dense text-xs").classes("ui-link-primary") ).props("flat dense text-xs").classes("ui-link-primary")
ui.button( ui.button(
"View Transcription Jobs", "View Transcription Jobs",
+28
View File
@@ -250,6 +250,34 @@ class TestDocumentsPageRendering:
assert "Create job" not in response.text assert "Create job" not in response.text
assert "Refresh" 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 @pytest.mark.asyncio
async def test_document_edit_page_prefills_existing_values(self, app_client, seed_person_and_document): async def test_document_edit_page_prefills_existing_values(self, app_client, seed_person_and_document):
_, client = app_client _, client = app_client