generated from john/python-template
UI slog continues
This commit is contained in:
@@ -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.",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user