generated from john/python-template
UI updates continue. Focus on Sources
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user