generated from john/python-template
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
Quality Gate / gate (push) Failing after 2m30s
This commit is contained in:
@@ -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**.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user