generated from john/python-template
Revamped Sources related pages with the help of Gemini, which had a lot to say.
This commit is contained in:
@@ -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."""
|
||||||
|
|||||||
@@ -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>
|
||||||
|
""",
|
||||||
)
|
)
|
||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user