UI updates continue. Focus on Sources

This commit is contained in:
Jim Lancaster
2026-08-02 18:41:09 -05:00
parent 0ab7ad50f2
commit 49e2e48df1
10 changed files with 398 additions and 39 deletions
+1 -2
View File
@@ -15,13 +15,12 @@ logger = logging.getLogger(__name__)
async def create_all(*, engine: AsyncEngine | None = None) -> None:
"""Create all tables on the selected engine from scratch."""
"""Create any missing tables on the selected engine."""
# Import models so SQLModel metadata is fully registered before bootstrap.
from transcription.db import models as _models # noqa: F401
active_engine = engine or resolve_engine()
async with active_engine.begin() as connection:
await connection.run_sync(SQLModel.metadata.drop_all)
await connection.run_sync(SQLModel.metadata.create_all)
logger.debug("Database schema bootstrap complete for database_url=%s", active_engine.url)
@@ -83,6 +83,27 @@ class TranscriptionService(ServiceBase):
)
return source
async def read_source_detail(self, source_id: UUID, *, session: AsyncSession | None = None) -> Source:
"""Read a source page record with job-source context for UI detail rendering."""
async with self._session_scope(session) as _session:
query = (
select(Source)
.options(
selectinload(Source.job_sources).selectinload(JobSource.job), # pyright: ignore[reportArgumentType]
)
.where(Source.id == source_id)
.execution_options(populate_existing=True)
)
source = (await _session.exec(query)).first()
if source is None:
raise TranscriptionNotFoundError(
f"Source with id {source_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the source id and retry.",
)
return source
async def update_source(self, source: Source, *, session: AsyncSession | None = None) -> Source:
"""Update an existing source page record."""
async with self._session_scope(session) as _session:
+2
View File
@@ -6,6 +6,7 @@ from nicegui import ui
from transcription.ui.pages.documents_page import register_page as register_documents_page
from transcription.ui.pages.jobs_page import register_page as register_jobs_page
from transcription.ui.pages.people_page import register_page as register_people_page
from transcription.ui.pages.sources_page import register_page as register_sources_page
from transcription.ui.pages.upload_page import register_page as register_upload_page
from transcription.ui.resources import read_css
@@ -27,5 +28,6 @@ def register_pages(app: FastAPI) -> None:
register_upload_page()
register_documents_page()
register_people_page()
register_sources_page()
register_jobs_page()
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False)
@@ -9,6 +9,7 @@ from transcription.ui.resources import read_css
NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
("Documents", "/documents", "description"),
("People", "/people", "group"),
("Sources", "/sources", "folder"),
("Jobs", "/jobs", "work_history"),
)
@@ -20,6 +21,8 @@ def _is_active_path(*, current_path: str, item_path: str) -> bool:
return current_path == "/documents" or current_path.startswith("/documents/")
if item_path == "/people":
return current_path == "/people" or current_path.startswith("/people/")
if item_path == "/sources":
return current_path == "/sources" or current_path.startswith("/sources/")
return current_path == item_path
+5 -35
View File
@@ -6,6 +6,7 @@ from datetime import date
from uuid import UUID
from fastapi import Request
from fastapi.responses import RedirectResponse
from nicegui import ui
from transcription.db.models import Document
@@ -211,7 +212,7 @@ def register_page() -> None:
with ui.row().classes("w-full items-center gap-2"):
ui.button(
"Sources",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/sources"),
on_click=lambda: ui.navigate.to(f"/sources?document_id={document.id}"),
icon="description",
).props("flat")
ui.button(
@@ -274,40 +275,9 @@ def register_page() -> None:
)
@ui.page("/documents/{document_id}/sources")
async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> None:
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
try:
parsed_document_id = UUID(document_id)
except ValueError:
ui.label("Invalid document id").classes("text-h6 text-negative")
return
try:
document = await document_service.read_document_detail(document_id=parsed_document_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-negative")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.sources")
return
ui.label(f"Sources for {document.name}").classes("text-h5 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
ui.button("Add sources", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="upload_file").props(
'unelevated color="primary"'
)
if not document.sources:
ui.label("No sources added yet.").classes("text-body2 vibe-text-muted")
return
for source in sorted(document.sources, key=lambda item: item.page_number):
with ui.card().classes("w-full"):
ui.label(f"Page {source.page_number}: {source.upload_name}").classes("text-body2")
ui.label(f"Stored filename: {source.filename}").classes("text-body2")
async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse:
_ = session_factory
return RedirectResponse(url=f"/ui/sources?document_id={document_id}")
@ui.page("/documents/{document_id}/edit")
async def document_edit_page(document_id: str, session_factory: SessionFactoryDep) -> None:
+1 -1
View File
@@ -218,7 +218,7 @@ def register_page() -> None: # noqa: PLR0915
).props("flat")
ui.button(
"Sources",
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}/sources"),
on_click=lambda: ui.navigate.to(f"/sources?job_id={job.id}"),
icon="description",
).props("flat")
ui.button(
+222
View File
@@ -0,0 +1,222 @@
"""Sources list and detail page registration."""
from __future__ import annotations
from uuid import UUID
from urllib.parse import urlencode
from fastapi import Request
from fastapi.responses import RedirectResponse
from nicegui import ui
from transcription.db.models import JobSource
from transcription.db.models import Source
from transcription.services.documents import DocumentError
from transcription.services.documents import DocumentService
from transcription.services.jobs import JobService
from transcription.services.transcription import TranscriptionService
from transcription.services.transcription import TranscriptionNotFoundError
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.document_panzoom import render_document_panzoom
from transcription.ui.components.error_presenter import show_error
from ...db.session import SessionFactoryDep
def register_page() -> None:
"""Register source list and detail routes."""
@ui.page("/sources")
async def sources_page(request: Request, session_factory: SessionFactoryDep) -> None:
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_text = request.query_params.get("document_id")
job_id_text = request.query_params.get("job_id")
document_id = _parse_uuid(document_id_text)
job_id = _parse_uuid(job_id_text)
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 = list(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 = [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:
sources = list(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-negative")
return
except ValueError:
ui.label("Job not found").classes("text-h6 text-negative")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="sources.list")
return
if document_name is not None:
ui.label(f"Sources for {document_name}").classes("text-h5 text-weight-medium")
elif job_label is not None:
ui.label(f"Sources for Job {job_label}").classes("text-h5 text-weight-medium")
else:
ui.label("Sources").classes("text-h5 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
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")
if not sources:
ui.label("No sources added yet.").classes("text-body2 vibe-text-muted")
return
with ui.column().classes("w-full gap-2"):
for source in sources:
with ui.card().classes("w-full") as card:
card.on(
"click",
lambda _=None, source_id=source.id, filters=_build_filter_query(document_id=document_id, job_id=job_id): ui.navigate.to(
f"/sources/{source_id}{filters}"
),
)
card.classes("cursor-pointer")
with ui.column().classes("gap-1"):
ui.label(f"Page {source.page_number}: {source.upload_name}").classes("text-subtitle1 text-weight-medium")
ui.label(f"Stored filename: {source.filename}").classes("text-body2")
ui.label(f"Document id: {source.document_id}").classes("text-body2 vibe-text-muted")
@ui.page("/sources/{source_id}")
async def source_detail_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
sources_service = TranscriptionService(session_factory=session_factory)
render_navigation_header(current_path="/sources")
try:
parsed_source_id = UUID(source_id)
except ValueError:
ui.label("Invalid source id").classes("text-h6 text-negative")
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-negative")
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)
ui.label(f"Source {source.upload_name}").classes("text-h5 text-weight-medium")
with ui.row().classes("w-full 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")
else:
ui.button("Back to Sources", on_click=lambda: ui.navigate.to("/sources"), icon="arrow_back")
with ui.column().classes("w-full gap-3"):
render_document_panzoom(source=source)
with ui.column().classes("gap-1"):
ui.label(f"Page number: {source.page_number}").classes("text-body2")
ui.label(f"Upload name: {source.upload_name}").classes("text-body2")
ui.label(f"Stored filename: {source.filename}").classes("text-body2")
ui.label(f"Document id: {source.document_id}").classes("text-body2")
ui.label(f"Uploaded: {source.date_uploaded.isoformat()}").classes("text-body2")
ui.label(f"Revised: {source.date_revised.isoformat() if source.date_revised else 'not set'}").classes("text-body2")
ui.separator()
ui.label("Transcription text").classes("text-subtitle1 text-weight-medium")
ui.textarea(value=_source_transcription_text(source) or "").props("outlined autogrow readonly").classes("w-full")
ui.label("Revision text").classes("text-subtitle1 text-weight-medium")
revision_input = ui.textarea(label="Revision text", value=source.revised_text or "").props("outlined autogrow")
revision_input.classes("w-full")
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"):
ui.button("Save revision", on_click=save_revision, icon="save").props('unelevated color="primary"')
@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}")
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 _back_query(query_params) -> 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) -> 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
+2 -1
View File
@@ -218,10 +218,11 @@ class TestDocumentsPageRendering:
return str(target.id)
document_id = asyncio.run(_seed())
response = client.get(f"/ui/documents/{document_id}/sources")
response = client.get(f"/ui/sources?document_id={document_id}")
assert response.status_code == 200
assert "Sources for Target" in response.text
assert "Back to Document" in response.text
assert "target_page.png" in response.text
assert "other_page.png" not in response.text
+2
View File
@@ -14,9 +14,11 @@ class TestPageRegistration:
upload_response = client.get("/ui/upload", follow_redirects=False)
documents_response = client.get("/ui/documents")
people_response = client.get("/ui/people")
sources_response = client.get("/ui/sources")
jobs_response = client.get("/ui/jobs")
assert upload_response.status_code == 307
assert documents_response.status_code == 200
assert people_response.status_code == 200
assert sources_response.status_code == 200
assert jobs_response.status_code == 200
+139
View File
@@ -0,0 +1,139 @@
"""Tests for the sources page routes."""
import asyncio
from pathlib import Path
import pytest
from sqlmodel import select
from transcription.db import session_scope
from transcription.db.models import Document
from transcription.db.models import Job
from transcription.db.models import Source
@pytest.mark.integration
class TestSourcesPageRendering:
"""Verify source list and detail routes render expected states."""
def test_sources_page_renders_empty_state(self, app_client):
_, client = app_client
response = client.get("/ui/sources")
assert response.status_code == 200
assert "Sources" in response.text
assert "No sources added yet." in response.text
def test_sources_page_lists_seeded_sources(self, app_client):
_, client = app_client
async def _seed() -> None:
async with session_scope() as session:
document = Document(name="Source Document", document_type="letter")
session.add(document)
await session.flush()
session.add(
Source(
document_id=document.id,
page_number=1,
upload_name="page_one.png",
filename="stored_page_one.png",
file_path="/tmp/stored_page_one.png",
)
)
await session.commit()
asyncio.run(_seed())
response = client.get("/ui/sources")
assert response.status_code == 200
assert "Page 1: 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):
_, client = app_client
async def _seed() -> str:
async with session_scope() as session:
target = Document(name="Target", document_type="letter")
other = Document(name="Other", document_type="record")
session.add(target)
session.add(other)
await session.flush()
session.add(
Source(
document_id=target.id,
page_number=1,
upload_name="target_page.png",
filename="target_stored.png",
file_path="/tmp/target_stored.png",
)
)
session.add(
Source(
document_id=other.id,
page_number=1,
upload_name="other_page.png",
filename="other_stored.png",
file_path="/tmp/other_stored.png",
)
)
await session.commit()
return str(target.id)
document_id = asyncio.run(_seed())
response = client.get(f"/ui/sources?document_id={document_id}")
assert response.status_code == 200
assert "Sources for Target" in response.text
assert "Back to Document" in response.text
assert "target_page.png" in response.text
assert "other_page.png" not in response.text
def test_sources_page_filters_to_job_context(self, app_client, seed_job):
_, client = app_client
job_id = seed_job(filename="job-page.png", transcription_text="job text")
response = client.get(f"/ui/sources?job_id={job_id}")
assert response.status_code == 200
assert "Sources for Job" in response.text
assert "Back to Job" in response.text
assert "job-page.png" in response.text
def test_source_detail_page_renders_preview_and_revision_box(self, app_client, seed_job):
_, client = app_client
fixture_path = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid" / "small_png.png"
job_id = seed_job(
filename="detail-source.png",
transcription_text="original transcription text",
revision_text="human revision text",
source_file=fixture_path,
)
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 "Source detail-source.png" in response.text
assert "Transcription text" in response.text
assert "original transcription text" in response.text
assert "Revision text" in response.text
assert "human revision text" in response.text
assert "Page number:" in response.text
assert "Stored filename:" in response.text