UI slog continues

This commit is contained in:
Jim Lancaster
2026-08-02 20:03:02 -05:00
parent 49e2e48df1
commit c098013a68
7 changed files with 156 additions and 31 deletions
+30 -7
View File
@@ -25,6 +25,7 @@ from .documents import UploadJobResult
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SUPPORTED_UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"} SUPPORTED_UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
SUPPORTED_PORTRAIT_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff"}
class UploadError(AppError): class UploadError(AppError):
@@ -246,13 +247,35 @@ def _best_effort_delete(path: Path) -> None:
def store_file(*, filename: str, file_bytes: bytes, settings: Settings | None = None) -> Path: def store_file(*, filename: str, file_bytes: bytes, settings: Settings | None = None) -> Path:
"""Persist an uploaded file to the configured upload directory.""" """Persist an uploaded file to the configured upload directory."""
runtime_settings = settings or get_settings() runtime_settings = settings or get_settings()
_validate_upload(filename=filename, file_bytes=file_bytes) _validate_upload(filename=filename, file_bytes=file_bytes, supported_extensions=SUPPORTED_UPLOAD_EXTENSIONS)
return _store_file_bytes(filename=filename, file_bytes=file_bytes, settings=runtime_settings)
upload_dir = runtime_settings.upload_dir
upload_dir.mkdir(parents=True, exist_ok=True) def store_person_portrait(*, filename: str, file_bytes: bytes, settings: Settings | None = None) -> Path:
"""Persist a portrait upload under uploads/portraits/person."""
runtime_settings = settings or get_settings()
_validate_upload(filename=filename, file_bytes=file_bytes, supported_extensions=SUPPORTED_PORTRAIT_EXTENSIONS)
return _store_file_bytes(
filename=filename,
file_bytes=file_bytes,
settings=runtime_settings,
relative_directory=Path("portraits") / "person",
)
def _store_file_bytes(
*,
filename: str,
file_bytes: bytes,
settings: Settings,
relative_directory: Path | None = None,
) -> Path:
upload_dir = settings.upload_dir
target_dir = upload_dir if relative_directory is None else upload_dir / relative_directory
target_dir.mkdir(parents=True, exist_ok=True)
stored_name = _build_stored_filename(filename) stored_name = _build_stored_filename(filename)
stored_path = upload_dir / stored_name stored_path = target_dir / stored_name
try: try:
stored_path.write_bytes(file_bytes) stored_path.write_bytes(file_bytes)
@@ -267,7 +290,7 @@ def store_file(*, filename: str, file_bytes: bytes, settings: Settings | None =
return stored_path return stored_path
def _validate_upload(*, filename: str, file_bytes: bytes) -> None: def _validate_upload(*, filename: str, file_bytes: bytes, supported_extensions: set[str]) -> None:
if not file_bytes: if not file_bytes:
raise UploadError( raise UploadError(
"Upload payload is empty", "Upload payload is empty",
@@ -284,11 +307,11 @@ def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
) )
suffix = Path(safe_name).suffix.lower() suffix = Path(safe_name).suffix.lower()
if suffix not in SUPPORTED_UPLOAD_EXTENSIONS: if suffix not in supported_extensions:
raise UploadError( raise UploadError(
f"Unsupported upload extension: {suffix}", f"Unsupported upload extension: {suffix}",
category=ErrorCategory.USER_INPUT, category=ErrorCategory.USER_INPUT,
suggestion="Upload JPG, JPEG, PNG, TIFF, or PDF files only.", suggestion="Upload a supported image or document file and retry.",
) )
+42 -7
View File
@@ -21,6 +21,8 @@ from transcription.ui.components.error_presenter import show_error
from ...db.session import SessionFactoryDep from ...db.session import SessionFactoryDep
CREATE_NEW_PERSON_OPTION = "__create_new_person__"
def register_page() -> None: def register_page() -> None:
"""Register documents list and detail routes.""" """Register documents list and detail routes."""
@@ -41,8 +43,20 @@ def register_page() -> None:
archive_input = ui.input(label="Archive identifier").props("outlined") archive_input = ui.input(label="Archive identifier").props("outlined")
notes_input = ui.textarea(label="Notes").props("outlined autogrow") notes_input = ui.textarea(label="Notes").props("outlined autogrow")
people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold()) people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold())
author_options = {"": "No author"} | {str(person.id): person.full_name for person in people} author_options = (
author_select = ui.select(author_options, label="Author (Person)", value="").props("outlined") {"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"}
| {str(person.id): person.full_name for person in people}
)
def on_author_change(event) -> None:
selected = str(event.value or "").strip()
if selected == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
author_select = ui.select(author_options, label="Author (Person)", value="", on_change=on_author_change).props(
"outlined"
)
ui.link("Create new person", "/people/new").classes("text-body2")
return_to = request.query_params.get("return_to") return_to = request.query_params.get("return_to")
@@ -78,6 +92,9 @@ def register_page() -> None:
return return
selected_author = (author_select.value or "").strip() selected_author = (author_select.value or "").strip()
if selected_author == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
return
if selected_author: if selected_author:
try: try:
parsed_person_id = UUID(selected_author) parsed_person_id = UUID(selected_author)
@@ -216,9 +233,9 @@ def register_page() -> None:
icon="description", icon="description",
).props("flat") ).props("flat")
ui.button( ui.button(
"Add sources", "+ Add Source",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="upload_file", icon="add",
).props('unelevated color="primary"') ).props('unelevated color="primary"')
ui.label(f"{len(document.sources)} source(s) linked").classes("text-body2") ui.label(f"{len(document.sources)} source(s) linked").classes("text-body2")
@@ -229,7 +246,7 @@ def register_page() -> None:
"flat" "flat"
) )
ui.button( ui.button(
"Create job", "+ Add Job",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="add", icon="add",
).props('unelevated color="primary"') ).props('unelevated color="primary"')
@@ -313,13 +330,28 @@ def register_page() -> None:
archive_input = ui.input(label="Archive identifier", value=document.archive_identifier or "").props("outlined") archive_input = ui.input(label="Archive identifier", value=document.archive_identifier or "").props("outlined")
notes_input = ui.textarea(label="Notes", value=document.notes or "").props("outlined autogrow") notes_input = ui.textarea(label="Notes", value=document.notes or "").props("outlined autogrow")
people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold()) people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold())
author_options = {"": "No author"} | {str(person.id): person.full_name for person in people} author_options = (
{"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"}
| {str(person.id): person.full_name for person in people}
)
existing_author = next( existing_author = next(
(item for item in document.document_people if item.role == DocumentPersonRole.AUTHOR), (item for item in document.document_people if item.role == DocumentPersonRole.AUTHOR),
None, None,
) )
author_value = str(existing_author.person_id) if existing_author is not None else "" author_value = str(existing_author.person_id) if existing_author is not None else ""
author_select = ui.select(author_options, label="Author (Person)", value=author_value).props("outlined")
def on_author_change(event) -> None:
selected = str(event.value or "").strip()
if selected == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
author_select = ui.select(
author_options,
label="Author (Person)",
value=author_value,
on_change=on_author_change,
).props("outlined")
ui.link("Create new person", "/people/new").classes("text-body2")
async def submit_edit() -> None: async def submit_edit() -> None:
candidate_name = (name_input.value or "").strip() candidate_name = (name_input.value or "").strip()
@@ -360,6 +392,9 @@ def register_page() -> None:
return return
selected_author = (author_select.value or "").strip() selected_author = (author_select.value or "").strip()
if selected_author == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
return
existing_author_links = [ existing_author_links = [
link for link in document.document_people if link.role == DocumentPersonRole.AUTHOR link for link in document.document_people if link.role == DocumentPersonRole.AUTHOR
] ]
+39 -8
View File
@@ -6,13 +6,18 @@ from datetime import date
from urllib.parse import quote from urllib.parse import quote
from uuid import UUID from uuid import UUID
from fastapi import Request
from nicegui import ui from nicegui import ui
from transcription.config import Settings
from transcription.config import get_settings
from transcription.db.models import Person from transcription.db.models import Person
from transcription.errors import ErrorCategory from transcription.errors import ErrorCategory
from transcription.services.documents import DocumentError from transcription.services.documents import DocumentError
from transcription.services.documents import DocumentService from transcription.services.documents import DocumentService
from transcription.services.documents import PersonDeleteBlockedError from transcription.services.documents import PersonDeleteBlockedError
from transcription.services.store import UploadError
from transcription.services.store import store_person_portrait
from transcription.ui.components.app_shell import render_navigation_header from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.error_presenter import show_error from transcription.ui.components.error_presenter import show_error
@@ -30,17 +35,36 @@ def _parse_optional_date(value: str | None, *, label: str) -> date | None:
raise ValueError(f"{label} must use YYYY-MM-DD.") from exc raise ValueError(f"{label} must use YYYY-MM-DD.") from exc
def _bind_portrait_file_picker(portrait_path_input: ui.input) -> None: def _bind_portrait_file_picker(portrait_path_input: ui.input, *, settings: Settings) -> None:
async def on_portrait_selected(event) -> None: async def on_portrait_selected(event) -> None:
portrait_path_input.value = event.file.name payload = await event.file.read()
ui.notify("Portrait filename selected. Edit the path if needed.", type="info") try:
stored_path = store_person_portrait(
filename=event.file.name,
file_bytes=payload,
settings=settings,
)
except UploadError as exc:
ui.notify(str(exc), type="negative")
return
except Exception: # noqa: BLE001
ui.notify("Unable to store portrait image.", type="negative")
return
try:
relative_path = stored_path.resolve().relative_to(settings.upload_dir.resolve()).as_posix()
except ValueError:
relative_path = stored_path.name
portrait_path_input.value = relative_path
ui.notify("Portrait uploaded.", type="positive")
ui.upload( ui.upload(
on_upload=on_portrait_selected, on_upload=on_portrait_selected,
auto_upload=True, auto_upload=True,
label="Choose portrait file", label="Choose portrait file",
).props('accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"') ).props('accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"')
ui.label("This picker fills the filename from your selected image.").classes("text-body2 vibe-text-muted") ui.label("Portraits are stored under uploads/portraits/person.").classes("text-body2 vibe-text-muted")
def _resolve_portrait_src(path: str | None) -> str | None: def _resolve_portrait_src(path: str | None) -> str | None:
@@ -59,6 +83,13 @@ def _resolve_portrait_src(path: str | None) -> str | None:
return f"/uploads/{quote(normalized)}" return f"/uploads/{quote(normalized)}"
def _resolve_runtime_settings(request: Request) -> Settings:
app_settings = getattr(request.app.state, "settings", None)
if isinstance(app_settings, Settings):
return app_settings
return get_settings()
def register_page() -> None: # noqa: PLR0915 def register_page() -> None: # noqa: PLR0915
"""Register people list and CRUD routes.""" """Register people list and CRUD routes."""
@@ -101,7 +132,7 @@ def register_page() -> None: # noqa: PLR0915
).props("flat") ).props("flat")
@ui.page("/people/new") @ui.page("/people/new")
async def person_create_page(session_factory: SessionFactoryDep) -> None: async def person_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
people_service = DocumentService(session_factory=session_factory) people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people") render_navigation_header(current_path="/people")
@@ -122,7 +153,7 @@ def register_page() -> None: # noqa: PLR0915
biography_input = ui.textarea(label="Biography").props("outlined autogrow") biography_input = ui.textarea(label="Biography").props("outlined autogrow")
portrait_path_input = ui.input(label="Portrait path").props("outlined") portrait_path_input = ui.input(label="Portrait path").props("outlined")
_bind_portrait_file_picker(portrait_path_input) _bind_portrait_file_picker(portrait_path_input, settings=_resolve_runtime_settings(request))
async def submit_create() -> None: async def submit_create() -> None:
full_name = (full_name_input.value or "").strip() full_name = (full_name_input.value or "").strip()
@@ -238,7 +269,7 @@ def register_page() -> None: # noqa: PLR0915
).props("flat") ).props("flat")
@ui.page("/people/{person_id}/edit") @ui.page("/people/{person_id}/edit")
async def person_edit_page(person_id: str, session_factory: SessionFactoryDep) -> None: async def person_edit_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
people_service = DocumentService(session_factory=session_factory) people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people") render_navigation_header(current_path="/people")
@@ -284,7 +315,7 @@ def register_page() -> None: # noqa: PLR0915
biography_input = ui.textarea(label="Biography", value=person.biography or "").props("outlined autogrow") biography_input = ui.textarea(label="Biography", value=person.biography or "").props("outlined autogrow")
portrait_path_input = ui.input(label="Portrait path", value=person.portrait_path or "").props("outlined") portrait_path_input = ui.input(label="Portrait path", value=person.portrait_path or "").props("outlined")
_bind_portrait_file_picker(portrait_path_input) _bind_portrait_file_picker(portrait_path_input, settings=_resolve_runtime_settings(request))
async def submit_edit() -> None: async def submit_edit() -> None:
full_name = (full_name_input.value or "").strip() full_name = (full_name_input.value or "").strip()
+14 -7
View File
@@ -87,18 +87,21 @@ def register_page() -> None:
with ui.column().classes("w-full gap-2"): with ui.column().classes("w-full gap-2"):
for source in sources: for source in sources:
detail_path = _source_detail_path(source_id=source.id, document_id=document_id, job_id=job_id)
with ui.card().classes("w-full") as card: with ui.card().classes("w-full") as card:
card.on( card.on(
"click", "click",
lambda _=None, source_id=source.id, filters=_build_filter_query(document_id=document_id, job_id=job_id): ui.navigate.to( lambda _=None, route=detail_path: ui.navigate.to(route),
f"/sources/{source_id}{filters}"
),
) )
card.classes("cursor-pointer") card.classes("cursor-pointer")
with ui.column().classes("gap-1"): with ui.row().classes("w-full items-center justify-between"):
ui.label(f"Page {source.page_number}: {source.upload_name}").classes("text-subtitle1 text-weight-medium") with ui.column().classes("gap-1"):
ui.label(f"Stored filename: {source.filename}").classes("text-body2") ui.label(f"Page {source.page_number}: {source.upload_name}").classes("text-subtitle1 text-weight-medium")
ui.label(f"Document id: {source.document_id}").classes("text-body2 vibe-text-muted") 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.button("Open source detail", on_click=lambda route=detail_path: ui.navigate.to(route), icon="open_in_new").props(
"flat"
)
@ui.page("/sources/{source_id}") @ui.page("/sources/{source_id}")
async def source_detail_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None: async def source_detail_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
@@ -194,6 +197,10 @@ def _build_filter_query(*, document_id: UUID | None, job_id: UUID | None) -> str
return f"?{urlencode(params)}" if params else "" 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) -> str: def _back_query(query_params) -> str:
params = {} params = {}
for key in ("document_id", "job_id"): for key in ("document_id", "job_id"):
+6 -2
View File
@@ -50,6 +50,8 @@ class TestDocumentsPageRendering:
assert "Document location" in response.text assert "Document location" in response.text
assert "Archive identifier" in response.text assert "Archive identifier" in response.text
assert "Notes" in response.text assert "Notes" in response.text
assert "Create new item" in response.text
assert "Create new person" in response.text
assert "Save document" in response.text assert "Save document" in response.text
def test_documents_page_lists_seeded_documents(self, app_client): def test_documents_page_lists_seeded_documents(self, app_client):
@@ -107,8 +109,8 @@ class TestDocumentsPageRendering:
assert "No linked people yet." in response.text assert "No linked people yet." in response.text
assert "0 source(s) linked" in response.text assert "0 source(s) linked" in response.text
assert "0 job(s) linked" in response.text assert "0 job(s) linked" in response.text
assert "Add sources" in response.text assert "+ Add Source" in response.text
assert "Create job" in response.text assert "+ Add Job" in response.text
assert "Sources" in response.text assert "Sources" in response.text
assert "Jobs" in response.text assert "Jobs" in response.text
assert "Edit document" in response.text assert "Edit document" in response.text
@@ -275,6 +277,8 @@ class TestDocumentsPageRendering:
assert "Document location" in response.text assert "Document location" in response.text
assert "Archive identifier" in response.text assert "Archive identifier" in response.text
assert "Notes" in response.text assert "Notes" in response.text
assert "Create new item" in response.text
assert "Create new person" in response.text
assert "Save changes" in response.text assert "Save changes" in response.text
def test_document_delete_page_shows_confirmation_when_unlinked(self, app_client): def test_document_delete_page_shows_confirmation_when_unlinked(self, app_client):
+21
View File
@@ -97,6 +97,27 @@ class TestPeoplePageRendering:
assert "No linked documents yet." in response.text assert "No linked documents yet." in response.text
assert "Link this person from a Document workflow." in response.text assert "Link this person from a Document workflow." in response.text
def test_person_detail_page_resolves_relative_portrait_path_to_uploads_mount(self, app_client):
_, client = app_client
async def _seed_person() -> str:
async with session_scope() as session:
person = Person(
full_name="Portrait Person",
portrait_path="portraits/person/seeded.png",
)
session.add(person)
await session.commit()
await session.refresh(person)
return str(person.id)
person_id = asyncio.run(_seed_person())
response = client.get(f"/ui/people/{person_id}")
assert response.status_code == 200
assert "Portrait path: portraits/person/seeded.png" in response.text
assert "/uploads/portraits/person/seeded.png" in response.text
def test_person_detail_page_renders_linked_documents(self, app_client): def test_person_detail_page_renders_linked_documents(self, app_client):
_, client = app_client _, client = app_client
+4
View File
@@ -51,6 +51,7 @@ class TestSourcesPageRendering:
assert response.status_code == 200 assert response.status_code == 200
assert "Page 1: page_one.png" in response.text assert "Page 1: page_one.png" in response.text
assert "stored_page_one.png" in response.text assert "stored_page_one.png" in response.text
assert "Open source detail" in response.text
def test_sources_page_filters_to_document_context(self, app_client): def test_sources_page_filters_to_document_context(self, app_client):
_, client = app_client _, client = app_client
@@ -93,6 +94,7 @@ class TestSourcesPageRendering:
assert "Back to Document" in response.text assert "Back to Document" in response.text
assert "target_page.png" in response.text assert "target_page.png" in response.text
assert "other_page.png" not in response.text assert "other_page.png" not in response.text
assert "Open source detail" in response.text
def test_sources_page_filters_to_job_context(self, app_client, seed_job): def test_sources_page_filters_to_job_context(self, app_client, seed_job):
_, client = app_client _, client = app_client
@@ -104,6 +106,7 @@ class TestSourcesPageRendering:
assert "Sources for Job" in response.text assert "Sources for Job" in response.text
assert "Back to Job" in response.text assert "Back to Job" in response.text
assert "job-page.png" in response.text assert "job-page.png" in response.text
assert "Open source detail" in response.text
def test_source_detail_page_renders_preview_and_revision_box(self, app_client, seed_job): def test_source_detail_page_renders_preview_and_revision_box(self, app_client, seed_job):
_, client = app_client _, client = app_client
@@ -131,6 +134,7 @@ class TestSourcesPageRendering:
assert response.status_code == 200 assert response.status_code == 200
assert "Source detail-source.png" in response.text assert "Source detail-source.png" in response.text
assert "Back to Sources" in response.text
assert "Transcription text" in response.text assert "Transcription text" in response.text
assert "original transcription text" in response.text assert "original transcription text" in response.text
assert "Revision text" in response.text assert "Revision text" in response.text