Revamped Sources related pages with the help of Gemini, which had a lot to say.

This commit is contained in:
Jim Lancaster
2026-08-05 12:35:54 -05:00
parent 4eeb552273
commit 72bc96ab3a
6 changed files with 416 additions and 298 deletions
+23
View File
@@ -181,6 +181,29 @@ class Source(SQLModel, table=True):
document: Optional["Document"] = Relationship(back_populates="sources", sa_relationship_kwargs={"lazy": "selectin"}) document: Optional["Document"] = Relationship(back_populates="sources", sa_relationship_kwargs={"lazy": "selectin"})
job_sources: list["JobSource"] = Relationship(back_populates="source", sa_relationship_kwargs={"lazy": "selectin"}) job_sources: list["JobSource"] = Relationship(back_populates="source", sa_relationship_kwargs={"lazy": "selectin"})
@property
def latest_job_source(self) -> Optional["JobSource"]:
"""Return the most recent job execution record for this source."""
if not self.job_sources:
return None
return max(self.job_sources, key=lambda js: js.executed_at)
@property
def latest_status(self) -> JobSourceStatus | None:
"""Return the execution status of the latest job run."""
latest = self.latest_job_source
return latest.status if latest else None
@property
def latest_error_detail(self) -> str | None:
"""Return the error detail from the latest job run, if present."""
latest = self.latest_job_source
return latest.error_detail if latest else None
@property
def document_name(self) -> str | None:
"""Return the parent document name if loaded."""
return self.document.name if self.document else None
class JobSource(SQLModel, table=True): class JobSource(SQLModel, table=True):
"""A single AI execution record for one source page.""" """A single AI execution record for one source page."""
+41 -31
View File
@@ -4,26 +4,21 @@ import logging
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
from nicegui import events from nicegui import events, ui
from nicegui import ui
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _extract_row_id(args: Any) -> str | None: def _extract_row_id(args: Any) -> str | None:
if isinstance(args, dict): if isinstance(args, list) and len(args) > 1 and isinstance(args[1], dict):
if isinstance(args.get("row"), dict): return str(args[1].get("id")) if args[1].get("id") is not None else None
row_id = args["row"].get("id")
return str(row_id) if row_id is not None else None
row_id = args.get("id")
return str(row_id) if row_id is not None else None
if isinstance(args, list): if isinstance(args, dict):
for value in args: row = args.get("row")
if isinstance(value, dict): if isinstance(row, dict) and "id" in row:
row_id = value.get("id") return str(row["id"])
if row_id is not None: if "id" in args:
return str(row_id) return str(args["id"])
return None return None
@@ -40,7 +35,6 @@ def _bind_row_click_handler(
on_row_click_id(row_id) on_row_click_id(row_id)
table.on("rowClick", handle_row_click) table.on("rowClick", handle_row_click)
logger.debug("Row click handler bound to table")
def build_table( def build_table(
@@ -49,31 +43,47 @@ def build_table(
*, *,
default_sort_by: str | None = None, default_sort_by: str | None = None,
default_descending: bool = False, default_descending: bool = False,
classes: str = "app-table", classes: str = "",
show_search: bool = True,
search_placeholder: str = "Search records...",
on_row_click_id: Callable[[str], None] | None = None, on_row_click_id: Callable[[str], None] | None = None,
) -> Any: ) -> Any:
"""Build a styled Quasar table widget with optional client-side filtering and row-click handlers."""
pagination: dict[str, Any] = {"rowsPerPage": 25} pagination: dict[str, Any] = {"rowsPerPage": 25}
if default_sort_by is not None: if default_sort_by is not None:
pagination["sortBy"] = default_sort_by pagination["sortBy"] = default_sort_by
pagination["descending"] = default_descending pagination["descending"] = default_descending
# Quasar props enforce behavior; visual styling is centralized in theme.css. # Use a parent container to hold both the search bar and the table seamlessly
table = ( with ui.column().classes("w-full gap-2"):
ui.table( if show_search:
rows=rows, with ui.row().classes("w-full items-center justify-end"):
columns=columns, search_input = (
row_key="id", ui.input(placeholder=search_placeholder)
pagination=pagination, .props("dense outlined clearable icon=search")
.classes("w-64 text-xs bg-white")
)
table = (
ui.table(
rows=rows,
columns=columns,
row_key="id",
pagination=pagination,
)
.classes(f"w-full ui-table {classes}".strip())
.props(
'flat square binary-state-sort table-style="table-layout: fixed; width: 100%;" '
'header-cell-class="ui-table-header text-xs uppercase tracking-wider" '
'table-class="ui-table-body text-xs"'
)
) )
.classes(f"w-full ui-table {classes}")
.props( # Bind client-side text filter if search input is active
'flat square binary-state-sort table-style="table-layout: fixed; width: 100%;" ' if show_search:
'header-cell-class="ui-table-header text-xs uppercase tracking-wider" ' table.bind_filter_from(search_input, "value")
'table-class="ui-table-body text-xs"'
)
)
logger.info("Table built with %d rows and %d columns", len(rows), len(columns))
if on_row_click_id is not None: if on_row_click_id is not None:
_bind_row_click_handler(table, on_row_click_id=on_row_click_id) _bind_row_click_handler(table, on_row_click_id=on_row_click_id)
return table return table
@@ -23,6 +23,7 @@ class SourceTableRow:
upload_name: str upload_name: str
filename: str filename: str
document_id: UUID document_id: UUID
document_name: str | None = None
job_source_status: str | None = None job_source_status: str | None = None
job_source_error_detail: str | None = None job_source_error_detail: str | None = None
@@ -35,6 +36,7 @@ def _serialize_rows(rows: Sequence[SourceTableRow]) -> list[dict[str, Any]]:
"upload_name": row.upload_name, "upload_name": row.upload_name,
"filename": row.filename, "filename": row.filename,
"document_id": str(row.document_id), "document_id": str(row.document_id),
"document_name": row.document_name or "-",
"job_source_status": row.job_source_status or "-", "job_source_status": row.job_source_status or "-",
"job_source_error_detail": row.job_source_error_detail or "-", "job_source_error_detail": row.job_source_error_detail or "-",
} }
@@ -49,29 +51,69 @@ def render_sources_table(rows: Sequence[SourceTableRow]) -> None:
render_empty_state("No source file records found.") render_empty_state("No source file records found.")
return return
build_table( table = build_table(
rows=_serialize_rows(rows), rows=_serialize_rows(rows),
columns=[ columns=[
{"name": "page_number", "label": "Page", "field": "page_number", "sortable": True}, {
{"name": "upload_name", "label": "Upload Title", "field": "upload_name", "sortable": True, "classes": "font-serif"}, "name": "document_name",
{"name": "filename", "label": "Stored Filename", "field": "filename", "sortable": True, "classes": "font-mono"}, "label": "Document Name",
"field": "document_name",
"sortable": True,
"classes": "font-serif",
},
{
"name": "page_number",
"label": "Page Number",
"field": "page_number",
"sortable": True,
},
{
"name": "upload_name",
"label": "Upload Title",
"field": "upload_name",
"sortable": True,
"classes": "font-serif",
},
{
"name": "filename",
"label": "Stored Filename",
"field": "filename",
"sortable": True,
"classes": "font-mono",
},
{ {
"name": "job_source_status", "name": "job_source_status",
"label": "Job Source Status", "label": "Status",
"field": "job_source_status", "field": "job_source_status",
"sortable": True, "sortable": True,
"classes": "font-mono", "classes": "font-mono",
}, },
{ {
"name": "job_source_error_detail", "name": "job_source_error_detail",
"label": "Job Source Error Detail", "label": "Error Detail",
"field": "job_source_error_detail", "field": "job_source_error_detail",
"sortable": False, "sortable": False,
"classes": "font-mono text-xs", "classes": "font-mono text-xs truncate max-w-xs vibe-text-muted",
}, },
{"name": "document_id", "label": "Document ID", "field": "document_id", "sortable": True, "classes": "font-mono"},
], ],
default_sort_by="page_number", default_sort_by="page_number",
classes="app-table w-full",
on_row_click_id=lambda source_id: ui.navigate.to(f"/sources/{source_id}"), on_row_click_id=lambda source_id: ui.navigate.to(f"/sources/{source_id}"),
) )
# Render job execution status using themed Quasar chips
table.add_slot(
"body-cell-job_source_status",
r"""
<q-td :props="props">
<q-chip
dense
square
size="sm"
:color="props.value === 'transcribed' ? 'positive' : props.value === 'failed' ? 'negative' : 'grey-5'"
text-color="white"
>
{{ props.value }}
</q-chip>
</q-td>
""",
)
+145 -123
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from urllib.parse import urlencode from urllib.parse import urlencode
from uuid import UUID from uuid import UUID
@@ -9,7 +10,7 @@ from fastapi import Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from nicegui import ui from nicegui import ui
from transcription.db.models import JobSource, Source from transcription.db.models import Source
from transcription.services.documents import DocumentError, DocumentService from transcription.services.documents import DocumentError, DocumentService
from transcription.services.jobs import JobService from transcription.services.jobs import JobService
from transcription.services.transcription import ( from transcription.services.transcription import (
@@ -22,14 +23,15 @@ from transcription.ui.components.cards import archival_card
from transcription.ui.components.data_display import metadata_row from transcription.ui.components.data_display import metadata_row
from transcription.ui.components.document_panzoom import render_document_panzoom from transcription.ui.components.document_panzoom import render_document_panzoom
from transcription.ui.components.error_presenter import show_error from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.primitives import destructive_button from transcription.ui.components.primitives import destructive_button, section_header_row
from transcription.ui.components.primitives import section_header_row
from transcription.ui.components.table.sources import SourceTableRow, render_sources_table from transcription.ui.components.table.sources import SourceTableRow, render_sources_table
from transcription.ui.theme import apply_archival_theme from transcription.ui.theme import apply_archival_theme, page_header
from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep from ...db.session import SessionFactoryDep
if TYPE_CHECKING:
from starlette.datastructures import QueryParams
def register_page() -> None: def register_page() -> None:
"""Register source list and detail routes.""" """Register source list and detail routes."""
@@ -42,37 +44,34 @@ def register_page() -> None:
documents_service = DocumentService(session_factory=session_factory) documents_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/sources") render_navigation_header(current_path="/sources")
document_id_text = request.query_params.get("document_id") document_id = _parse_uuid(request.query_params.get("document_id"))
job_id_text = request.query_params.get("job_id") job_id = _parse_uuid(request.query_params.get("job_id"))
document_id = _parse_uuid(document_id_text)
job_id = _parse_uuid(job_id_text)
document_name = None document_name = None
job_label = None job_label = None
back_path = None back_path = None
sources: list[Source] = [] sources: list[Source] = []
job_source_by_source_id: dict[UUID, JobSource] = {}
try: try:
if document_id is not None: if document_id is not None:
document = await documents_service.read_document_detail(document_id=document_id) document = await documents_service.read_document_detail(document_id=document_id)
document_name = document.name document_name = document.name
back_path = f"/documents/{document.id}" back_path = f"/documents/{document.id}"
sources = list(sorted(document.sources, key=lambda item: (item.page_number, item.upload_name.casefold()))) sources = sorted(document.sources, key=lambda item: (item.page_number, item.upload_name.casefold()))
elif job_id is not None: elif job_id is not None:
job = await jobs_service.read_job(job_id=job_id) job = await jobs_service.read_job(job_id=job_id)
job_label = str(job.id) job_label = str(job.id)
back_path = f"/jobs/{job.id}" back_path = f"/jobs/{job.id}"
job_sources = await sources_service.list_job_sources(job_id=job.id) job_sources = await sources_service.list_job_sources(job_id=job.id)
job_source_by_source_id = { sources = sorted(
job_source.source_id: job_source [js.source for js in job_sources if js.source is not None],
for job_source in job_sources key=lambda item: (item.page_number, item.upload_name.casefold()),
} )
sources = [job_source.source for job_source in job_sources if job_source.source is not None]
sources.sort(key=lambda item: (item.page_number, item.upload_name.casefold()))
else: else:
sources = list(sorted(await sources_service.list_sources(), key=lambda item: (item.document_id, item.page_number))) sources = sorted(
await sources_service.list_sources(),
key=lambda item: (item.document_id, item.page_number),
)
except DocumentError: except DocumentError:
ui.label("Document not found").classes("text-h6 text-red-800 p-4") ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return return
@@ -85,22 +84,18 @@ def register_page() -> None:
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"): with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
with section_header_row(): with section_header_row():
if document_name is not None: header_title = _get_list_header_title(document_name, job_label)
header_title = f"Sources: {document_name}"
elif job_label is not None:
header_title = f"Sources for Job {job_label}"
else:
header_title = "Archival Source Media"
page_header(header_title) page_header(header_title)
if back_path is not None: if back_path is not None:
back_label = "Back to Document" if document_id is not None else "Back to Job" back_label = "Back to Document" if document_id is not None else "Back to Job"
ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back").classes( ui.button(
"ui-btn-primary text-xs" back_label,
) on_click=lambda route=back_path: ui.navigate.to(route),
icon="arrow_back",
).classes("ui-btn-primary text-xs")
# Format source records into read-model rows for the table renderer # Clean row construction leveraging SQLModel @property definitions
rows = [ rows = [
SourceTableRow( SourceTableRow(
id=source.id, id=source.id,
@@ -108,16 +103,9 @@ def register_page() -> None:
upload_name=source.upload_name, upload_name=source.upload_name,
filename=source.filename, filename=source.filename,
document_id=source.document_id, document_id=source.document_id,
job_source_status=( document_name=source.document_name or document_name,
job_source_by_source_id[source.id].status.value job_source_status=source.latest_status.value if source.latest_status else None,
if source.id in job_source_by_source_id job_source_error_detail=source.latest_error_detail,
else None
),
job_source_error_detail=(
job_source_by_source_id[source.id].error_detail
if source.id in job_source_by_source_id
else None
),
) )
for source in sources for source in sources
] ]
@@ -129,9 +117,8 @@ def register_page() -> None:
sources_service = TranscriptionService(session_factory=session_factory) sources_service = TranscriptionService(session_factory=session_factory)
render_navigation_header(current_path="/sources") render_navigation_header(current_path="/sources")
try: parsed_source_id = _parse_uuid(source_id)
parsed_source_id = UUID(source_id) if parsed_source_id is None:
except ValueError:
ui.label("Invalid source id").classes("text-h6 text-red-800 p-4") ui.label("Invalid source id").classes("text-h6 text-red-800 p-4")
return return
@@ -149,30 +136,7 @@ def register_page() -> None:
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"):
with section_header_row(): with section_header_row():
page_header(f"Source Page {source.page_number}: {source.upload_name}", subtitle=f"Source ID: {source.id}") page_header(f"Source Page {source.page_number}: {source.upload_name}", subtitle=f"Source ID: {source.id}")
_render_source_header_actions(source, back_path, request.query_params)
with ui.row().classes("items-center gap-2"):
if back_path is not None:
back_label = (
"Back to Document"
if "document_id" in request.query_params
else "Back to Job"
if "job_id" in request.query_params
else "Back to Sources"
)
ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back").classes(
"ui-btn-primary text-xs"
)
else:
ui.button("Back to Sources", on_click=lambda: ui.navigate.to("/sources"), icon="arrow_back").props(
"flat text-xs"
)
destructive_button(
"Delete Source",
on_click=lambda: ui.navigate.to(f"/sources/{source.id}/delete{_back_query(request.query_params)}"),
icon="delete",
extra_classes="text-xs",
)
with ui.grid().classes("w-full grid-cols-12 gap-4"): with ui.grid().classes("w-full grid-cols-12 gap-4"):
with ui.column().classes("col-span-12 lg:col-span-7 gap-4"): with ui.column().classes("col-span-12 lg:col-span-7 gap-4"):
@@ -180,57 +144,10 @@ def register_page() -> None:
render_document_panzoom(source=source) render_document_panzoom(source=source)
with ui.column().classes("col-span-12 lg:col-span-5 gap-4"): with ui.column().classes("col-span-12 lg:col-span-5 gap-4"):
with archival_card(title="Source Metadata"): _render_source_metadata(source)
metadata_row("Page Number:", str(source.page_number)) _render_job_outcomes(source)
metadata_row("Upload Name:", source.upload_name) _render_raw_transcription(source)
metadata_row("Stored Filename:", source.filename) _render_curated_transcription(source, sources_service, request)
metadata_row("Document ID:", str(source.document_id))
metadata_row("Date Uploaded:", source.date_uploaded.isoformat())
metadata_row(
"Date Revised:",
source.date_revised.isoformat() if source.date_revised else "Not revised",
)
with archival_card(title="Job Source Outcomes"):
if not source.job_sources:
ui.label("No job-source execution records found for this source.").classes("text-xs ui-text-muted")
else:
for job_source in sorted(source.job_sources, key=lambda item: item.executed_at, reverse=True):
with ui.column().classes("w-full gap-1 p-2 ui-row-surface rounded"):
metadata_row("Job ID:", str(job_source.job_id))
metadata_row("Status:", job_source.status.value)
metadata_row("Executed At:", job_source.executed_at.isoformat())
metadata_row("Error Detail:", job_source.error_detail or "None")
with archival_card(title="Automated Raw Transcription"):
ui.textarea(value=_source_transcription_text(source) or "").props("outlined autogrow readonly bg-white").classes(
"w-full text-xs font-mono"
)
with archival_card(title="Curated Human Transcription"):
revision_input = (
ui.textarea(value=source.revised_text or "")
.props("outlined autogrow bg-white")
.classes("w-full text-xs")
)
async def save_revision() -> None:
candidate = (revision_input.value or "").strip()
if not candidate:
ui.notify("Revision text is required.", type="warning")
return
try:
await sources_service.upsert_revision_for_source(source_id=source.id, text=candidate)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Save failed", operation="sources.save_revision")
return
ui.notify("Revision saved", type="positive")
ui.navigate.to(request.url.path + _back_query(request.query_params))
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save Revision", on_click=save_revision, icon="save").classes("ui-btn-primary text-xs")
@ui.page("/sources/{source_id}/delete") @ui.page("/sources/{source_id}/delete")
async def source_delete_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None: async def source_delete_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
@@ -238,9 +155,8 @@ def register_page() -> None:
sources_service = TranscriptionService(session_factory=session_factory) sources_service = TranscriptionService(session_factory=session_factory)
render_navigation_header(current_path="/sources") render_navigation_header(current_path="/sources")
try: parsed_source_id = _parse_uuid(source_id)
parsed_source_id = UUID(source_id) if parsed_source_id is None:
except ValueError:
ui.label("Invalid source id").classes("text-h6 text-red-800 p-4") ui.label("Invalid source id").classes("text-h6 text-red-800 p-4")
return return
@@ -308,6 +224,112 @@ def register_page() -> None:
return RedirectResponse(url=f"/ui/sources?job_id={job_id}") return RedirectResponse(url=f"/ui/sources?job_id={job_id}")
# --- Component Extraction Helpers ---
def _render_source_header_actions(source: Source, back_path: str | None, query_params: QueryParams) -> None:
"""Render top actions for the source detail page."""
with ui.row().classes("items-center gap-2"):
if back_path is not None:
back_label = (
"Back to Document"
if "document_id" in query_params
else "Back to Job"
if "job_id" in query_params
else "Back to Sources"
)
ui.button(
back_label,
on_click=lambda route=back_path: ui.navigate.to(route),
icon="arrow_back",
).classes("ui-btn-primary text-xs")
else:
ui.button("Back to Sources", on_click=lambda: ui.navigate.to("/sources"), icon="arrow_back").props(
"flat text-xs"
)
destructive_button(
"Delete Source",
on_click=lambda: ui.navigate.to(f"/sources/{source.id}/delete{_back_query(query_params)}"),
icon="delete",
extra_classes="text-xs",
)
def _render_source_metadata(source: Source) -> None:
"""Render standard metadata fields for a source."""
with archival_card(title="Source Metadata"):
metadata_row("Page Number:", str(source.page_number))
metadata_row("Upload Name:", source.upload_name)
metadata_row("Stored Filename:", source.filename)
metadata_row("Document ID:", str(source.document_id))
metadata_row("Date Uploaded:", source.date_uploaded.isoformat())
metadata_row(
"Date Revised:",
source.date_revised.isoformat() if source.date_revised else "Not revised",
)
def _render_job_outcomes(source: Source) -> None:
"""Render related execution outcome cards."""
with archival_card(title="Job Source Outcomes"):
if not source.job_sources:
ui.label("No job-source execution records found for this source.").classes("text-xs ui-text-muted")
return
for job_source in sorted(source.job_sources, key=lambda item: item.executed_at, reverse=True):
with ui.column().classes("w-full gap-1 p-2 ui-row-surface rounded"):
metadata_row("Job ID:", str(job_source.job_id))
metadata_row("Status:", job_source.status.value)
metadata_row("Executed At:", job_source.executed_at.isoformat())
metadata_row("Error Detail:", job_source.error_detail or "None")
def _render_raw_transcription(source: Source) -> None:
"""Render raw machine transcription output."""
with archival_card(title="Automated Raw Transcription"):
ui.textarea(value=_source_transcription_text(source) or "").props("outlined autogrow readonly bg-white").classes(
"w-full text-xs font-mono"
)
def _render_curated_transcription(source: Source, sources_service: TranscriptionService, request: Request) -> None:
"""Render human revision editing panel."""
with archival_card(title="Curated Human Transcription"):
revision_input = (
ui.textarea(value=source.revised_text or "").props("outlined autogrow bg-white").classes("w-full text-xs")
)
async def save_revision() -> None:
candidate = (revision_input.value or "").strip()
if not candidate:
ui.notify("Revision text is required.", type="warning")
return
try:
await sources_service.upsert_revision_for_source(source_id=source.id, text=candidate)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Save failed", operation="sources.save_revision")
return
ui.notify("Revision saved", type="positive")
ui.navigate.to(request.url.path + _back_query(request.query_params))
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save Revision", on_click=save_revision, icon="save").classes("ui-btn-primary text-xs")
# --- Utility Functions ---
def _get_list_header_title(document_name: str | None, job_label: str | None) -> str:
if document_name is not None:
return f"Sources: {document_name}"
if job_label is not None:
return f"Sources for Job {job_label}"
return "Archival Source Media"
def _parse_uuid(value: str | None) -> UUID | None: def _parse_uuid(value: str | None) -> UUID | None:
if not value: if not value:
return None return None
@@ -330,7 +352,7 @@ def _source_detail_path(*, source_id: UUID, document_id: UUID | None, job_id: UU
return f"/sources/{source_id}{_build_filter_query(document_id=document_id, job_id=job_id)}" return f"/sources/{source_id}{_build_filter_query(document_id=document_id, job_id=job_id)}"
def _back_query(query_params) -> str: def _back_query(query_params: QueryParams) -> str:
params = {} params = {}
for key in ("document_id", "job_id"): for key in ("document_id", "job_id"):
if query_params.get(key): if query_params.get(key):
@@ -338,7 +360,7 @@ def _back_query(query_params) -> str:
return f"?{urlencode(params)}" if params else "" return f"?{urlencode(params)}" if params else ""
def _back_path_from_query(query_params) -> str | None: def _back_path_from_query(query_params: QueryParams) -> str | None:
document_id = query_params.get("document_id") document_id = query_params.get("document_id")
if document_id: if document_id:
return f"/documents/{document_id}" return f"/documents/{document_id}"
+15 -7
View File
@@ -229,26 +229,34 @@ input:focus-visible,
font-size: 0.75rem; font-size: 0.75rem;
} }
/* Table Archival Theme Bridge */
.ui-table { .ui-table {
border: 1px solid var(--theme-border); border: 1px solid var(--theme-border);
color: var(--theme-text); color: var(--theme-text);
background: var(--theme-surface-raised); background: var(--theme-surface-raised);
border-radius: 0.125rem; border-radius: 0.125rem;
overflow: hidden;
} }
.ui-table .q-table tbody tr:hover { .ui-table .q-table {
background: var(--theme-surface) !important; color: var(--theme-text);
cursor: pointer; }
.ui-table .q-table th,
.ui-table-header {
color: var(--theme-inverse-text) !important;
background-color: var(--theme-primary) !important;
font-weight: 700;
} }
.ui-table .q-table td { .ui-table .q-table td {
border-bottom: 1px solid var(--theme-border) !important; border-bottom: 1px solid var(--theme-border) !important;
color: var(--theme-text);
} }
.ui-table-header { .ui-table .q-table tbody tr:hover {
color: var(--theme-inverse-text); background-color: var(--theme-surface) !important;
background: var(--theme-primary); cursor: pointer;
font-weight: 700;
} }
.ui-table-body { .ui-table-body {
+141 -128
View File
@@ -1,16 +1,60 @@
"""Tests for the sources page routes.""" """Tests for the sources page routes and Source model properties."""
import asyncio
from pathlib import Path from pathlib import Path
import pytest import pytest
from sqlmodel import select from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from transcription.db import session_scope from transcription.db import session_scope
from transcription.db.models import Document from transcription.db.models import Document, Job, JobSourceStatus, JobStatus, Source
from transcription.db.models import Job
from transcription.db.models import JobStatus
from transcription.db.models import Source # --- Unit Tests for Model @property Definitions ---
class TestSourceModelProperties:
"""Direct unit tests for Source computed properties."""
@pytest.mark.asyncio
async def test_source_properties_with_no_job_sources(self):
source = Source(
page_number=1,
upload_name="page_one.png",
filename="stored_page_one.png",
file_path="/tmp/stored_page_one.png",
)
assert source.latest_job_source is None
assert source.latest_status is None
assert source.latest_error_detail is None
assert source.document_name is None
@pytest.mark.asyncio
async def test_source_properties_with_document_and_job_sources(self, seed_job):
job_id = seed_job(
filename="source_prop_test.png",
status=JobStatus.FAILED,
transcription_text=None,
error_detail="Timeout during OCR parsing",
)
async with session_scope() as session:
job = await session.get(Job, job_id)
assert job is not None
source = (
await session.exec(select(Source).where(Source.document_id == job.document_id))
).first()
assert source is not None
# Validate computed properties
assert source.document_name is not None
assert source.latest_status == JobSourceStatus.FAILED
assert source.latest_error_detail == "Timeout during OCR parsing"
assert source.latest_job_source is not None
# --- Integration Tests for Page Rendering ---
@pytest.mark.integration @pytest.mark.integration
@@ -26,26 +70,24 @@ class TestSourcesPageRendering:
assert "Sources" in response.text assert "Sources" in response.text
assert "No source file records found." in response.text assert "No source file records found." in response.text
def test_sources_page_lists_seeded_sources(self, app_client): @pytest.mark.asyncio
async def test_sources_page_lists_seeded_sources(self, app_client):
_, client = app_client _, client = app_client
async def _seed() -> None: async with session_scope() as session:
async with session_scope() as session: document = Document(name="Source Document", document_type="letter")
document = Document(name="Source Document", document_type="letter") session.add(document)
session.add(document) await session.flush()
await session.flush() session.add(
session.add( Source(
Source( document_id=document.id,
document_id=document.id, page_number=1,
page_number=1, upload_name="page_one.png",
upload_name="page_one.png", filename="stored_page_one.png",
filename="stored_page_one.png", file_path="/tmp/stored_page_one.png",
file_path="/tmp/stored_page_one.png",
)
) )
await session.commit() )
await session.commit()
asyncio.run(_seed())
response = client.get("/ui/sources") response = client.get("/ui/sources")
@@ -53,41 +95,38 @@ class TestSourcesPageRendering:
assert "page_one.png" in response.text assert "page_one.png" in response.text
assert "stored_page_one.png" in response.text assert "stored_page_one.png" in response.text
def test_sources_page_filters_to_document_context(self, app_client): @pytest.mark.asyncio
async def test_sources_page_filters_to_document_context(self, app_client):
_, client = app_client _, client = app_client
async def _seed() -> str: async with session_scope() as session:
async with session_scope() as session: target = Document(name="Target", document_type="letter")
target = Document(name="Target", document_type="letter") other = Document(name="Other", document_type="record")
other = Document(name="Other", document_type="record") session.add_all([target, other])
session.add(target) await session.flush()
session.add(other)
await session.flush()
session.add( session.add(
Source( Source(
document_id=target.id, document_id=target.id,
page_number=1, page_number=1,
upload_name="target_page.png", upload_name="target_page.png",
filename="target_stored.png", filename="target_stored.png",
file_path="/tmp/target_stored.png", file_path="/tmp/target_stored.png",
)
) )
session.add( )
Source( session.add(
document_id=other.id, Source(
page_number=1, document_id=other.id,
upload_name="other_page.png", page_number=1,
filename="other_stored.png", upload_name="other_page.png",
file_path="/tmp/other_stored.png", filename="other_stored.png",
) file_path="/tmp/other_stored.png",
) )
await session.commit() )
return str(target.id) await session.commit()
target_id = str(target.id)
document_id = asyncio.run(_seed()) response = client.get(f"/ui/sources?document_id={target_id}")
response = client.get(f"/ui/sources?document_id={document_id}")
assert response.status_code == 200 assert response.status_code == 200
assert "Sources: Target" in response.text assert "Sources: Target" in response.text
@@ -105,9 +144,10 @@ class TestSourcesPageRendering:
assert "Sources for Job" in response.text assert "Sources for Job" in response.text
assert "Back to Job" in response.text assert "Back to Job" in response.text
assert "job-page.png" in response.text assert "job-page.png" in response.text
assert "Job Source Status" in response.text
def test_sources_page_job_context_shows_job_source_status_and_error_detail(self, app_client, seed_job): def test_sources_page_job_context_shows_job_source_status_and_error_detail(
self, app_client, seed_job
):
_, client = app_client _, client = app_client
job_id = seed_job( job_id = seed_job(
filename="job-failed-page.png", filename="job-failed-page.png",
@@ -123,9 +163,18 @@ class TestSourcesPageRendering:
assert "failed" in response.text.lower() assert "failed" in response.text.lower()
assert "Provider timed out" in response.text assert "Provider timed out" in response.text
def test_source_detail_page_renders_preview_and_revision_box(self, app_client, seed_job): @pytest.mark.asyncio
async def test_source_detail_page_renders_preview_and_revision_box(
self, app_client, seed_job
):
_, client = app_client _, client = app_client
fixture_path = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid" / "small_png.png" fixture_path = (
Path(__file__).resolve().parents[1]
/ "fixtures"
/ "images"
/ "valid"
/ "small_png.png"
)
job_id = seed_job( job_id = seed_job(
filename="detail-source.png", filename="detail-source.png",
transcription_text="original transcription text", transcription_text="original transcription text",
@@ -133,101 +182,65 @@ class TestSourcesPageRendering:
source_file=fixture_path, source_file=fixture_path,
) )
async def _get_source_id() -> str: async with session_scope() as session:
async with session_scope() as session: job = await session.get(Job, job_id)
job = await session.get(Job, job_id) assert job is not None
assert job is not None source = (
source = ( await session.exec(select(Source).where(Source.document_id == job.document_id))
await session.exec(select(Source).where(Source.document_id == job.document_id)) ).first()
).first() assert source is not None
assert source is not None source_id = str(source.id)
return str(source.id)
source_id = asyncio.run(_get_source_id())
response = client.get(f"/ui/sources/{source_id}") response = client.get(f"/ui/sources/{source_id}")
assert response.status_code == 200 assert response.status_code == 200
assert "Source Page 1: detail-source.png" in response.text assert "Source Page 1: detail-source.png" in response.text
assert "Back to Sources" in response.text assert "Back to Sources" in response.text
assert "automated raw transcription" in response.text.lower()
assert "original transcription text" in response.text assert "original transcription text" in response.text
assert "curated human transcription" in response.text.lower()
assert "human revision text" in response.text assert "human revision text" in response.text
assert "Page Number:" in response.text
assert "Stored Filename:" in response.text
assert "Delete Source" in response.text assert "Delete Source" in response.text
def test_source_detail_page_displays_job_source_status_and_error_detail(self, app_client, seed_job): @pytest.mark.asyncio
_, client = app_client async def test_source_delete_page_blocks_when_source_is_job_linked(
job_id = seed_job( self, app_client, seed_job
filename="failed-source.png", ):
status=JobStatus.FAILED,
transcription_text=None,
error_detail="Provider timed out",
)
async def _get_source_id() -> str:
async with session_scope() as session:
job = await session.get(Job, job_id)
assert job is not None
source = (
await session.exec(select(Source).where(Source.document_id == job.document_id))
).first()
assert source is not None
return str(source.id)
source_id = asyncio.run(_get_source_id())
response = client.get(f"/ui/sources/{source_id}")
assert response.status_code == 200
assert "JOB SOURCE OUTCOMES" in response.text
assert "Status:" in response.text
assert "failed" in response.text.lower()
assert "Error Detail:" in response.text
assert "Provider timed out" in response.text
def test_source_delete_page_blocks_when_source_is_job_linked(self, app_client, seed_job):
_, client = app_client _, client = app_client
job_id = seed_job(filename="linked-source.png", transcription_text="linked text") job_id = seed_job(filename="linked-source.png", transcription_text="linked text")
async def _get_source_id() -> str: async with session_scope() as session:
async with session_scope() as session: job = await session.get(Job, job_id)
job = await session.get(Job, job_id) assert job is not None
assert job is not None source = (
source = ( await session.exec(select(Source).where(Source.document_id == job.document_id))
await session.exec(select(Source).where(Source.document_id == job.document_id)) ).first()
).first() assert source is not None
assert source is not None source_id = str(source.id)
return str(source.id)
source_id = asyncio.run(_get_source_id())
response = client.get(f"/ui/sources/{source_id}/delete") response = client.get(f"/ui/sources/{source_id}/delete")
assert response.status_code == 200 assert response.status_code == 200
assert "Delete Source Record" in response.text assert "Delete Source Record" in response.text
assert "Delete is only available for unlinked sources." in response.text assert "Delete is only available for unlinked sources." in response.text
def test_source_delete_page_allows_unlinked_source(self, app_client): @pytest.mark.asyncio
async def test_source_delete_page_allows_unlinked_source(self, app_client):
_, client = app_client _, client = app_client
async def _seed_unlinked_source() -> str: async with session_scope() as session:
async with session_scope() as session: document = Document(name="Unlinked Source Doc", document_type="memo")
document = Document(name="Unlinked Source Doc", document_type="memo") session.add(document)
session.add(document) await session.flush()
await session.flush() source = Source(
source = Source( document_id=document.id,
document_id=document.id, page_number=1,
page_number=1, upload_name="orphan-source.png",
upload_name="orphan-source.png", filename="orphan-source.png",
filename="orphan-source.png", file_path="/tmp/orphan-source.png",
file_path="/tmp/orphan-source.png", )
) session.add(source)
session.add(source) await session.commit()
await session.commit() source_id = str(source.id)
return str(source.id)
source_id = asyncio.run(_seed_unlinked_source())
response = client.get(f"/ui/sources/{source_id}/delete") response = client.get(f"/ui/sources/{source_id}/delete")
assert response.status_code == 200 assert response.status_code == 200