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}/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**.
|
||||||
|
|||||||
@@ -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,14 +61,22 @@ class MaintenanceService(ServiceBase):
|
|||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
session: AsyncSession | None = None,
|
session: AsyncSession | None = None,
|
||||||
) -> list[MaintenanceRun]:
|
) -> list[MaintenanceRun]:
|
||||||
async with self._session_scope(session) as _session:
|
try:
|
||||||
await self._ensure_runs_table(session=_session)
|
async with self._session_scope(session) as _session:
|
||||||
query = (
|
await self._ensure_runs_table(session=_session)
|
||||||
select(MaintenanceRun)
|
query = (
|
||||||
.order_by(col(MaintenanceRun.created_at).desc(), col(MaintenanceRun.id).desc())
|
select(MaintenanceRun)
|
||||||
.limit(limit)
|
.order_by(col(MaintenanceRun.created_at).desc(), col(MaintenanceRun.id).desc())
|
||||||
)
|
.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,43 +90,59 @@ class MaintenanceService(ServiceBase):
|
|||||||
status=MaintenanceRunStatus.QUEUED,
|
status=MaintenanceRunStatus.QUEUED,
|
||||||
triggered_by=triggered_by,
|
triggered_by=triggered_by,
|
||||||
)
|
)
|
||||||
async with self._session_scope(session) as _session:
|
try:
|
||||||
await self._ensure_runs_table(session=_session)
|
async with self._session_scope(session) as _session:
|
||||||
_session.add(run)
|
await self._ensure_runs_table(session=_session)
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(run,))
|
_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
|
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:
|
||||||
async with self._session_scope(session) as _session:
|
try:
|
||||||
await self._ensure_runs_table(session=_session)
|
async with self._session_scope(session) as _session:
|
||||||
now = _utc_now_naive()
|
await self._ensure_runs_table(session=_session)
|
||||||
queued_run_id = (
|
now = _utc_now_naive()
|
||||||
select(col(MaintenanceRun.id))
|
queued_run_id = (
|
||||||
.where(col(MaintenanceRun.status) == MaintenanceRunStatus.QUEUED)
|
select(col(MaintenanceRun.id))
|
||||||
.order_by(col(MaintenanceRun.created_at), col(MaintenanceRun.id))
|
.where(col(MaintenanceRun.status) == MaintenanceRunStatus.QUEUED)
|
||||||
.limit(1)
|
.order_by(col(MaintenanceRun.created_at), col(MaintenanceRun.id))
|
||||||
.scalar_subquery()
|
.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,
|
|
||||||
)
|
)
|
||||||
.returning(col(MaintenanceRun.id))
|
claim_statement = (
|
||||||
)
|
update(MaintenanceRun)
|
||||||
claimed_row = (await _session.exec(claim_statement)).first()
|
.where(col(MaintenanceRun.id) == queued_run_id)
|
||||||
if claimed_row is None:
|
.where(col(MaintenanceRun.status) == MaintenanceRunStatus.QUEUED)
|
||||||
return None
|
.values(
|
||||||
claimed_run_id = claimed_row if isinstance(claimed_row, UUID) else claimed_row[0]
|
status=MaintenanceRunStatus.PROCESSING,
|
||||||
run = await _session.get(MaintenanceRun, claimed_run_id)
|
started_at=now,
|
||||||
if run is None:
|
updated_at=now,
|
||||||
return None
|
)
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(run,))
|
.returning(col(MaintenanceRun.id))
|
||||||
return run
|
)
|
||||||
|
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:
|
async def _ensure_runs_table(self, *, session: AsyncSession) -> None:
|
||||||
if self._maintenance_table_ready:
|
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"):
|
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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user