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}/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**.
+26 -1
View File
@@ -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,6 +61,7 @@ class MaintenanceService(ServiceBase):
limit: int = 100,
session: AsyncSession | None = None,
) -> list[MaintenanceRun]:
try:
async with self._session_scope(session) as _session:
await self._ensure_runs_table(session=_session)
query = (
@@ -68,6 +70,13 @@ class MaintenanceService(ServiceBase):
.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,13 +90,22 @@ class MaintenanceService(ServiceBase):
status=MaintenanceRunStatus.QUEUED,
triggered_by=triggered_by,
)
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:
try:
async with self._session_scope(session) as _session:
await self._ensure_runs_table(session=_session)
now = _utc_now_naive()
@@ -118,6 +136,13 @@ class MaintenanceService(ServiceBase):
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:
+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"):
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",
+28
View File
@@ -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