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__)
SUPPORTED_UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
SUPPORTED_PORTRAIT_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff"}
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:
"""Persist an uploaded file to the configured upload directory."""
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_path = upload_dir / stored_name
stored_path = target_dir / stored_name
try:
stored_path.write_bytes(file_bytes)
@@ -267,7 +290,7 @@ def store_file(*, filename: str, file_bytes: bytes, settings: Settings | None =
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:
raise UploadError(
"Upload payload is empty",
@@ -284,11 +307,11 @@ def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
)
suffix = Path(safe_name).suffix.lower()
if suffix not in SUPPORTED_UPLOAD_EXTENSIONS:
if suffix not in supported_extensions:
raise UploadError(
f"Unsupported upload extension: {suffix}",
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
CREATE_NEW_PERSON_OPTION = "__create_new_person__"
def register_page() -> None:
"""Register documents list and detail routes."""
@@ -41,8 +43,20 @@ def register_page() -> None:
archive_input = ui.input(label="Archive identifier").props("outlined")
notes_input = ui.textarea(label="Notes").props("outlined autogrow")
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_select = ui.select(author_options, label="Author (Person)", value="").props("outlined")
author_options = (
{"": "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")
@@ -78,6 +92,9 @@ def register_page() -> None:
return
selected_author = (author_select.value or "").strip()
if selected_author == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
return
if selected_author:
try:
parsed_person_id = UUID(selected_author)
@@ -216,9 +233,9 @@ def register_page() -> None:
icon="description",
).props("flat")
ui.button(
"Add sources",
"+ Add Source",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="upload_file",
icon="add",
).props('unelevated color="primary"')
ui.label(f"{len(document.sources)} source(s) linked").classes("text-body2")
@@ -229,7 +246,7 @@ def register_page() -> None:
"flat"
)
ui.button(
"Create job",
"+ Add Job",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="add",
).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")
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())
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(
(item for item in document.document_people if item.role == DocumentPersonRole.AUTHOR),
None,
)
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:
candidate_name = (name_input.value or "").strip()
@@ -360,6 +392,9 @@ def register_page() -> None:
return
selected_author = (author_select.value or "").strip()
if selected_author == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
return
existing_author_links = [
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 uuid import UUID
from fastapi import Request
from nicegui import ui
from transcription.config import Settings
from transcription.config import get_settings
from transcription.db.models import Person
from transcription.errors import ErrorCategory
from transcription.services.documents import DocumentError
from transcription.services.documents import DocumentService
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.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
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:
portrait_path_input.value = event.file.name
ui.notify("Portrait filename selected. Edit the path if needed.", type="info")
payload = await event.file.read()
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(
on_upload=on_portrait_selected,
auto_upload=True,
label="Choose portrait file",
).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:
@@ -59,6 +83,13 @@ def _resolve_portrait_src(path: str | None) -> str | None:
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
"""Register people list and CRUD routes."""
@@ -101,7 +132,7 @@ def register_page() -> None: # noqa: PLR0915
).props("flat")
@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)
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")
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:
full_name = (full_name_input.value or "").strip()
@@ -238,7 +269,7 @@ def register_page() -> None: # noqa: PLR0915
).props("flat")
@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)
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")
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:
full_name = (full_name_input.value or "").strip()
+10 -3
View File
@@ -87,18 +87,21 @@ def register_page() -> None:
with ui.column().classes("w-full gap-2"):
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:
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}"
),
lambda _=None, route=detail_path: ui.navigate.to(route),
)
card.classes("cursor-pointer")
with ui.row().classes("w-full items-center justify-between"):
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.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}")
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 ""
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:
params = {}
for key in ("document_id", "job_id"):
+6 -2
View File
@@ -50,6 +50,8 @@ class TestDocumentsPageRendering:
assert "Document location" in response.text
assert "Archive identifier" 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
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 "0 source(s) linked" in response.text
assert "0 job(s) linked" in response.text
assert "Add sources" in response.text
assert "Create job" in response.text
assert "+ Add Source" in response.text
assert "+ Add Job" in response.text
assert "Sources" in response.text
assert "Jobs" in response.text
assert "Edit document" in response.text
@@ -275,6 +277,8 @@ class TestDocumentsPageRendering:
assert "Document location" in response.text
assert "Archive identifier" 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
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 "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):
_, client = app_client
+4
View File
@@ -51,6 +51,7 @@ class TestSourcesPageRendering:
assert response.status_code == 200
assert "Page 1: 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):
_, client = app_client
@@ -93,6 +94,7 @@ class TestSourcesPageRendering:
assert "Back to Document" in response.text
assert "target_page.png" 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):
_, client = app_client
@@ -104,6 +106,7 @@ class TestSourcesPageRendering:
assert "Sources for Job" in response.text
assert "Back to Job" 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):
_, client = app_client
@@ -131,6 +134,7 @@ class TestSourcesPageRendering:
assert response.status_code == 200
assert "Source detail-source.png" in response.text
assert "Back to Sources" in response.text
assert "Transcription text" in response.text
assert "original transcription text" in response.text
assert "Revision text" in response.text