diff --git a/src/transcription/services/store.py b/src/transcription/services/store.py index 72c6ad1..f75f1e8 100644 --- a/src/transcription/services/store.py +++ b/src/transcription/services/store.py @@ -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.", ) diff --git a/src/transcription/ui/pages/documents_page.py b/src/transcription/ui/pages/documents_page.py index e7f9eb2..579d124 100644 --- a/src/transcription/ui/pages/documents_page.py +++ b/src/transcription/ui/pages/documents_page.py @@ -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 ] diff --git a/src/transcription/ui/pages/people_page.py b/src/transcription/ui/pages/people_page.py index ba91778..5faabc8 100644 --- a/src/transcription/ui/pages/people_page.py +++ b/src/transcription/ui/pages/people_page.py @@ -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() diff --git a/src/transcription/ui/pages/sources_page.py b/src/transcription/ui/pages/sources_page.py index e356b1c..787f920 100644 --- a/src/transcription/ui/pages/sources_page.py +++ b/src/transcription/ui/pages/sources_page.py @@ -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.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") + 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"): diff --git a/tests/ui/test_documents_page.py b/tests/ui/test_documents_page.py index e4d68b3..b9ceea4 100644 --- a/tests/ui/test_documents_page.py +++ b/tests/ui/test_documents_page.py @@ -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): diff --git a/tests/ui/test_people_page.py b/tests/ui/test_people_page.py index b74e3ed..0f5e0b8 100644 --- a/tests/ui/test_people_page.py +++ b/tests/ui/test_people_page.py @@ -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 diff --git a/tests/ui/test_sources_page.py b/tests/ui/test_sources_page.py index 0b3b48d..bdc5daf 100644 --- a/tests/ui/test_sources_page.py +++ b/tests/ui/test_sources_page.py @@ -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