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__)
|
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.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user