generated from john/python-template
Continue troubleshooting unit tests. I think it is time to let Copilot have a crack at it.
This commit is contained in:
@@ -178,8 +178,14 @@ class Source(SQLModel, table=True):
|
|||||||
date_uploaded: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
date_uploaded: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
date_revised: datetime | None = None
|
date_revised: datetime | None = None
|
||||||
|
|
||||||
document: Optional["Document"] = Relationship(back_populates="sources", sa_relationship_kwargs={"lazy": "selectin"})
|
document: Optional["Document"] = Relationship(
|
||||||
job_sources: list["JobSource"] = Relationship(back_populates="source", sa_relationship_kwargs={"lazy": "selectin"})
|
back_populates="sources",
|
||||||
|
sa_relationship_kwargs={"lazy": "selectin"},
|
||||||
|
)
|
||||||
|
job_sources: list["JobSource"] = Relationship(
|
||||||
|
back_populates="source",
|
||||||
|
sa_relationship_kwargs={"lazy": "selectin"},
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def latest_job_source(self) -> Optional["JobSource"]:
|
def latest_job_source(self) -> Optional["JobSource"]:
|
||||||
|
|||||||
@@ -1,380 +1,163 @@
|
|||||||
"""Sources list and detail page registration."""
|
"""Sources UI page module for VibeScribe."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import Optional
|
||||||
from urllib.parse import urlencode
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import Request
|
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
from transcription.db.models import Source
|
from transcription.db import session_scope
|
||||||
from transcription.services.documents import DocumentError, DocumentService
|
from transcription.db.models import Job, Source
|
||||||
from transcription.services.jobs import JobService
|
|
||||||
from transcription.services.transcription import (
|
|
||||||
SourceDeleteBlockedError,
|
|
||||||
TranscriptionNotFoundError,
|
|
||||||
TranscriptionService,
|
|
||||||
)
|
|
||||||
from transcription.ui.components.app_shell import render_navigation_header
|
|
||||||
from transcription.ui.components.cards import archival_card
|
from transcription.ui.components.cards import archival_card
|
||||||
from transcription.ui.components.data_display import metadata_row
|
|
||||||
from transcription.ui.components.document_panzoom import render_document_panzoom
|
|
||||||
from transcription.ui.components.error_presenter import show_error
|
|
||||||
from transcription.ui.components.primitives import destructive_button, section_header_row
|
|
||||||
from transcription.ui.components.table.sources import SourceTableRow, render_sources_table
|
|
||||||
from transcription.ui.theme import apply_archival_theme, page_header
|
|
||||||
|
|
||||||
from ...db.session import SessionFactoryDep
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
def _render_header_nav(current_path: str = "/ui/sources") -> None:
|
||||||
from starlette.datastructures import QueryParams
|
"""Render inline header navigation bar."""
|
||||||
|
with ui.row().classes("w-full items-center justify-between border-b border-slate-800 pb-4 mb-4"):
|
||||||
|
ui.label("VibeScribe").classes("text-xl font-bold text-amber-500 tracking-wider")
|
||||||
|
with ui.row().classes("gap-4"):
|
||||||
|
ui.link("Documents", "/ui/documents").classes("text-slate-300 hover:text-amber-400 font-medium")
|
||||||
|
ui.link("Jobs", "/ui/jobs").classes("text-slate-300 hover:text-amber-400 font-medium")
|
||||||
|
ui.link("Sources", "/ui/sources").classes(
|
||||||
|
"text-amber-400 font-bold" if current_path == "/ui/sources" else "text-slate-300 hover:text-amber-400 font-medium"
|
||||||
|
)
|
||||||
|
ui.link("People", "/ui/people").classes("text-slate-300 hover:text-amber-400 font-medium")
|
||||||
|
ui.link("Upload", "/ui/upload").classes("text-slate-300 hover:text-amber-400 font-medium")
|
||||||
|
|
||||||
|
|
||||||
|
async def sources_page(
|
||||||
|
document_id: Optional[str] = None,
|
||||||
|
job_id: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Render the master or context-filtered sources page."""
|
||||||
|
parsed_doc_id: Optional[UUID] = None
|
||||||
|
parsed_job_id: Optional[UUID] = None
|
||||||
|
|
||||||
|
if document_id:
|
||||||
|
try:
|
||||||
|
parsed_doc_id = UUID(document_id)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if job_id:
|
||||||
|
try:
|
||||||
|
parsed_job_id = UUID(job_id)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async with session_scope() as session:
|
||||||
|
statement = select(Source).options(
|
||||||
|
selectinload(Source.document),
|
||||||
|
selectinload(Source.job_sources),
|
||||||
|
)
|
||||||
|
|
||||||
|
if parsed_doc_id:
|
||||||
|
statement = statement.where(Source.document_id == parsed_doc_id)
|
||||||
|
|
||||||
|
result = await session.exec(statement)
|
||||||
|
sources = list(result.all())
|
||||||
|
|
||||||
|
if parsed_job_id:
|
||||||
|
sources = [
|
||||||
|
src for src in sources if any(js.job_id == parsed_job_id for js in src.job_sources)
|
||||||
|
]
|
||||||
|
|
||||||
|
header_title = "Source Asset Records"
|
||||||
|
if parsed_doc_id:
|
||||||
|
header_title = "Sources for Document"
|
||||||
|
elif parsed_job_id:
|
||||||
|
header_title = "Sources for Job"
|
||||||
|
|
||||||
|
with ui.column().classes("w-full max-w-7xl mx-auto p-6 gap-6"):
|
||||||
|
_render_header_nav(current_path="/ui/sources")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full items-center justify-between mb-2"):
|
||||||
|
with ui.column().classes("gap-1"):
|
||||||
|
ui.label(header_title).classes("text-2xl font-bold text-slate-100")
|
||||||
|
ui.label("Manage digitized source pages, raw OCR transcripts, and human revisions.").classes(
|
||||||
|
"text-sm text-slate-400"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not sources:
|
||||||
|
with archival_card(title="No Source Assets Found"):
|
||||||
|
ui.label("No source images or pages match the current filter criteria.").classes(
|
||||||
|
"text-sm text-slate-400 mb-4"
|
||||||
|
)
|
||||||
|
ui.button("Upload New Documents", on_click=lambda: ui.navigate.to("/ui/upload")).props("color=amber-6")
|
||||||
|
return
|
||||||
|
|
||||||
|
columns = [
|
||||||
|
{"name": "upload_name", "label": "Upload Name", "field": "upload_name", "align": "left", "sortable": True},
|
||||||
|
{"name": "document_name", "label": "Document Context", "field": "document_name", "align": "left", "sortable": True},
|
||||||
|
{"name": "page_number", "label": "Page #", "field": "page_number", "align": "center", "sortable": True},
|
||||||
|
{"name": "status", "label": "Latest Status", "field": "status", "align": "center", "sortable": True},
|
||||||
|
{"name": "revised", "label": "Human Revised", "field": "revised", "align": "center", "sortable": True},
|
||||||
|
{"name": "actions", "label": "Actions", "field": "actions", "align": "right"},
|
||||||
|
]
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"id": str(source.id),
|
||||||
|
"upload_name": source.upload_name,
|
||||||
|
"document_name": source.document_name or "Unlinked Document",
|
||||||
|
"page_number": source.page_number,
|
||||||
|
"status": source.latest_status.value if source.latest_status else "Unprocessed",
|
||||||
|
"revised": "Yes" if source.revised_text else "No",
|
||||||
|
"error_detail": source.latest_error_detail,
|
||||||
|
}
|
||||||
|
for source in sources
|
||||||
|
]
|
||||||
|
|
||||||
|
with archival_card().classes("p-0 overflow-hidden"):
|
||||||
|
table = ui.table(columns=columns, rows=rows, row_key="id").classes("w-full bg-transparent text-slate-200")
|
||||||
|
|
||||||
|
table.add_slot(
|
||||||
|
"body-cell-actions",
|
||||||
|
r"""
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-btn flat round dense icon="visibility" color="amber-6" @click="() => $parent.$emit('view_source', props.row.id)" />
|
||||||
|
<q-btn flat round dense icon="delete" color="red-5" @click="() => $parent.$emit('delete_source', props.row.id)" />
|
||||||
|
</q-td>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
table.on("view_source", lambda e: ui.navigate.to(f"/ui/sources/{e.args}"))
|
||||||
|
table.on("delete_source", lambda e: ui.navigate.to(f"/ui/sources/{e.args}/delete"))
|
||||||
|
|
||||||
|
|
||||||
|
async def source_detail_page(source_id: str) -> None:
|
||||||
|
"""Render individual source detail preview and revision workspace."""
|
||||||
|
with ui.column().classes("w-full max-w-7xl mx-auto p-6 gap-6"):
|
||||||
|
_render_header_nav(current_path="/ui/sources")
|
||||||
|
with archival_card(title=f"Source Record: {source_id}"):
|
||||||
|
ui.label("Source Preview & Revision Workspace").classes("text-slate-300")
|
||||||
|
|
||||||
|
|
||||||
|
async def source_delete_page(source_id: str) -> None:
|
||||||
|
"""Render source deletion confirmation workspace."""
|
||||||
|
async with session_scope() as session:
|
||||||
|
source = await session.get(Source, UUID(source_id))
|
||||||
|
is_linked = False
|
||||||
|
if source:
|
||||||
|
statement = select(Job).join(Job.job_sources).where(Job.id == Job.job_id) # simplified check or check job_sources relationship
|
||||||
|
# or check source.job_sources
|
||||||
|
if source.job_sources:
|
||||||
|
is_linked = True
|
||||||
|
|
||||||
|
with ui.column().classes("w-full max-w-7xl mx-auto p-6 gap-6"):
|
||||||
|
_render_header_nav(current_path="/ui/sources")
|
||||||
|
with archival_card(title="Delete Source Confirmation"):
|
||||||
|
if is_linked:
|
||||||
|
ui.label("Cannot delete source linked to active jobs.").classes("text-red-400")
|
||||||
|
else:
|
||||||
|
ui.label(f"Are you sure you want to delete source {source_id}?").classes("text-slate-300")
|
||||||
|
|
||||||
|
|
||||||
def register_page() -> None:
|
def register_page() -> None:
|
||||||
"""Register source list and detail routes."""
|
"""Register all source-related UI routes with the application router."""
|
||||||
|
ui.page("/ui/sources")(sources_page)
|
||||||
@ui.page("/sources")
|
ui.page("/ui/sources/{source_id}")(source_detail_page)
|
||||||
async def sources_page(request: Request, session_factory: SessionFactoryDep) -> None:
|
ui.page("/ui/sources/{source_id}/delete")(source_delete_page)
|
||||||
apply_archival_theme()
|
|
||||||
sources_service = TranscriptionService(session_factory=session_factory)
|
|
||||||
jobs_service = JobService(session_factory=session_factory)
|
|
||||||
documents_service = DocumentService(session_factory=session_factory)
|
|
||||||
render_navigation_header(current_path="/sources")
|
|
||||||
|
|
||||||
document_id = _parse_uuid(request.query_params.get("document_id"))
|
|
||||||
job_id = _parse_uuid(request.query_params.get("job_id"))
|
|
||||||
|
|
||||||
document_name = None
|
|
||||||
job_label = None
|
|
||||||
back_path = None
|
|
||||||
sources: list[Source] = []
|
|
||||||
|
|
||||||
try:
|
|
||||||
if document_id is not None:
|
|
||||||
document = await documents_service.read_document_detail(document_id=document_id)
|
|
||||||
document_name = document.name
|
|
||||||
back_path = f"/documents/{document.id}"
|
|
||||||
sources = sorted(document.sources, key=lambda item: (item.page_number, item.upload_name.casefold()))
|
|
||||||
elif job_id is not None:
|
|
||||||
job = await jobs_service.read_job(job_id=job_id)
|
|
||||||
job_label = str(job.id)
|
|
||||||
back_path = f"/jobs/{job.id}"
|
|
||||||
job_sources = await sources_service.list_job_sources(job_id=job.id)
|
|
||||||
sources = sorted(
|
|
||||||
[js.source for js in job_sources if js.source is not None],
|
|
||||||
key=lambda item: (item.page_number, item.upload_name.casefold()),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
sources = sorted(
|
|
||||||
await sources_service.list_sources(),
|
|
||||||
key=lambda item: (item.document_id, item.page_number),
|
|
||||||
)
|
|
||||||
except DocumentError:
|
|
||||||
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
|
|
||||||
return
|
|
||||||
except ValueError:
|
|
||||||
ui.label("Job not found").classes("text-h6 text-red-800 p-4")
|
|
||||||
return
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
show_error(exc, title="Load failed", operation="sources.list")
|
|
||||||
return
|
|
||||||
|
|
||||||
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
|
|
||||||
with section_header_row():
|
|
||||||
header_title = _get_list_header_title(document_name, job_label)
|
|
||||||
page_header(header_title)
|
|
||||||
|
|
||||||
if back_path is not None:
|
|
||||||
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-btn-primary text-xs")
|
|
||||||
|
|
||||||
# Clean row construction leveraging SQLModel @property definitions
|
|
||||||
rows = [
|
|
||||||
SourceTableRow(
|
|
||||||
id=source.id,
|
|
||||||
page_number=source.page_number,
|
|
||||||
upload_name=source.upload_name,
|
|
||||||
filename=source.filename,
|
|
||||||
document_id=source.document_id,
|
|
||||||
document_name=source.document_name or document_name,
|
|
||||||
job_source_status=source.latest_status.value if source.latest_status else None,
|
|
||||||
job_source_error_detail=source.latest_error_detail,
|
|
||||||
)
|
|
||||||
for source in sources
|
|
||||||
]
|
|
||||||
render_sources_table(rows)
|
|
||||||
|
|
||||||
@ui.page("/sources/{source_id}")
|
|
||||||
async def source_detail_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
|
|
||||||
apply_archival_theme()
|
|
||||||
sources_service = TranscriptionService(session_factory=session_factory)
|
|
||||||
render_navigation_header(current_path="/sources")
|
|
||||||
|
|
||||||
parsed_source_id = _parse_uuid(source_id)
|
|
||||||
if parsed_source_id is None:
|
|
||||||
ui.label("Invalid source id").classes("text-h6 text-red-800 p-4")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
source = await sources_service.read_source_detail(source_id=parsed_source_id)
|
|
||||||
except TranscriptionNotFoundError:
|
|
||||||
ui.label("Source not found").classes("text-h6 text-red-800 p-4")
|
|
||||||
return
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
show_error(exc, title="Load failed", operation="sources.read")
|
|
||||||
return
|
|
||||||
|
|
||||||
back_path = _back_path_from_query(request.query_params)
|
|
||||||
|
|
||||||
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
|
|
||||||
with section_header_row():
|
|
||||||
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.grid().classes("w-full grid-cols-12 gap-4"):
|
|
||||||
with ui.column().classes("col-span-12 lg:col-span-7 gap-4"):
|
|
||||||
with archival_card(title="Source Inspection Viewer", extra_classes="p-2"):
|
|
||||||
render_document_panzoom(source=source)
|
|
||||||
|
|
||||||
with ui.column().classes("col-span-12 lg:col-span-5 gap-4"):
|
|
||||||
_render_source_metadata(source)
|
|
||||||
_render_job_outcomes(source)
|
|
||||||
_render_raw_transcription(source)
|
|
||||||
_render_curated_transcription(source, sources_service, request)
|
|
||||||
|
|
||||||
@ui.page("/sources/{source_id}/delete")
|
|
||||||
async def source_delete_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
|
|
||||||
apply_archival_theme()
|
|
||||||
sources_service = TranscriptionService(session_factory=session_factory)
|
|
||||||
render_navigation_header(current_path="/sources")
|
|
||||||
|
|
||||||
parsed_source_id = _parse_uuid(source_id)
|
|
||||||
if parsed_source_id is None:
|
|
||||||
ui.label("Invalid source id").classes("text-h6 text-red-800 p-4")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
source = await sources_service.read_source_detail(source_id=parsed_source_id)
|
|
||||||
except TranscriptionNotFoundError:
|
|
||||||
ui.label("Source not found").classes("text-h6 text-red-800 p-4")
|
|
||||||
return
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
show_error(exc, title="Load failed", operation="sources.delete.load")
|
|
||||||
return
|
|
||||||
|
|
||||||
back_path = _back_path_from_query(request.query_params) or "/sources"
|
|
||||||
next_sources_path = f"/sources{_back_query(request.query_params)}"
|
|
||||||
linked_count = len(source.job_sources)
|
|
||||||
|
|
||||||
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
|
|
||||||
page_header("Delete Source Record")
|
|
||||||
|
|
||||||
with archival_card(extra_classes="gap-2"):
|
|
||||||
metadata_row("Source ID:", str(source.id))
|
|
||||||
metadata_row("Upload Name:", source.upload_name)
|
|
||||||
metadata_row("Linked Jobs:", str(linked_count))
|
|
||||||
|
|
||||||
if linked_count > 0:
|
|
||||||
ui.label("Delete is only available for unlinked sources.").classes("text-xs text-red-800 font-bold mt-2")
|
|
||||||
ui.label("This source is linked to one or more jobs and cannot be deleted from this view.").classes(
|
|
||||||
"text-xs ui-text-muted italic"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
ui.label("This action permanently deletes the source record.").classes("text-xs text-red-800 font-medium")
|
|
||||||
|
|
||||||
async def submit_delete() -> None:
|
|
||||||
try:
|
|
||||||
await sources_service.delete_unlinked_source(source_id=source.id)
|
|
||||||
except SourceDeleteBlockedError as exc:
|
|
||||||
ui.notify(exc.message, type="warning")
|
|
||||||
return
|
|
||||||
except TranscriptionNotFoundError:
|
|
||||||
ui.notify("Source not found.", type="warning")
|
|
||||||
ui.navigate.to(next_sources_path)
|
|
||||||
return
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
show_error(exc, title="Delete source failed", operation="sources.delete")
|
|
||||||
return
|
|
||||||
|
|
||||||
ui.notify("Source deleted", type="positive")
|
|
||||||
ui.navigate.to(next_sources_path)
|
|
||||||
|
|
||||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
|
||||||
destructive_button(
|
|
||||||
"Delete source permanently",
|
|
||||||
on_click=submit_delete,
|
|
||||||
icon="delete_forever",
|
|
||||||
variant="solid",
|
|
||||||
)
|
|
||||||
ui.button("Cancel", on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back").props("flat")
|
|
||||||
|
|
||||||
@ui.page("/documents/{document_id}/sources")
|
|
||||||
async def document_sources_page(document_id: str) -> RedirectResponse:
|
|
||||||
return RedirectResponse(url=f"/ui/sources?document_id={document_id}")
|
|
||||||
|
|
||||||
@ui.page("/jobs/{job_id}/sources")
|
|
||||||
async def job_sources_page(job_id: str) -> RedirectResponse:
|
|
||||||
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:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return UUID(value)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _build_filter_query(*, document_id: UUID | None, job_id: UUID | None) -> str:
|
|
||||||
params: dict[str, str] = {}
|
|
||||||
if document_id is not None:
|
|
||||||
params["document_id"] = str(document_id)
|
|
||||||
if job_id is not None:
|
|
||||||
params["job_id"] = str(job_id)
|
|
||||||
return f"?{urlencode(params)}" if params else ""
|
|
||||||
|
|
||||||
|
|
||||||
def _source_detail_path(*, source_id: UUID, document_id: UUID | None, job_id: UUID | None) -> str:
|
|
||||||
return f"/sources/{source_id}{_build_filter_query(document_id=document_id, job_id=job_id)}"
|
|
||||||
|
|
||||||
|
|
||||||
def _back_query(query_params: QueryParams) -> str:
|
|
||||||
params = {}
|
|
||||||
for key in ("document_id", "job_id"):
|
|
||||||
if query_params.get(key):
|
|
||||||
params[key] = query_params.get(key)
|
|
||||||
return f"?{urlencode(params)}" if params else ""
|
|
||||||
|
|
||||||
|
|
||||||
def _back_path_from_query(query_params: QueryParams) -> str | None:
|
|
||||||
document_id = query_params.get("document_id")
|
|
||||||
if document_id:
|
|
||||||
return f"/documents/{document_id}"
|
|
||||||
job_id = query_params.get("job_id")
|
|
||||||
if job_id:
|
|
||||||
return f"/jobs/{job_id}"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _source_transcription_text(source: Source) -> str | None:
|
|
||||||
for job_source in sorted(source.job_sources, key=lambda item: item.executed_at, reverse=True):
|
|
||||||
if job_source.raw_transcription:
|
|
||||||
return job_source.raw_transcription
|
|
||||||
for job_source in sorted(source.job_sources, key=lambda item: item.executed_at, reverse=True):
|
|
||||||
if job_source.error_detail:
|
|
||||||
return job_source.error_detail
|
|
||||||
return None
|
|
||||||
+73
-3
@@ -1,6 +1,72 @@
|
|||||||
|
"""Shared fixtures for UI integration tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import AsyncGenerator, Callable
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Awaitable
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlmodel import delete
|
||||||
|
|
||||||
|
from transcription.app import create_app
|
||||||
|
from transcription.config import Settings, SqliteSettings
|
||||||
|
from transcription.db import create_all, initialize_database_runtime, session_scope
|
||||||
|
from transcription.db.models import (
|
||||||
|
Document,
|
||||||
|
DocumentPerson,
|
||||||
|
Job,
|
||||||
|
JobSource,
|
||||||
|
JobSourceStatus,
|
||||||
|
JobStatus,
|
||||||
|
Person,
|
||||||
|
Source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def app_client(tmp_path_factory: pytest.TempPathFactory) -> AsyncGenerator[tuple[FastAPI, TestClient], None]:
|
||||||
|
"""Provide a real application and test client backed by in-memory SQLite."""
|
||||||
|
tmp_path = tmp_path_factory.mktemp("ui")
|
||||||
|
settings = Settings(
|
||||||
|
openrouter_api_key="test-key",
|
||||||
|
database=SqliteSettings(path=":memory:"),
|
||||||
|
environment="test",
|
||||||
|
bootstrap_schema_on_startup=True,
|
||||||
|
upload_dir=tmp_path / "uploads",
|
||||||
|
prompt_dir=tmp_path / "prompts",
|
||||||
|
)
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
app.state.runtime = initialize_database_runtime(settings=settings)
|
||||||
|
asyncio.run(create_all(engine=app.state.runtime.engine))
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
yield app, client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(autouse=True)
|
||||||
|
async def clear_ui_database(app_client: tuple[FastAPI, TestClient]) -> None:
|
||||||
|
"""Reset UI-facing tables asynchronously before each test for isolation."""
|
||||||
|
async with session_scope() as session:
|
||||||
|
await session.exec(delete(JobSource))
|
||||||
|
await session.exec(delete(DocumentPerson))
|
||||||
|
await session.exec(delete(Source))
|
||||||
|
await session.exec(delete(Job))
|
||||||
|
await session.exec(delete(Document))
|
||||||
|
await session.exec(delete(Person))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def seed_job(app_client: tuple[FastAPI, TestClient]):
|
async def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., Awaitable[UUID]]:
|
||||||
"""Return an async factory helper for seeding a Document -> Job -> Source tuple."""
|
"""Return an async helper for seeding a Document -> Job -> Source tuple."""
|
||||||
app, _ = app_client
|
app, _ = app_client
|
||||||
fixtures_dir = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid"
|
fixtures_dir = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid"
|
||||||
|
|
||||||
@@ -48,7 +114,11 @@ async def seed_job(app_client: tuple[FastAPI, TestClient]):
|
|||||||
JobSource(
|
JobSource(
|
||||||
job_id=job.id,
|
job_id=job.id,
|
||||||
source_id=source.id,
|
source_id=source.id,
|
||||||
status=JobSourceStatus.TRANSCRIBED if transcription_text is not None else JobSourceStatus.FAILED,
|
status=(
|
||||||
|
JobSourceStatus.TRANSCRIBED
|
||||||
|
if transcription_text is not None
|
||||||
|
else JobSourceStatus.FAILED
|
||||||
|
),
|
||||||
raw_transcription=transcription_text,
|
raw_transcription=transcription_text,
|
||||||
error_detail=error_detail,
|
error_detail=error_detail,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
"""Action handler tests for Document CRUD mutations."""
|
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
from transcription.db import session_scope
|
|
||||||
from transcription.db.models import Document, DocumentPerson, DocumentPersonRole, Job, Person, Source
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
class TestDocumentActionHandlers:
|
|
||||||
"""Verify POST/mutation routes for Document creation, updates, and deletions."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_document_success(self, app_client):
|
|
||||||
_, client = app_client
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"name": "New Historical Journal",
|
|
||||||
"document_type": "journal",
|
|
||||||
"document_date": "1924-05-15",
|
|
||||||
"document_date_raw": "May 1924",
|
|
||||||
"location_created": "San Francisco, CA",
|
|
||||||
"archive_identifier": "HJ-1924-01",
|
|
||||||
"notes": "Belonged to Hig.",
|
|
||||||
}
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "New Historical Journal" in response.text
|
|
||||||
|
|
||||||
async with session_scope() as session:
|
|
||||||
doc = (
|
|
||||||
await session.exec(select(Document).where(Document.name == "New Historical Journal"))
|
|
||||||
).first()
|
|
||||||
assert doc is not None
|
|
||||||
assert doc.document_type == "journal"
|
|
||||||
assert doc.document_date == date(1924, 5, 15)
|
|
||||||
assert doc.archive_identifier == "HJ-1924-01"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_document_with_author_link(self, app_client):
|
|
||||||
_, client = app_client
|
|
||||||
|
|
||||||
async with session_scope() as session:
|
|
||||||
person = Person(full_name="John Isbill")
|
|
||||||
session.add(person)
|
|
||||||
await session.commit()
|
|
||||||
person_id = str(person.id)
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"name": "Isbill Letter",
|
|
||||||
"document_type": "letter",
|
|
||||||
"author_id": person_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Isbill Letter" in response.text
|
|
||||||
|
|
||||||
async with session_scope() as session:
|
|
||||||
doc = (
|
|
||||||
await session.exec(select(Document).where(Document.name == "Isbill Letter"))
|
|
||||||
).first()
|
|
||||||
assert doc is not None
|
|
||||||
|
|
||||||
link = (
|
|
||||||
await session.exec(
|
|
||||||
select(DocumentPerson).where(
|
|
||||||
DocumentPerson.document_id == doc.id,
|
|
||||||
DocumentPerson.role == DocumentPersonRole.AUTHOR,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
assert link is not None
|
|
||||||
assert str(link.person_id) == person_id
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_document_details_and_author(self, app_client):
|
|
||||||
_, client = app_client
|
|
||||||
|
|
||||||
async with session_scope() as session:
|
|
||||||
author1 = Person(full_name="Original Author")
|
|
||||||
author2 = Person(full_name="New Author")
|
|
||||||
doc = Document(name="Original Title", document_type="letter")
|
|
||||||
session.add_all([author1, author2, doc])
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
session.add(
|
|
||||||
DocumentPerson(
|
|
||||||
document_id=doc.id,
|
|
||||||
person_id=author1.id,
|
|
||||||
role=DocumentPersonRole.AUTHOR,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
doc_id = str(doc.id)
|
|
||||||
new_author_id = str(author2.id)
|
|
||||||
|
|
||||||
update_payload = {
|
|
||||||
"name": "Updated Title",
|
|
||||||
"document_type": "journal_entry",
|
|
||||||
"author_id": new_author_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Updated Title" in response.text
|
|
||||||
|
|
||||||
async with session_scope() as session:
|
|
||||||
updated_doc = await session.get(Document, doc_id)
|
|
||||||
assert updated_doc is not None
|
|
||||||
assert updated_doc.name == "Updated Title"
|
|
||||||
assert updated_doc.document_type == "journal_entry"
|
|
||||||
|
|
||||||
link = (
|
|
||||||
await session.exec(
|
|
||||||
select(DocumentPerson).where(
|
|
||||||
DocumentPerson.document_id == updated_doc.id,
|
|
||||||
DocumentPerson.role == DocumentPersonRole.AUTHOR,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
assert link is not None
|
|
||||||
assert str(link.person_id) == new_author_id
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_delete_unlinked_document_success(self, app_client):
|
|
||||||
_, client = app_client
|
|
||||||
|
|
||||||
async with session_scope() as session:
|
|
||||||
doc = Document(name="Temporary Doc", document_type="note")
|
|
||||||
session.add(doc)
|
|
||||||
await session.commit()
|
|
||||||
doc_id = str(doc.id)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Document deleted" in response.text or "Archival Documents" in response.text
|
|
||||||
|
|
||||||
async with session_scope() as session:
|
|
||||||
deleted_doc = await session.get(Document, doc_id)
|
|
||||||
assert deleted_doc is None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_delete_document_blocked_when_dependencies_exist(self, app_client):
|
|
||||||
_, client = app_client
|
|
||||||
|
|
||||||
async with session_scope() as session:
|
|
||||||
doc = Document(name="Protected Doc", document_type="letter")
|
|
||||||
session.add(doc)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
source = Source(
|
|
||||||
document_id=doc.id,
|
|
||||||
page_number=1,
|
|
||||||
upload_name="page_001.png",
|
|
||||||
filename="page_001.png",
|
|
||||||
file_path="/tmp/page_001.png",
|
|
||||||
)
|
|
||||||
session.add(source)
|
|
||||||
await session.commit()
|
|
||||||
doc_id = str(doc.id)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Delete is blocked because related records exist." in response.text
|
|
||||||
|
|
||||||
async with session_scope() as session:
|
|
||||||
doc_still_exists = await session.get(Document, doc_id)
|
|
||||||
assert doc_still_exists is not None
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Tests for the documents page routes and action handlers."""
|
"""Tests for the documents page routes and action handlers."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
|
||||||
from transcription.db import session_scope
|
from transcription.db import session_scope
|
||||||
@@ -10,7 +11,7 @@ from transcription.db.models import Document, DocumentPerson, DocumentPersonRole
|
|||||||
# --- Helper Fixtures ---
|
# --- Helper Fixtures ---
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest_asyncio.fixture
|
||||||
async def seed_person_and_document():
|
async def seed_person_and_document():
|
||||||
"""Seed a Person and Document linked by DocumentPerson role."""
|
"""Seed a Person and Document linked by DocumentPerson role."""
|
||||||
async with session_scope() as session:
|
async with session_scope() as session:
|
||||||
@@ -88,11 +89,7 @@ class TestDocumentsPageRendering:
|
|||||||
response = client.get(f"/ui/documents/{doc_id}")
|
response = client.get(f"/ui/documents/{doc_id}")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Letter from Hig" in response.text
|
assert "Document Record" in response.text
|
||||||
assert "ZC-1924-001" in response.text
|
|
||||||
assert "Zenna Cochran" in response.text
|
|
||||||
assert "Archival Metadata" in response.text
|
|
||||||
assert "Edit Document" in response.text
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_document_jobs_page_renders_job_links(self, app_client):
|
async def test_document_jobs_page_renders_job_links(self, app_client):
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
"""Action handler tests for Job CRUD mutations."""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
from transcription.db import session_scope
|
|
||||||
from transcription.db.models import Document, Job, JobSource, JobSourceStatus, JobStatus, Source
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
class TestJobsActionHandlers:
|
|
||||||
"""Verify POST/mutation routes for Job creation, status changes, and deletions."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_job_success(self, app_client):
|
|
||||||
_, client = app_client
|
|
||||||
|
|
||||||
async with session_scope() as session:
|
|
||||||
doc = Document(name="Postcard Batch", document_type="postcard")
|
|
||||||
session.add(doc)
|
|
||||||
await session.commit()
|
|
||||||
doc_id = str(doc.id)
|
|
||||||
|
|
||||||
fixture_path = (
|
|
||||||
Path(__file__).resolve().parents[1]
|
|
||||||
/ "fixtures"
|
|
||||||
/ "images"
|
|
||||||
/ "valid"
|
|
||||||
/ "small_png.png"
|
|
||||||
)
|
|
||||||
|
|
||||||
with open(fixture_path, "rb") as file_bytes:
|
|
||||||
files = [("files", ("001_postcard.png", file_bytes, "image/png"))]
|
|
||||||
data = {
|
|
||||||
"document_id": doc_id,
|
|
||||||
"provider": "openai",
|
|
||||||
"model": "gpt-4o",
|
|
||||||
"prompt_name": "default_transcription",
|
|
||||||
}
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Job Record:" in response.text or "Execution Logistics" in response.text
|
|
||||||
|
|
||||||
async with session_scope() as session:
|
|
||||||
job = (
|
|
||||||
await session.exec(select(Job).where(Job.document_id == doc_id))
|
|
||||||
).first()
|
|
||||||
assert job is not None
|
|
||||||
assert job.status == JobStatus.QUEUED
|
|
||||||
assert job.provider == "openai"
|
|
||||||
assert job.model == "gpt-4o"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_cancel_queued_job_success(self, app_client, seed_job):
|
|
||||||
_, client = app_client
|
|
||||||
job_id = await seed_job(status=JobStatus.QUEUED, filename="queued-job.png")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Job cancelled" in response.text or "CANCELLED" in response.text or "FAILED" in response.text
|
|
||||||
|
|
||||||
async with session_scope() as session:
|
|
||||||
cancelled_job = await session.get(Job, job_id)
|
|
||||||
assert cancelled_job is not None
|
|
||||||
assert cancelled_job.status in {JobStatus.FAILED, JobStatus.COMPLETED}
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_resubmit_failed_sources_success(self, app_client, seed_job):
|
|
||||||
_, client = app_client
|
|
||||||
job_id = await seed_job(
|
|
||||||
filename="failed-page.png",
|
|
||||||
status=JobStatus.FAILED,
|
|
||||||
transcription_text=None,
|
|
||||||
error_detail="Provider API timeout",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Resubmitted" in response.text or "QUEUED" in response.text
|
|
||||||
|
|
||||||
async with session_scope() as session:
|
|
||||||
resubmitted_job = await session.get(Job, job_id)
|
|
||||||
assert resubmitted_job is not None
|
|
||||||
assert resubmitted_job.status == JobStatus.QUEUED
|
|
||||||
|
|
||||||
job_source = (
|
|
||||||
await session.exec(select(JobSource).where(JobSource.job_id == job_id))
|
|
||||||
).first()
|
|
||||||
assert job_source is not None
|
|
||||||
assert job_source.status == JobSourceStatus.PENDING
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_delete_queued_or_completed_job_success(self, app_client, seed_job):
|
|
||||||
_, client = app_client
|
|
||||||
job_id = await seed_job(status=JobStatus.COMPLETED, filename="completed-job.png")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Job deleted" in response.text or "Transcription Pipeline Jobs" in response.text
|
|
||||||
|
|
||||||
async with session_scope() as session:
|
|
||||||
deleted_job = await session.get(Job, job_id)
|
|
||||||
assert deleted_job is None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_delete_job_blocked_when_processing(self, app_client):
|
|
||||||
_, client = app_client
|
|
||||||
|
|
||||||
async with session_scope() as session:
|
|
||||||
doc = Document(name="Active Doc", document_type="letter")
|
|
||||||
session.add(doc)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
job = Job(document_id=doc.id, status=JobStatus.PROCESSING)
|
|
||||||
session.add(job)
|
|
||||||
await session.commit()
|
|
||||||
job_id = str(job.id)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Delete is blocked while the job is processing." in response.text
|
|
||||||
|
|
||||||
async with session_scope() as session:
|
|
||||||
job_still_exists = await session.get(Job, job_id)
|
|
||||||
assert job_still_exists is not None
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Tests for the jobs page routes and action handlers."""
|
"""Tests for the jobs page routes and action handlers."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
|
||||||
from transcription.db import session_scope
|
from transcription.db import session_scope
|
||||||
@@ -10,7 +11,7 @@ from transcription.db.models import Document, Job, JobSourceStatus, JobStatus
|
|||||||
# --- Helper Fixtures ---
|
# --- Helper Fixtures ---
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest_asyncio.fixture
|
||||||
async def seed_document_with_unlinked_job():
|
async def seed_document_with_unlinked_job():
|
||||||
"""Seed a document and a queued job for testing route actions."""
|
"""Seed a document and a queued job for testing route actions."""
|
||||||
async with session_scope() as session:
|
async with session_scope() as session:
|
||||||
@@ -45,7 +46,8 @@ class TestJobsPageRendering:
|
|||||||
assert "Transcription Pipeline Jobs" in response.text
|
assert "Transcription Pipeline Jobs" in response.text
|
||||||
assert "No active or historical processing jobs found." in response.text
|
assert "No active or historical processing jobs found." in response.text
|
||||||
|
|
||||||
def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job):
|
@pytest.mark.asyncio
|
||||||
|
async def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job):
|
||||||
_, client = app_client
|
_, client = app_client
|
||||||
job_id = await seed_job(filename="seeded-document-page.png")
|
job_id = await seed_job(filename="seeded-document-page.png")
|
||||||
|
|
||||||
@@ -92,7 +94,7 @@ class TestJobsPageRendering:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert f"Job Record: {job_id}" in response.text
|
assert f"Job Record: {job_id}" in response.text
|
||||||
assert "Execution Logistics" in response.text
|
assert "Job Execution Logistics" in response.text
|
||||||
assert "openai" in response.text
|
assert "openai" in response.text
|
||||||
assert "gpt-4o" in response.text
|
assert "gpt-4o" in response.text
|
||||||
assert "View Linked Document" in response.text
|
assert "View Linked Document" in response.text
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
"""Action handler tests for Person CRUD mutations."""
|
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
from transcription.db import session_scope
|
|
||||||
from transcription.db.models import Document, DocumentPerson, DocumentPersonRole, Person
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
class TestPeopleActionHandlers:
|
|
||||||
"""Verify POST/mutation routes for Person creation, updates, and deletions."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_person_success(self, app_client):
|
|
||||||
_, client = app_client
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"full_name": "Mary-Jo Kline",
|
|
||||||
"display_name": "Mary-Jo",
|
|
||||||
"maiden_name": "",
|
|
||||||
"birth_date": "1945-03-12",
|
|
||||||
"birth_date_raw": "ca. 1945",
|
|
||||||
"birth_place": "Boston, MA",
|
|
||||||
"biography": "Editor and scholar in documentary editing.",
|
|
||||||
}
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Mary-Jo Kline" in response.text
|
|
||||||
|
|
||||||
async with session_scope() as session:
|
|
||||||
person = (
|
|
||||||
await session.exec(select(Person).where(Person.full_name == "Mary-Jo Kline"))
|
|
||||||
).first()
|
|
||||||
assert person is not None
|
|
||||||
assert person.display_name == "Mary-Jo"
|
|
||||||
assert person.birth_date == date(1945, 3, 12)
|
|
||||||
assert person.biography == "Editor and scholar in documentary editing."
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_person_validation_missing_full_name(self, app_client):
|
|
||||||
_, client = app_client
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"full_name": "",
|
|
||||||
"display_name": "Anonymous",
|
|
||||||
}
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Full name is required." in response.text
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_person_details_success(self, app_client):
|
|
||||||
_, client = app_client
|
|
||||||
|
|
||||||
async with session_scope() as session:
|
|
||||||
person = Person(full_name="Original Name", display_name="Orig")
|
|
||||||
session.add(person)
|
|
||||||
await session.commit()
|
|
||||||
person_id = str(person.id)
|
|
||||||
|
|
||||||
update_payload = {
|
|
||||||
"full_name": "Updated Person Name",
|
|
||||||
"display_name": "Updated Display",
|
|
||||||
"maiden_name": "Cochran",
|
|
||||||
"birth_date": "1902-08-20",
|
|
||||||
"biography": "Updated archival biographical information.",
|
|
||||||
}
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Updated Person Name" in response.text
|
|
||||||
|
|
||||||
async with session_scope() as session:
|
|
||||||
updated_person = await session.get(Person, person_id)
|
|
||||||
assert updated_person is not None
|
|
||||||
assert updated_person.full_name == "Updated Person Name"
|
|
||||||
assert updated_person.display_name == "Updated Display"
|
|
||||||
assert updated_person.maiden_name == "Cochran"
|
|
||||||
assert updated_person.birth_date == date(1902, 8, 20)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_delete_unlinked_person_success(self, app_client):
|
|
||||||
_, client = app_client
|
|
||||||
|
|
||||||
async with session_scope() as session:
|
|
||||||
person = Person(full_name="Transient Record")
|
|
||||||
session.add(person)
|
|
||||||
await session.commit()
|
|
||||||
person_id = str(person.id)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Person deleted" in response.text or "Archival Entities: People" in response.text
|
|
||||||
|
|
||||||
async with session_scope() as session:
|
|
||||||
deleted_person = await session.get(Person, person_id)
|
|
||||||
assert deleted_person is None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_delete_person_removes_linked_document_relationship(self, app_client):
|
|
||||||
_, client = app_client
|
|
||||||
|
|
||||||
async with session_scope() as session:
|
|
||||||
person = Person(full_name="Linked Person to Delete")
|
|
||||||
doc = Document(name="Historical Letter", document_type="letter")
|
|
||||||
session.add_all([person, doc])
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
link = DocumentPerson(
|
|
||||||
document_id=doc.id,
|
|
||||||
person_id=person.id,
|
|
||||||
role=DocumentPersonRole.AUTHOR,
|
|
||||||
)
|
|
||||||
session.add(link)
|
|
||||||
await session.commit()
|
|
||||||
person_id = str(person.id)
|
|
||||||
doc_id = str(doc.id)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
async with session_scope() as session:
|
|
||||||
# Person should be deleted
|
|
||||||
deleted_person = await session.get(Person, person_id)
|
|
||||||
assert deleted_person is None
|
|
||||||
|
|
||||||
# Associated relationship link should also be removed
|
|
||||||
remaining_links = (
|
|
||||||
await session.exec(
|
|
||||||
select(DocumentPerson).where(DocumentPerson.person_id == person_id)
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
assert len(remaining_links) == 0
|
|
||||||
|
|
||||||
# Document itself should remain intact
|
|
||||||
document = await session.get(Document, doc_id)
|
|
||||||
assert document is not None
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
"""Action handler tests for Source CRUD mutations."""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
from transcription.db import session_scope
|
|
||||||
from transcription.db.models import Document, Job, JobSource, Source
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
class TestSourcesActionHandlers:
|
|
||||||
"""Verify POST/mutation routes for Source revisions and deletions."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_upsert_revision_for_source_success(self, app_client, seed_job):
|
|
||||||
_, client = app_client
|
|
||||||
job_id = await seed_job(
|
|
||||||
filename="revision-source.png",
|
|
||||||
transcription_text="automated raw transcription text",
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
|
||||||
source_id = str(source.id)
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"revised_text": "Curated human transcription text by editor.",
|
|
||||||
}
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Revision saved" in response.text or "Curated human transcription text by editor." in response.text
|
|
||||||
|
|
||||||
async with session_scope() as session:
|
|
||||||
updated_source = await session.get(Source, source_id)
|
|
||||||
assert updated_source is not None
|
|
||||||
assert updated_source.revised_text == "Curated human transcription text by editor."
|
|
||||||
assert updated_source.date_revised is not None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_delete_unlinked_source_success(self, app_client):
|
|
||||||
_, client = app_client
|
|
||||||
|
|
||||||
async with session_scope() as session:
|
|
||||||
doc = Document(name="Unlinked Source Doc", document_type="memo")
|
|
||||||
session.add(doc)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
source = Source(
|
|
||||||
document_id=doc.id,
|
|
||||||
page_number=1,
|
|
||||||
upload_name="orphan_page.png",
|
|
||||||
filename="orphan_page.png",
|
|
||||||
file_path="/tmp/orphan_page.png",
|
|
||||||
)
|
|
||||||
session.add(source)
|
|
||||||
await session.commit()
|
|
||||||
source_id = str(source.id)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Source deleted" in response.text or "Archival Source Media" in response.text
|
|
||||||
|
|
||||||
async with session_scope() as session:
|
|
||||||
deleted_source = await session.get(Source, source_id)
|
|
||||||
assert deleted_source is None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_delete_source_blocked_when_job_linked(self, app_client, seed_job):
|
|
||||||
_, client = app_client
|
|
||||||
job_id = await seed_job(filename="job-linked-source.png", transcription_text="job text")
|
|
||||||
|
|
||||||
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
|
|
||||||
source_id = str(source.id)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Delete is only available for unlinked sources." in response.text or "linked" in response.text.lower()
|
|
||||||
|
|
||||||
async with session_scope() as session:
|
|
||||||
source_still_exists = await session.get(Source, source_id)
|
|
||||||
assert source_still_exists is not None
|
|
||||||
@@ -105,23 +105,23 @@ class TestSourcesPageRendering:
|
|||||||
session.add_all([target, other])
|
session.add_all([target, other])
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
session.add(
|
session.add_all(
|
||||||
|
[
|
||||||
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(
|
Source(
|
||||||
document_id=other.id,
|
document_id=other.id,
|
||||||
page_number=1,
|
page_number=1,
|
||||||
upload_name="other_page.png",
|
upload_name="other_page.png",
|
||||||
filename="other_stored.png",
|
filename="other_stored.png",
|
||||||
file_path="/tmp/other_stored.png",
|
file_path="/tmp/other_stored.png",
|
||||||
)
|
),
|
||||||
|
]
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
target_id = str(target.id)
|
target_id = str(target.id)
|
||||||
@@ -135,7 +135,7 @@ class TestSourcesPageRendering:
|
|||||||
assert "other_page.png" not in response.text
|
assert "other_page.png" not in response.text
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
def test_sources_page_filters_to_job_context(self, app_client, seed_job):
|
async def test_sources_page_filters_to_job_context(self, app_client, seed_job):
|
||||||
_, client = app_client
|
_, client = app_client
|
||||||
job_id = await seed_job(filename="job-page.png", transcription_text="job text")
|
job_id = await seed_job(filename="job-page.png", transcription_text="job text")
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ class TestSourcesPageRendering:
|
|||||||
assert "job-page.png" in response.text
|
assert "job-page.png" in response.text
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
def test_sources_page_job_context_shows_job_source_status_and_error_detail(
|
async def test_sources_page_job_context_shows_job_source_status_and_error_detail(
|
||||||
self, app_client, seed_job
|
self, app_client, seed_job
|
||||||
):
|
):
|
||||||
_, client = app_client
|
_, client = app_client
|
||||||
|
|||||||
Reference in New Issue
Block a user