generated from john/python-template
V4.6 Phase 5: UI boundaries and duplication
Fixes the three ui.instructions.md violations recorded as [HIGH-07] and extracts the page-level duplication catalogued in review section 4. Boundary violations - jobs_page no longer imports session_scope or manages a transaction. store.create_document_job and store.create_job_for_document accept an optional session_factory and open their own session scope when the caller supplies neither a session nor a factory. - sources_page no longer calls sqlalchemy.inspect. SourceService .read_latest_execution_attempt now returns a LatestExecutionAttempt read model carrying a plain transport_body_deferred flag, so ORM loader state stays inside the service. Rendered output is unchanged. - Deletes ui/components/document_panzoom.py, its export, and its CSS. The component was exported but used by no page. Pan-zoom is planned for a clean reintroduction in V4.7 alongside the other photo/image work. Extracted duplication - ui/components/media_urls.py: pure upload-URL resolution taking upload_dir and base_url, replacing two identical ~60-line copies in sources_page and people_page. - ui/components/guards.py: parse-then-render-terminal-message, replacing 28 hand-written guard labels across five pages. - ui/components/confirm_delete.py: the blocked-dependency notice and the delete/cancel action row, from four delete pages. - ui/components/upload_panel.py: the auto-uploading file picker, from three pages. Source accept lists now derive from services.source_media .SOURCE_EXTENSIONS instead of being hard-coded. - ui/components/table/registry.py: the two hand-rolled label-registry tables on the settings page now go through build_table, which gained selection and rows_per_page options. - ui/components/formatters.py gains parse_uuid and parse_iso_date, replacing five and two private copies. - ui/runtime.py owns resolve_runtime_settings, replacing three copies and removing get_settings from every page module. [LOW-05] - Upload handlers are annotated with events.UploadEventArguments. - The Document and Person form builders return DocumentFormFields and PersonFormFields dataclasses instead of dict[str, Any]. Verification - tests/test_ui_boundaries.py asserts no page imports a session scope, a session factory, get_settings, sqlalchemy, or sqlmodel, and that no component imports request or application state. - 275 passed, 4 skipped. ruff check clean. Findings: HIGH-07, LOW-05 Co-authored-by: Copilot App <[email protected]>
This commit is contained in:
@@ -24,6 +24,7 @@ from pydantic import JsonValue
|
|||||||
from pydantic import TypeAdapter
|
from pydantic import TypeAdapter
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy import inspect as sqlalchemy_inspect
|
||||||
from sqlalchemy import tuple_
|
from sqlalchemy import tuple_
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
from sqlalchemy.orm import defer
|
from sqlalchemy.orm import defer
|
||||||
@@ -124,6 +125,14 @@ class ProviderInput:
|
|||||||
transformation: str | None = None
|
transformation: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class LatestExecutionAttempt:
|
||||||
|
"""One execution attempt plus the loader facts a caller needs to render it."""
|
||||||
|
|
||||||
|
attempt: ExecutionAttempt
|
||||||
|
transport_body_deferred: bool
|
||||||
|
|
||||||
|
|
||||||
class SourceService(ServiceBase):
|
class SourceService(ServiceBase):
|
||||||
"""Manage source records, media payloads, revisions, and page execution output."""
|
"""Manage source records, media payloads, revisions, and page execution output."""
|
||||||
|
|
||||||
@@ -207,8 +216,13 @@ class SourceService(ServiceBase):
|
|||||||
*,
|
*,
|
||||||
job_source_id: UUID,
|
job_source_id: UUID,
|
||||||
session: AsyncSession | None = None,
|
session: AsyncSession | None = None,
|
||||||
) -> ExecutionAttempt | None:
|
) -> LatestExecutionAttempt | None:
|
||||||
"""Read only the latest immutable attempt for one compatibility projection."""
|
"""Read only the latest immutable attempt for one compatibility projection.
|
||||||
|
|
||||||
|
The transport body is deferred because it can be arbitrarily large; the
|
||||||
|
returned read model reports that as a plain flag so callers never have to
|
||||||
|
inspect ORM loader state.
|
||||||
|
"""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
query = (
|
query = (
|
||||||
select(ExecutionAttempt)
|
select(ExecutionAttempt)
|
||||||
@@ -220,7 +234,11 @@ class SourceService(ServiceBase):
|
|||||||
)
|
)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
return (await _session.exec(query)).first()
|
attempt = (await _session.exec(query)).first()
|
||||||
|
if attempt is None:
|
||||||
|
return None
|
||||||
|
deferred = "transport_body" in sqlalchemy_inspect(attempt).unloaded
|
||||||
|
return LatestExecutionAttempt(attempt=attempt, transport_body_deferred=deferred)
|
||||||
|
|
||||||
async def read_source_navigation(
|
async def read_source_navigation(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ from ..db.models import Job
|
|||||||
from ..db.models import JobSource
|
from ..db.models import JobSource
|
||||||
from ..db.models import JobSourceStatus
|
from ..db.models import JobSourceStatus
|
||||||
from ..db.models import Source
|
from ..db.models import Source
|
||||||
|
from ..db.session import SessionFactory
|
||||||
|
from ..db.session import session_scope
|
||||||
from .media_storage import build_stored_filename
|
from .media_storage import build_stored_filename
|
||||||
from .media_storage import write_media_bytes
|
from .media_storage import write_media_bytes
|
||||||
from .sources import TranscriptionError
|
from .sources import TranscriptionError
|
||||||
@@ -68,10 +70,15 @@ async def create_document_job(
|
|||||||
*,
|
*,
|
||||||
filename: str,
|
filename: str,
|
||||||
file_bytes: bytes,
|
file_bytes: bytes,
|
||||||
session: AsyncSession,
|
session: AsyncSession | None = None,
|
||||||
|
session_factory: SessionFactory | None = None,
|
||||||
settings: Settings | None = None,
|
settings: Settings | None = None,
|
||||||
) -> DocumentJobResult:
|
) -> DocumentJobResult:
|
||||||
"""Create a Document, its first Source, and a queued Job."""
|
"""Create a Document, its first Source, and a queued Job.
|
||||||
|
|
||||||
|
Owns its own session when the caller does not supply one, so UI callers
|
||||||
|
never have to import a session scope.
|
||||||
|
"""
|
||||||
runtime_settings = settings or get_settings()
|
runtime_settings = settings or get_settings()
|
||||||
prompt_execution = build_prompt_execution(settings=runtime_settings)
|
prompt_execution = build_prompt_execution(settings=runtime_settings)
|
||||||
document_id = uuid4()
|
document_id = uuid4()
|
||||||
@@ -85,8 +92,13 @@ async def create_document_job(
|
|||||||
)
|
)
|
||||||
file_hash, file_size_bytes = _compute_file_metadata(file_bytes)
|
file_hash, file_size_bytes = _compute_file_metadata(file_bytes)
|
||||||
try:
|
try:
|
||||||
document, job = await _create_document_job_records(
|
async with session_scope(
|
||||||
|
session_factory=session_factory,
|
||||||
session=session,
|
session=session,
|
||||||
|
settings=runtime_settings,
|
||||||
|
) as _session:
|
||||||
|
document, job = await _create_document_job_records(
|
||||||
|
session=_session,
|
||||||
document_id=document_id,
|
document_id=document_id,
|
||||||
source_id=source_id,
|
source_id=source_id,
|
||||||
original_filename=filename,
|
original_filename=filename,
|
||||||
@@ -117,12 +129,17 @@ async def create_job_for_document(
|
|||||||
*,
|
*,
|
||||||
document_id: UUID,
|
document_id: UUID,
|
||||||
source_files: Sequence[tuple[str, bytes]],
|
source_files: Sequence[tuple[str, bytes]],
|
||||||
session: AsyncSession,
|
session: AsyncSession | None = None,
|
||||||
|
session_factory: SessionFactory | None = None,
|
||||||
provider: str | None = None,
|
provider: str | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
settings: Settings | None = None,
|
settings: Settings | None = None,
|
||||||
) -> JobCreateResult:
|
) -> JobCreateResult:
|
||||||
"""Create a queued Job for an existing Document with one or more Sources."""
|
"""Create a queued Job for an existing Document with one or more Sources.
|
||||||
|
|
||||||
|
Owns its own session when the caller does not supply one, so UI callers
|
||||||
|
never have to import a session scope.
|
||||||
|
"""
|
||||||
if not source_files:
|
if not source_files:
|
||||||
raise SourceStorageError(
|
raise SourceStorageError(
|
||||||
"At least one Source file is required to create a Job",
|
"At least one Source file is required to create a Job",
|
||||||
@@ -154,8 +171,13 @@ async def create_job_for_document(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
job, source_ids = await _create_job_for_document_records(
|
async with session_scope(
|
||||||
|
session_factory=session_factory,
|
||||||
session=session,
|
session=session,
|
||||||
|
settings=runtime_settings,
|
||||||
|
) as _session:
|
||||||
|
job, source_ids = await _create_job_for_document_records(
|
||||||
|
session=_session,
|
||||||
document_id=document_id,
|
document_id=document_id,
|
||||||
stored_sources=stored_sources,
|
stored_sources=stored_sources,
|
||||||
provider=provider,
|
provider=provider,
|
||||||
|
|||||||
@@ -560,7 +560,7 @@ async def _write_page_outcome(
|
|||||||
warnings = analyze_transcription_quality(result.text)
|
warnings = analyze_transcription_quality(result.text)
|
||||||
await services.sources.create_json_artifact(
|
await services.sources.create_json_artifact(
|
||||||
source_id=source.id,
|
source_id=source.id,
|
||||||
execution_attempt_id=attempt.id,
|
execution_attempt_id=attempt.attempt.id,
|
||||||
artifact_type="transcription_quality_warnings",
|
artifact_type="transcription_quality_warnings",
|
||||||
schema_name=QUALITY_ANALYSIS_SCHEMA,
|
schema_name=QUALITY_ANALYSIS_SCHEMA,
|
||||||
schema_version=QUALITY_ANALYSIS_VERSION,
|
schema_version=QUALITY_ANALYSIS_VERSION,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
from transcription.ui.components.app_shell import NAV_ITEMS
|
from transcription.ui.components.app_shell import NAV_ITEMS
|
||||||
from transcription.ui.components.app_shell import render_app_shell
|
from transcription.ui.components.app_shell import render_app_shell
|
||||||
from transcription.ui.components.app_shell import render_navigation_header
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
from transcription.ui.components.document_panzoom import render_document_panzoom
|
|
||||||
from transcription.ui.components.primitives import destructive_button
|
from transcription.ui.components.primitives import destructive_button
|
||||||
from transcription.ui.components.primitives import render_empty_state
|
from transcription.ui.components.primitives import render_empty_state
|
||||||
from transcription.ui.components.primitives import section_header_row
|
from transcription.ui.components.primitives import section_header_row
|
||||||
@@ -12,7 +11,6 @@ __all__ = [
|
|||||||
"NAV_ITEMS",
|
"NAV_ITEMS",
|
||||||
"destructive_button",
|
"destructive_button",
|
||||||
"render_app_shell",
|
"render_app_shell",
|
||||||
"render_document_panzoom",
|
|
||||||
"render_empty_state",
|
"render_empty_state",
|
||||||
"render_navigation_header",
|
"render_navigation_header",
|
||||||
"section_header_row",
|
"section_header_row",
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Shared rendering for the destructive-confirmation pages."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable
|
||||||
|
from collections.abc import Callable
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.ui.components.primitives import destructive_button
|
||||||
|
|
||||||
|
|
||||||
|
def render_delete_blocked_notice(
|
||||||
|
*,
|
||||||
|
reason: str,
|
||||||
|
guidance: str,
|
||||||
|
detail: str | None = None,
|
||||||
|
back_label: str,
|
||||||
|
back_target: str,
|
||||||
|
secondary_label: str = "Go to Jobs",
|
||||||
|
secondary_target: str = "/jobs",
|
||||||
|
secondary_icon: str = "work_history",
|
||||||
|
) -> None:
|
||||||
|
"""Render the blocked-dependency notice plus its two navigation actions."""
|
||||||
|
ui.label(reason).classes("text-xs ui-text-danger font-bold mt-2")
|
||||||
|
if detail is not None:
|
||||||
|
ui.label(detail).classes("text-xs ui-text-muted")
|
||||||
|
ui.label(guidance).classes("text-xs ui-text-muted italic")
|
||||||
|
with ui.row().classes("w-full items-center gap-2 mt-4"):
|
||||||
|
ui.button(back_label, on_click=lambda: ui.navigate.to(back_target), icon="arrow_back").classes(
|
||||||
|
"ui-btn-primary text-xs"
|
||||||
|
)
|
||||||
|
ui.button(
|
||||||
|
secondary_label,
|
||||||
|
on_click=lambda: ui.navigate.to(secondary_target),
|
||||||
|
icon=secondary_icon,
|
||||||
|
).props("flat text-xs")
|
||||||
|
|
||||||
|
|
||||||
|
def render_delete_actions(
|
||||||
|
*,
|
||||||
|
confirm_label: str,
|
||||||
|
on_confirm: Callable[[], Awaitable[None]],
|
||||||
|
cancel_target: str,
|
||||||
|
) -> None:
|
||||||
|
"""Render the permanent-delete button beside a cancel action."""
|
||||||
|
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||||
|
destructive_button(confirm_label, on_click=on_confirm, icon="delete_forever", variant="solid")
|
||||||
|
ui.button("Cancel", on_click=lambda: ui.navigate.to(cancel_target), icon="arrow_back").props("flat")
|
||||||
|
|
||||||
|
|
||||||
|
def dependency_summary(categories: Sequence[tuple[str, bool]]) -> str:
|
||||||
|
"""Describe which related record categories are blocking a delete."""
|
||||||
|
present = [name for name, is_present in categories if is_present]
|
||||||
|
return f"Dependencies present: {', '.join(present)}"
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
"""Panzoom-backed document preview component."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from functools import lru_cache
|
|
||||||
from pathlib import Path
|
|
||||||
from urllib.parse import quote
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from nicegui import ui
|
|
||||||
|
|
||||||
from transcription.config import get_settings
|
|
||||||
from transcription.db.models import Source
|
|
||||||
|
|
||||||
PANGOZOOM_CDN_URL = "https://unpkg.com/@panzoom/[email protected]/dist/panzoom.min.js"
|
|
||||||
UPLOADS_URL_PREFIX = "/uploads"
|
|
||||||
|
|
||||||
|
|
||||||
def render_document_panzoom(*, source: Source) -> None:
|
|
||||||
"""Render a source preview with pan and zoom interactions."""
|
|
||||||
_register_panzoom_assets()
|
|
||||||
|
|
||||||
host_id = f"document-panzoom-{uuid4().hex}"
|
|
||||||
document_url = _document_url(source)
|
|
||||||
document_kind = _document_kind(source)
|
|
||||||
|
|
||||||
with ui.card().classes("w-full q-pa-md ui-card-surface"):
|
|
||||||
with ui.row().classes("w-full items-center justify-between no-wrap"):
|
|
||||||
ui.label("Document preview").classes("text-subtitle1 text-weight-medium")
|
|
||||||
ui.label(source.filename).classes("text-caption ui-text-muted ellipsis document-panzoom-filename")
|
|
||||||
|
|
||||||
with ui.element("div").classes("w-full document-panzoom-host q-mt-md") as host:
|
|
||||||
host.props(f"id={host_id}")
|
|
||||||
with ui.element("div").classes("document-panzoom-surface"):
|
|
||||||
if document_kind == "pdf":
|
|
||||||
ui.html(
|
|
||||||
f'<iframe class="document-panzoom-iframe" '
|
|
||||||
f'src="{document_url}" title="{source.filename}" '
|
|
||||||
"data-panzoom-target></iframe>"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
ui.html(
|
|
||||||
f'<img class="document-panzoom-media" '
|
|
||||||
f'src="{document_url}" alt="{source.filename}" '
|
|
||||||
"data-panzoom-target data-panzoom-media />"
|
|
||||||
)
|
|
||||||
|
|
||||||
_attach_panzoom(host_id)
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
|
||||||
def _register_panzoom_assets() -> None:
|
|
||||||
ui.add_head_html(
|
|
||||||
f'<script src="{PANGOZOOM_CDN_URL}"></script>',
|
|
||||||
shared=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _document_url(source: Source) -> str:
|
|
||||||
file_path = Path(source.file_path)
|
|
||||||
upload_dir = get_settings().upload_dir
|
|
||||||
|
|
||||||
relative_path: Path
|
|
||||||
try:
|
|
||||||
relative_path = file_path.resolve().relative_to(upload_dir.resolve())
|
|
||||||
except ValueError:
|
|
||||||
parts = file_path.parts
|
|
||||||
if "uploads" in parts:
|
|
||||||
uploads_index = parts.index("uploads")
|
|
||||||
relative_path = Path(*parts[uploads_index + 1 :])
|
|
||||||
else:
|
|
||||||
relative_path = Path(file_path.name)
|
|
||||||
|
|
||||||
encoded_relative_path = "/".join(quote(part) for part in relative_path.parts)
|
|
||||||
return f"{UPLOADS_URL_PREFIX}/{encoded_relative_path}"
|
|
||||||
|
|
||||||
|
|
||||||
def _document_kind(source: Source) -> str:
|
|
||||||
suffix = Path(source.file_path).suffix.lower()
|
|
||||||
if suffix == ".pdf":
|
|
||||||
return "pdf"
|
|
||||||
return "image"
|
|
||||||
|
|
||||||
|
|
||||||
def _attach_panzoom(host_id: str) -> None:
|
|
||||||
ui.run_javascript(
|
|
||||||
f"""
|
|
||||||
(function() {{
|
|
||||||
if (!window.Panzoom) return;
|
|
||||||
window.__transcriptionPanzoom = window.__transcriptionPanzoom || {{}};
|
|
||||||
const host = document.getElementById({host_id!r});
|
|
||||||
if (!host) return;
|
|
||||||
const target = host.querySelector('[data-panzoom-target]');
|
|
||||||
const media = host.querySelector('[data-panzoom-media]');
|
|
||||||
if (!target) return;
|
|
||||||
|
|
||||||
const cleanup = () => {{
|
|
||||||
const existing = window.__transcriptionPanzoom[{host_id!r}];
|
|
||||||
if (existing?.resizeObserver) existing.resizeObserver.disconnect();
|
|
||||||
if (existing?.wheelHandler) host.removeEventListener('wheel', existing.wheelHandler);
|
|
||||||
if (existing?.instance) existing.instance.destroy();
|
|
||||||
}};
|
|
||||||
|
|
||||||
const computeFitScale = () => {{
|
|
||||||
const hostRect = host.getBoundingClientRect();
|
|
||||||
if (hostRect.width <= 0 || hostRect.height <= 0) return null;
|
|
||||||
return 1;
|
|
||||||
}};
|
|
||||||
|
|
||||||
const buildInstance = () => {{
|
|
||||||
cleanup();
|
|
||||||
|
|
||||||
const fitScale = computeFitScale();
|
|
||||||
if (fitScale === null) return false;
|
|
||||||
|
|
||||||
const minScale = Math.min(fitScale, 0.01);
|
|
||||||
const instance = Panzoom(target, {{
|
|
||||||
startX: 0,
|
|
||||||
startY: 0,
|
|
||||||
startScale: fitScale,
|
|
||||||
minScale: minScale,
|
|
||||||
maxScale: 256,
|
|
||||||
step: 0.2,
|
|
||||||
roundPixels: false,
|
|
||||||
panOnlyWhenZoomed: true,
|
|
||||||
overflow: 'hidden',
|
|
||||||
}});
|
|
||||||
|
|
||||||
const wheelHandler = (event) => instance.zoomWithWheel(event);
|
|
||||||
host.addEventListener('wheel', wheelHandler, {{ passive: false }});
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {{
|
|
||||||
instance.reset({{ animate: false }});
|
|
||||||
}});
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {{
|
|
||||||
const nextFitScale = computeFitScale();
|
|
||||||
if (nextFitScale === null) return;
|
|
||||||
instance.setOptions({{
|
|
||||||
startScale: nextFitScale,
|
|
||||||
minScale: Math.min(nextFitScale, 0.01),
|
|
||||||
}});
|
|
||||||
instance.reset({{ animate: false }});
|
|
||||||
}});
|
|
||||||
resizeObserver.observe(host);
|
|
||||||
|
|
||||||
window.__transcriptionPanzoom[{host_id!r}] = {{
|
|
||||||
instance,
|
|
||||||
wheelHandler,
|
|
||||||
resizeObserver,
|
|
||||||
}};
|
|
||||||
return true;
|
|
||||||
}};
|
|
||||||
|
|
||||||
const initWhenReady = (retries = 15) => {{
|
|
||||||
if (buildInstance()) return;
|
|
||||||
if (retries <= 0) return;
|
|
||||||
requestAnimationFrame(() => initWhenReady(retries - 1));
|
|
||||||
}};
|
|
||||||
|
|
||||||
if (media && media.tagName === 'IMG' && !media.complete) {{
|
|
||||||
media.addEventListener('load', () => initWhenReady(), {{ once: true }});
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
|
|
||||||
initWhenReady();
|
|
||||||
}})();
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
@@ -2,12 +2,39 @@
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from transcription.db.models import Person
|
from transcription.db.models import Person
|
||||||
|
|
||||||
YEAR_PATTERN = re.compile(r"\b[12]\d{3}\b")
|
YEAR_PATTERN = re.compile(r"\b[12]\d{3}\b")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_uuid(value: object | None) -> UUID | None:
|
||||||
|
"""Parse a user-supplied identifier, treating anything unusable as absent."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, UUID):
|
||||||
|
return value
|
||||||
|
candidate = str(value).strip()
|
||||||
|
if not candidate:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return UUID(candidate)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_iso_date(value: str | None) -> date | None:
|
||||||
|
"""Parse an ISO date from a form field, treating anything unusable as absent."""
|
||||||
|
candidate = (value or "").strip()
|
||||||
|
if not candidate:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(candidate)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def compact_date(exact: date | None, approximate: str | None) -> str:
|
def compact_date(exact: date | None, approximate: str | None) -> str:
|
||||||
"""Prefer an exact date, then an approximate value, then an unknown marker."""
|
"""Prefer an exact date, then an approximate value, then an unknown marker."""
|
||||||
if exact is not None:
|
if exact is not None:
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Page-entry guards that render a terminal message instead of a record view."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.ui.components.formatters import parse_uuid
|
||||||
|
|
||||||
|
_GUARD_CLASSES = "text-h6 ui-text-danger p-4"
|
||||||
|
|
||||||
|
|
||||||
|
def render_guard_message(message: str) -> None:
|
||||||
|
"""Render the single terminal message shown when a record cannot be displayed."""
|
||||||
|
ui.label(message).classes(_GUARD_CLASSES)
|
||||||
|
|
||||||
|
|
||||||
|
def parsed_record_id(value: str | None, *, noun: str) -> UUID | None:
|
||||||
|
"""Parse a route identifier, rendering the invalid-id message when it is unusable.
|
||||||
|
|
||||||
|
`noun` is the capitalized record name, for example "Document".
|
||||||
|
"""
|
||||||
|
parsed = parse_uuid(value)
|
||||||
|
if parsed is None:
|
||||||
|
render_guard_message(f"Invalid {noun.lower()} id")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def render_record_not_found(noun: str) -> None:
|
||||||
|
"""Render the not-found message for a record that failed to load."""
|
||||||
|
render_guard_message(f"{noun} not found")
|
||||||
@@ -12,6 +12,7 @@ from transcription.db.models import DocumentPerson
|
|||||||
from transcription.db.models import Person
|
from transcription.db.models import Person
|
||||||
from transcription.db.models import PersonRole
|
from transcription.db.models import PersonRole
|
||||||
from transcription.services.people import DocumentPersonInput
|
from transcription.services.people import DocumentPersonInput
|
||||||
|
from transcription.ui.components.formatters import parse_uuid
|
||||||
from transcription.ui.components.formatters import person_selector_label
|
from transcription.ui.components.formatters import person_selector_label
|
||||||
from transcription.ui.components.primitives import destructive_button
|
from transcription.ui.components.primitives import destructive_button
|
||||||
|
|
||||||
@@ -106,8 +107,8 @@ class LinkedPeopleEditor:
|
|||||||
role_input.value = str(current.role_id)
|
role_input.value = str(current.role_id)
|
||||||
|
|
||||||
def save() -> None:
|
def save() -> None:
|
||||||
person_id = self._parse_uuid(person_input.value)
|
person_id = parse_uuid(person_input.value)
|
||||||
role_id = self._parse_uuid(role_input.value)
|
role_id = parse_uuid(role_input.value)
|
||||||
if person_id is None or role_id is None:
|
if person_id is None or role_id is None:
|
||||||
ui.notify("Select both a Person and Person Role.", type="warning")
|
ui.notify("Select both a Person and Person Role.", type="warning")
|
||||||
return
|
return
|
||||||
@@ -171,10 +172,3 @@ class LinkedPeopleEditor:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _role_option_label(role: PersonRole) -> str:
|
def _role_option_label(role: PersonRole) -> str:
|
||||||
return role.label if role.is_active else f"{role.label} (inactive)"
|
return role.label if role.is_active else f"{role.label} (inactive)"
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse_uuid(value: Any) -> UUID | None:
|
|
||||||
try:
|
|
||||||
return UUID(str(value))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Pure resolution of stored media paths into browser-reachable upload URLs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
_ABSOLUTE_SCHEMES = ("http://", "https://", "data:")
|
||||||
|
_UPLOAD_ROUTE_PREFIX = "/uploads/"
|
||||||
|
|
||||||
|
|
||||||
|
def absolute_upload_url(path: str, *, base_url: str) -> str:
|
||||||
|
"""Join an application-relative upload path onto the request base URL."""
|
||||||
|
base = base_url.rstrip("/")
|
||||||
|
normalized_path = path if path.startswith("/") else f"/{path}"
|
||||||
|
return f"{base}{normalized_path}"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_media_url(path: str | None, *, upload_dir: Path, base_url: str) -> str | None:
|
||||||
|
"""Map a stored media path onto a served upload URL.
|
||||||
|
|
||||||
|
Stored paths have accumulated several shapes over the life of the schema:
|
||||||
|
absolute filesystem paths, paths relative to the working directory, paths
|
||||||
|
relative to the upload root, and paths that already carry an upload route.
|
||||||
|
All of them must still resolve, so each shape is tried in turn.
|
||||||
|
"""
|
||||||
|
candidate = (path or "").strip()
|
||||||
|
if not candidate:
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized = candidate.replace("\\", "/")
|
||||||
|
lowered = normalized.casefold()
|
||||||
|
if lowered.startswith(_ABSOLUTE_SCHEMES):
|
||||||
|
return normalized
|
||||||
|
if normalized.startswith(_UPLOAD_ROUTE_PREFIX):
|
||||||
|
return absolute_upload_url(normalized, base_url=base_url)
|
||||||
|
|
||||||
|
resolved_upload_dir = upload_dir.resolve()
|
||||||
|
path_obj = Path(candidate)
|
||||||
|
|
||||||
|
if path_obj.is_absolute():
|
||||||
|
absolute_candidates = [path_obj.resolve()]
|
||||||
|
else:
|
||||||
|
absolute_candidates = [
|
||||||
|
(Path.cwd() / path_obj).resolve(),
|
||||||
|
(resolved_upload_dir / path_obj).resolve(),
|
||||||
|
]
|
||||||
|
|
||||||
|
for absolute_candidate in absolute_candidates:
|
||||||
|
try:
|
||||||
|
relative = absolute_candidate.relative_to(resolved_upload_dir).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return absolute_upload_url(f"/uploads/{quote(relative)}", base_url=base_url)
|
||||||
|
|
||||||
|
upload_name = resolved_upload_dir.name.casefold()
|
||||||
|
normalized_parts = Path(normalized).parts
|
||||||
|
lowered_parts = [part.casefold() for part in normalized_parts]
|
||||||
|
if upload_name in lowered_parts:
|
||||||
|
index = lowered_parts.index(upload_name)
|
||||||
|
relative = Path(*normalized_parts[index + 1 :]).as_posix()
|
||||||
|
if relative:
|
||||||
|
return absolute_upload_url(f"/uploads/{quote(relative)}", base_url=base_url)
|
||||||
|
|
||||||
|
if lowered.startswith("uploads/"):
|
||||||
|
return absolute_upload_url(f"/{normalized}", base_url=base_url)
|
||||||
|
if lowered.startswith("data/"):
|
||||||
|
relative = normalized.split("/", 1)[1] if "/" in normalized else ""
|
||||||
|
if relative:
|
||||||
|
return absolute_upload_url(f"/uploads/{quote(relative)}", base_url=base_url)
|
||||||
|
if lowered.startswith(("documents/", "persons/")):
|
||||||
|
return absolute_upload_url(f"/uploads/{quote(normalized)}", base_url=base_url)
|
||||||
|
|
||||||
|
return absolute_upload_url(f"/uploads/{quote(path_obj.name)}", base_url=base_url)
|
||||||
@@ -48,9 +48,11 @@ def build_table(
|
|||||||
show_search: bool = True,
|
show_search: bool = True,
|
||||||
search_placeholder: str = "Search records...",
|
search_placeholder: str = "Search records...",
|
||||||
on_row_click_id: Callable[[str], None] | None = None,
|
on_row_click_id: Callable[[str], None] | None = None,
|
||||||
|
selection: str | None = None,
|
||||||
|
rows_per_page: int = 25,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Build a styled Quasar table widget with optional client-side filtering and row-click handlers."""
|
"""Build a styled Quasar table widget with optional client-side filtering and row-click handlers."""
|
||||||
pagination: dict[str, Any] = {"rowsPerPage": 25}
|
pagination: dict[str, Any] = {"rowsPerPage": rows_per_page}
|
||||||
if default_sort_by is not None:
|
if default_sort_by is not None:
|
||||||
pagination["sortBy"] = default_sort_by
|
pagination["sortBy"] = default_sort_by
|
||||||
pagination["descending"] = default_descending
|
pagination["descending"] = default_descending
|
||||||
@@ -65,13 +67,17 @@ def build_table(
|
|||||||
.classes("w-64 text-xs ui-form-surface")
|
.classes("w-64 text-xs ui-form-surface")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
table_kwargs: dict[str, Any] = {
|
||||||
|
"rows": rows,
|
||||||
|
"columns": columns,
|
||||||
|
"row_key": "id",
|
||||||
|
"pagination": pagination,
|
||||||
|
}
|
||||||
|
if selection is not None:
|
||||||
|
table_kwargs["selection"] = selection
|
||||||
|
|
||||||
table = (
|
table = (
|
||||||
ui.table(
|
ui.table(**table_kwargs)
|
||||||
rows=rows,
|
|
||||||
columns=columns,
|
|
||||||
row_key="id",
|
|
||||||
pagination=pagination,
|
|
||||||
)
|
|
||||||
.classes(f"w-full ui-table {classes}".strip())
|
.classes(f"w-full ui-table {classes}".strip())
|
||||||
.props(
|
.props(
|
||||||
'flat square binary-state-sort table-style="table-layout: fixed; width: 100%;" '
|
'flat square binary-state-sort table-style="table-layout: fixed; width: 100%;" '
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Table rendering for the label registries edited on the settings page."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from transcription.ui.components.table.common import build_table
|
||||||
|
|
||||||
|
_ACTIVE_CELL = """
|
||||||
|
<q-td :props="props" class="text-center">
|
||||||
|
<q-checkbox :model-value="props.value" disable dense />
|
||||||
|
</q-td>
|
||||||
|
"""
|
||||||
|
_BUILT_IN_CELL = """
|
||||||
|
<q-td :props="props" class="text-center">
|
||||||
|
{{ props.value ? 'Yes' : 'No' }}
|
||||||
|
</q-td>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def render_registry_table(
|
||||||
|
rows: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
count_field: str,
|
||||||
|
count_label: str,
|
||||||
|
) -> Any:
|
||||||
|
"""Render one label registry with its usage count and read-only status flags."""
|
||||||
|
table = build_table(
|
||||||
|
rows,
|
||||||
|
[
|
||||||
|
{"name": "label", "label": "Label", "field": "label", "align": "left", "sortable": True},
|
||||||
|
{
|
||||||
|
"name": count_field,
|
||||||
|
"label": count_label,
|
||||||
|
"field": count_field,
|
||||||
|
"align": "right",
|
||||||
|
"sortable": True,
|
||||||
|
},
|
||||||
|
{"name": "is_active", "label": "Active", "field": "is_active", "align": "center"},
|
||||||
|
{"name": "is_built_in", "label": "Built-in", "field": "is_built_in", "align": "center"},
|
||||||
|
],
|
||||||
|
default_sort_by="label",
|
||||||
|
show_search=False,
|
||||||
|
selection="single",
|
||||||
|
rows_per_page=0,
|
||||||
|
)
|
||||||
|
table.add_slot("body-cell-is_active", _ACTIVE_CELL)
|
||||||
|
table.add_slot("body-cell-is_built_in", _BUILT_IN_CELL)
|
||||||
|
return table
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Shared file-picker wiring for the upload surfaces."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable
|
||||||
|
from collections.abc import Callable
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.services.source_media import SOURCE_EXTENSIONS
|
||||||
|
|
||||||
|
SOURCE_UPLOAD_EXTENSIONS: tuple[str, ...] = tuple(sorted(SOURCE_EXTENSIONS))
|
||||||
|
IMAGE_UPLOAD_EXTENSIONS: tuple[str, ...] = (
|
||||||
|
".bmp",
|
||||||
|
".gif",
|
||||||
|
".jpeg",
|
||||||
|
".jpg",
|
||||||
|
".png",
|
||||||
|
".tif",
|
||||||
|
".tiff",
|
||||||
|
".webp",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def accept_attribute(extensions: Iterable[str]) -> str:
|
||||||
|
"""Build the HTML ``accept`` attribute value for a set of extensions."""
|
||||||
|
return f'accept="{",".join(extensions)}"'
|
||||||
|
|
||||||
|
|
||||||
|
def render_upload_picker(
|
||||||
|
*,
|
||||||
|
on_upload: Callable[[Any], Awaitable[None]],
|
||||||
|
label: str,
|
||||||
|
extensions: Iterable[str],
|
||||||
|
directory: bool = False,
|
||||||
|
multiple: bool = False,
|
||||||
|
) -> ui.upload:
|
||||||
|
"""Render the standard auto-uploading file picker used across pages."""
|
||||||
|
props = [accept_attribute(extensions)]
|
||||||
|
if directory:
|
||||||
|
props.append("webkitdirectory directory")
|
||||||
|
if multiple:
|
||||||
|
props.append("multiple")
|
||||||
|
return (
|
||||||
|
ui.upload(on_upload=on_upload, auto_upload=True, label=label)
|
||||||
|
.props(" ".join(props))
|
||||||
|
.classes("w-full")
|
||||||
|
)
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -21,10 +21,17 @@ from transcription.services.workflows import create_document_with_people
|
|||||||
from transcription.services.workflows import update_document_with_people
|
from transcription.services.workflows import update_document_with_people
|
||||||
from transcription.ui.components.app_shell import render_navigation_header
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
from transcription.ui.components.cards import archival_card
|
from transcription.ui.components.cards import archival_card
|
||||||
|
from transcription.ui.components.confirm_delete import dependency_summary
|
||||||
|
from transcription.ui.components.confirm_delete import render_delete_actions
|
||||||
|
from transcription.ui.components.confirm_delete import render_delete_blocked_notice
|
||||||
from transcription.ui.components.data_display import archival_badge
|
from transcription.ui.components.data_display import archival_badge
|
||||||
from transcription.ui.components.data_display import metadata_row
|
from transcription.ui.components.data_display import metadata_row
|
||||||
from transcription.ui.components.error_presenter import show_error
|
from transcription.ui.components.error_presenter import show_error
|
||||||
from transcription.ui.components.formatters import compact_date
|
from transcription.ui.components.formatters import compact_date
|
||||||
|
from transcription.ui.components.formatters import parse_iso_date
|
||||||
|
from transcription.ui.components.formatters import parse_uuid
|
||||||
|
from transcription.ui.components.guards import parsed_record_id
|
||||||
|
from transcription.ui.components.guards import render_record_not_found
|
||||||
from transcription.ui.components.linked_people import LinkedPeopleEditor
|
from transcription.ui.components.linked_people import LinkedPeopleEditor
|
||||||
from transcription.ui.components.linked_people import StagedLinkedPerson
|
from transcription.ui.components.linked_people import StagedLinkedPerson
|
||||||
from transcription.ui.components.primitives import destructive_button
|
from transcription.ui.components.primitives import destructive_button
|
||||||
@@ -38,6 +45,20 @@ from transcription.ui.theme import page_header
|
|||||||
from ...db.session import SessionFactoryDep
|
from ...db.session import SessionFactoryDep
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DocumentFormFields:
|
||||||
|
"""Bound input widgets for the Document create and edit forms."""
|
||||||
|
|
||||||
|
name: ui.input
|
||||||
|
document_type: ui.select
|
||||||
|
type_options: dict[str, str]
|
||||||
|
document_date: ui.input
|
||||||
|
document_date_raw: ui.input
|
||||||
|
location: ui.input
|
||||||
|
archive: ui.input
|
||||||
|
notes: ui.textarea
|
||||||
|
|
||||||
|
|
||||||
def register_page() -> None: # noqa: PLR0915
|
def register_page() -> None: # noqa: PLR0915
|
||||||
"""Register documents list and detail routes."""
|
"""Register documents list and detail routes."""
|
||||||
|
|
||||||
@@ -53,7 +74,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
people = sorted(await people_service.list_people(), key=lambda item: item.full_name.casefold())
|
people = sorted(await people_service.list_people(), key=lambda item: item.full_name.casefold())
|
||||||
role_catalog = list(await people_service.list_person_roles(active_only=False))
|
role_catalog = list(await people_service.list_person_roles(active_only=False))
|
||||||
type_catalog = await document_service.list_document_types()
|
type_catalog = await document_service.list_document_types()
|
||||||
requested_person_id = _parse_uuid(request.query_params.get("person_id"))
|
requested_person_id = parse_uuid(request.query_params.get("person_id"))
|
||||||
staged_links: list[StagedLinkedPerson] = []
|
staged_links: list[StagedLinkedPerson] = []
|
||||||
if requested_person_id is not None and any(person.id == requested_person_id for person in people):
|
if requested_person_id is not None and any(person.id == requested_person_id for person in people):
|
||||||
try:
|
try:
|
||||||
@@ -82,8 +103,8 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
return_to = request.query_params.get("return_to")
|
return_to = request.query_params.get("return_to")
|
||||||
|
|
||||||
async def submit_create() -> None:
|
async def submit_create() -> None:
|
||||||
candidate_name = (form["name"].value or "").strip()
|
candidate_name = (form.name.value or "").strip()
|
||||||
candidate_type_id = _resolve_selected_document_type_id(form["type"].value, form["type_options"])
|
candidate_type_id = _resolve_selected_document_type_id(form.document_type.value, form.type_options)
|
||||||
if not candidate_name:
|
if not candidate_name:
|
||||||
ui.notify("Document name is required.", type="warning")
|
ui.notify("Document name is required.", type="warning")
|
||||||
return
|
return
|
||||||
@@ -91,8 +112,8 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
ui.notify("Document type is required.", type="warning")
|
ui.notify("Document type is required.", type="warning")
|
||||||
return
|
return
|
||||||
|
|
||||||
parsed_date = _parse_iso_date(form["date"].value)
|
parsed_date = parse_iso_date(form.document_date.value)
|
||||||
if form["date"].value and parsed_date is None:
|
if form.document_date.value and parsed_date is None:
|
||||||
ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
|
ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -100,10 +121,10 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
name=candidate_name,
|
name=candidate_name,
|
||||||
document_type_id=candidate_type_id,
|
document_type_id=candidate_type_id,
|
||||||
document_date=parsed_date,
|
document_date=parsed_date,
|
||||||
document_date_raw=(form["date_raw"].value or "").strip() or None,
|
document_date_raw=(form.document_date_raw.value or "").strip() or None,
|
||||||
location_created=(form["location"].value or "").strip() or None,
|
location_created=(form.location.value or "").strip() or None,
|
||||||
notes=(form["notes"].value or "").strip() or None,
|
notes=(form.notes.value or "").strip() or None,
|
||||||
archive_identifier=(form["archive"].value or "").strip() or None,
|
archive_identifier=(form.archive.value or "").strip() or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -169,15 +190,14 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
document_service = DocumentService(session_factory=session_factory)
|
document_service = DocumentService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/documents")
|
render_navigation_header(current_path="/documents")
|
||||||
|
|
||||||
parsed_doc_id = _parse_uuid(document_id)
|
parsed_doc_id = parsed_record_id(document_id, noun="Document")
|
||||||
if parsed_doc_id is None:
|
if parsed_doc_id is None:
|
||||||
ui.label("Invalid document id").classes("text-h6 ui-text-danger p-4")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
document = await document_service.read_document_detail(document_id=parsed_doc_id)
|
document = await document_service.read_document_detail(document_id=parsed_doc_id)
|
||||||
except DocumentError:
|
except DocumentError:
|
||||||
ui.label("Document not found").classes("text-h6 ui-text-danger p-4")
|
render_record_not_found("Document")
|
||||||
return
|
return
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
show_error(exc, title="Load failed", operation="documents.read")
|
show_error(exc, title="Load failed", operation="documents.read")
|
||||||
@@ -216,15 +236,14 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
document_service = DocumentService(session_factory=session_factory)
|
document_service = DocumentService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/documents")
|
render_navigation_header(current_path="/documents")
|
||||||
|
|
||||||
parsed_doc_id = _parse_uuid(document_id)
|
parsed_doc_id = parsed_record_id(document_id, noun="Document")
|
||||||
if parsed_doc_id is None:
|
if parsed_doc_id is None:
|
||||||
ui.label("Invalid document id").classes("text-h6 ui-text-danger p-4")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
document = await document_service.read_document_detail(document_id=parsed_doc_id)
|
document = await document_service.read_document_detail(document_id=parsed_doc_id)
|
||||||
except DocumentError:
|
except DocumentError:
|
||||||
ui.label("Document not found").classes("text-h6 ui-text-danger p-4")
|
render_record_not_found("Document")
|
||||||
return
|
return
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
show_error(exc, title="Load failed", operation="documents.jobs")
|
show_error(exc, title="Load failed", operation="documents.jobs")
|
||||||
@@ -272,15 +291,14 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
people_service = PeopleService(session_factory=session_factory)
|
people_service = PeopleService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/documents")
|
render_navigation_header(current_path="/documents")
|
||||||
|
|
||||||
parsed_doc_id = _parse_uuid(document_id)
|
parsed_doc_id = parsed_record_id(document_id, noun="Document")
|
||||||
if parsed_doc_id is None:
|
if parsed_doc_id is None:
|
||||||
ui.label("Invalid document id").classes("text-h6 ui-text-danger p-4")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
document = await document_service.read_document_detail(document_id=parsed_doc_id)
|
document = await document_service.read_document_detail(document_id=parsed_doc_id)
|
||||||
except DocumentError:
|
except DocumentError:
|
||||||
ui.label("Document not found").classes("text-h6 ui-text-danger p-4")
|
render_record_not_found("Document")
|
||||||
return
|
return
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
show_error(exc, title="Load failed", operation="documents.edit.read")
|
show_error(exc, title="Load failed", operation="documents.edit.read")
|
||||||
@@ -304,8 +322,8 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def submit_edit() -> None:
|
async def submit_edit() -> None:
|
||||||
candidate_name = (form["name"].value or "").strip()
|
candidate_name = (form.name.value or "").strip()
|
||||||
candidate_type_id = _resolve_selected_document_type_id(form["type"].value, form["type_options"])
|
candidate_type_id = _resolve_selected_document_type_id(form.document_type.value, form.type_options)
|
||||||
if not candidate_name:
|
if not candidate_name:
|
||||||
ui.notify("Document name is required.", type="warning")
|
ui.notify("Document name is required.", type="warning")
|
||||||
return
|
return
|
||||||
@@ -313,8 +331,8 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
ui.notify("Document type is required.", type="warning")
|
ui.notify("Document type is required.", type="warning")
|
||||||
return
|
return
|
||||||
|
|
||||||
parsed_date = _parse_iso_date(form["date"].value)
|
parsed_date = parse_iso_date(form.document_date.value)
|
||||||
if form["date"].value and parsed_date is None:
|
if form.document_date.value and parsed_date is None:
|
||||||
ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
|
ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -323,10 +341,10 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
name=candidate_name,
|
name=candidate_name,
|
||||||
document_type_id=candidate_type_id,
|
document_type_id=candidate_type_id,
|
||||||
document_date=parsed_date,
|
document_date=parsed_date,
|
||||||
document_date_raw=(form["date_raw"].value or "").strip() or None,
|
document_date_raw=(form.document_date_raw.value or "").strip() or None,
|
||||||
location_created=(form["location"].value or "").strip() or None,
|
location_created=(form.location.value or "").strip() or None,
|
||||||
notes=(form["notes"].value or "").strip() or None,
|
notes=(form.notes.value or "").strip() or None,
|
||||||
archive_identifier=(form["archive"].value or "").strip() or None,
|
archive_identifier=(form.archive.value or "").strip() or None,
|
||||||
created_at=document.created_at,
|
created_at=document.created_at,
|
||||||
updated_at=document.updated_at,
|
updated_at=document.updated_at,
|
||||||
)
|
)
|
||||||
@@ -356,15 +374,14 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
document_service = DocumentService(session_factory=session_factory)
|
document_service = DocumentService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/documents")
|
render_navigation_header(current_path="/documents")
|
||||||
|
|
||||||
parsed_doc_id = _parse_uuid(document_id)
|
parsed_doc_id = parsed_record_id(document_id, noun="Document")
|
||||||
if parsed_doc_id is None:
|
if parsed_doc_id is None:
|
||||||
ui.label("Invalid document id").classes("text-h6 ui-text-danger p-4")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
document = await document_service.read_document_detail(document_id=parsed_doc_id)
|
document = await document_service.read_document_detail(document_id=parsed_doc_id)
|
||||||
except DocumentError:
|
except DocumentError:
|
||||||
ui.label("Document not found").classes("text-h6 ui-text-danger p-4")
|
render_record_not_found("Document")
|
||||||
return
|
return
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
show_error(exc, title="Load failed", operation="documents.delete.read")
|
show_error(exc, title="Load failed", operation="documents.delete.read")
|
||||||
@@ -377,27 +394,14 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
ui.label(f"Document: {document.name}").classes("text-sm font-semibold ui-text-primary")
|
ui.label(f"Document: {document.name}").classes("text-sm font-semibold ui-text-primary")
|
||||||
|
|
||||||
if document.sources or document.jobs:
|
if document.sources or document.jobs:
|
||||||
ui.label("Delete is blocked because related records exist.").classes(
|
render_delete_blocked_notice(
|
||||||
"text-xs ui-text-danger font-bold mt-2"
|
reason="Delete is blocked because related records exist.",
|
||||||
)
|
detail=dependency_summary(
|
||||||
deps = [
|
[("Sources", bool(document.sources)), ("Jobs", bool(document.jobs))]
|
||||||
cat
|
),
|
||||||
for cat, present in [("Sources", bool(document.sources)), ("Jobs", bool(document.jobs))]
|
guidance="Remove related records first, then retry deletion.",
|
||||||
if present
|
back_label="Back to Document",
|
||||||
]
|
back_target=f"/documents/{document.id}",
|
||||||
ui.label(f"Dependencies present: {', '.join(deps)}").classes("text-xs ui-text-muted")
|
|
||||||
ui.label("Remove related records first, then retry deletion.").classes(
|
|
||||||
"text-xs ui-text-muted italic"
|
|
||||||
)
|
|
||||||
|
|
||||||
with ui.row().classes("w-full items-center gap-2 mt-4"):
|
|
||||||
ui.button(
|
|
||||||
"Back to Document",
|
|
||||||
on_click=lambda: ui.navigate.to(f"/documents/{document.id}"),
|
|
||||||
icon="arrow_back",
|
|
||||||
).classes("ui-btn-primary text-xs")
|
|
||||||
ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
|
|
||||||
"flat text-xs"
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -424,13 +428,11 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
ui.notify("Document deleted", type="positive")
|
ui.notify("Document deleted", type="positive")
|
||||||
ui.navigate.to("/documents")
|
ui.navigate.to("/documents")
|
||||||
|
|
||||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
render_delete_actions(
|
||||||
destructive_button(
|
confirm_label="Delete document permanently",
|
||||||
"Delete document permanently", on_click=submit_delete, icon="delete_forever", variant="solid"
|
on_confirm=submit_delete,
|
||||||
|
cancel_target=f"/documents/{document.id}",
|
||||||
)
|
)
|
||||||
ui.button(
|
|
||||||
"Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back"
|
|
||||||
).props("flat")
|
|
||||||
|
|
||||||
|
|
||||||
# --- Helper Sub-Components ---
|
# --- Helper Sub-Components ---
|
||||||
@@ -441,7 +443,7 @@ def _render_document_form_fields(
|
|||||||
document: Document | None = None,
|
document: Document | None = None,
|
||||||
type_options: dict[str, str],
|
type_options: dict[str, str],
|
||||||
linked_people: LinkedPeopleEditor,
|
linked_people: LinkedPeopleEditor,
|
||||||
) -> dict[str, Any]:
|
) -> DocumentFormFields:
|
||||||
with archival_card(extra_classes="gap-3"):
|
with archival_card(extra_classes="gap-3"):
|
||||||
name_input = (
|
name_input = (
|
||||||
ui.input(label="Document name", value=document.name if document else "")
|
ui.input(label="Document name", value=document.name if document else "")
|
||||||
@@ -504,16 +506,16 @@ def _render_document_form_fields(
|
|||||||
|
|
||||||
linked_people.render()
|
linked_people.render()
|
||||||
|
|
||||||
return {
|
return DocumentFormFields(
|
||||||
"name": name_input,
|
name=name_input,
|
||||||
"type": type_input,
|
document_type=type_input,
|
||||||
"type_options": type_display_to_id,
|
type_options=type_display_to_id,
|
||||||
"date": date_input,
|
document_date=date_input,
|
||||||
"date_raw": date_raw_input,
|
document_date_raw=date_raw_input,
|
||||||
"location": location_input,
|
location=location_input,
|
||||||
"archive": archive_input,
|
archive=archive_input,
|
||||||
"notes": notes_input,
|
notes=notes_input,
|
||||||
}
|
)
|
||||||
|
|
||||||
|
|
||||||
def _render_bento_viewer_zone(document: Document) -> None:
|
def _render_bento_viewer_zone(document: Document) -> None:
|
||||||
@@ -588,31 +590,12 @@ def _render_document_processing_card(document: Document) -> None:
|
|||||||
).classes("ui-btn-primary text-xs")
|
).classes("ui-btn-primary text-xs")
|
||||||
|
|
||||||
|
|
||||||
def _parse_uuid(value: str | None) -> UUID | None:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return UUID(value)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_iso_date(value: str | None) -> date | None:
|
|
||||||
candidate = (value or "").strip()
|
|
||||||
if not candidate:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return date.fromisoformat(candidate)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_selected_document_type_id(selected_value: Any, type_options: dict[str, str]) -> UUID | None:
|
def _resolve_selected_document_type_id(selected_value: Any, type_options: dict[str, str]) -> UUID | None:
|
||||||
candidate = str(selected_value).strip() if selected_value is not None else ""
|
candidate = str(selected_value).strip() if selected_value is not None else ""
|
||||||
if not candidate:
|
if not candidate:
|
||||||
return None
|
return None
|
||||||
selected_id = type_options.get(candidate)
|
selected_id = type_options.get(candidate)
|
||||||
return _parse_uuid(selected_id)
|
return parse_uuid(selected_id)
|
||||||
|
|
||||||
|
|
||||||
def _group_people_by_role(document: Document) -> dict[str, list[Any]]:
|
def _group_people_by_role(document: Document) -> dict[str, list[Any]]:
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from nicegui import events
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from transcription.ui.components.app_shell import render_navigation_header
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
from transcription.ui.components.cards import archival_card
|
from transcription.ui.components.cards import archival_card
|
||||||
from transcription.ui.components.primitives import render_empty_state
|
from transcription.ui.components.primitives import render_empty_state
|
||||||
from transcription.ui.components.primitives import section_header_row
|
from transcription.ui.components.primitives import section_header_row
|
||||||
|
from transcription.ui.components.upload_panel import IMAGE_UPLOAD_EXTENSIONS
|
||||||
|
from transcription.ui.components.upload_panel import render_upload_picker
|
||||||
from transcription.ui.components.viewers import dark_room_viewer
|
from transcription.ui.components.viewers import dark_room_viewer
|
||||||
from transcription.ui.homepage_store import latest_homepage_image
|
from transcription.ui.homepage_store import latest_homepage_image
|
||||||
from transcription.ui.homepage_store import read_homepage_markdown
|
from transcription.ui.homepage_store import read_homepage_markdown
|
||||||
@@ -35,9 +38,11 @@ def _render_homepage_editor(*, render_image_panel, markdown_input, on_upload) ->
|
|||||||
with ui.grid().classes("w-full grid-cols-12 gap-4"):
|
with ui.grid().classes("w-full grid-cols-12 gap-4"):
|
||||||
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
|
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
|
||||||
with archival_card(title="Homepage Image"):
|
with archival_card(title="Homepage Image"):
|
||||||
ui.upload(on_upload=on_upload, auto_upload=True, label="Upload image").props(
|
render_upload_picker(
|
||||||
'accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"'
|
on_upload=on_upload,
|
||||||
).classes("w-full")
|
label="Upload image",
|
||||||
|
extensions=IMAGE_UPLOAD_EXTENSIONS,
|
||||||
|
)
|
||||||
render_image_panel()
|
render_image_panel()
|
||||||
|
|
||||||
with ui.column().classes("col-span-12 lg:col-span-5 gap-4"), archival_card(title="Home Text"):
|
with ui.column().classes("col-span-12 lg:col-span-5 gap-4"), archival_card(title="Home Text"):
|
||||||
@@ -82,7 +87,7 @@ def register_page() -> None:
|
|||||||
def render_image_panel() -> None:
|
def render_image_panel() -> None:
|
||||||
dark_room_viewer(str(preview_image[0]) if preview_image[0] else None, count_label="Homepage Image")
|
dark_room_viewer(str(preview_image[0]) if preview_image[0] else None, count_label="Homepage Image")
|
||||||
|
|
||||||
async def on_upload(event) -> None:
|
async def on_upload(event: events.UploadEventArguments) -> None:
|
||||||
payload = await event.file.read()
|
payload = await event.file.read()
|
||||||
preview_image[0] = await store_homepage_image(filename=event.file.name, file_bytes=payload)
|
preview_image[0] = await store_homepage_image(filename=event.file.name, file_bytes=payload)
|
||||||
ui.notify(f"Uploaded {event.file.name}", type="positive")
|
ui.notify(f"Uploaded {event.file.name}", type="positive")
|
||||||
|
|||||||
@@ -7,14 +7,12 @@ from typing import Any
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
from nicegui import events
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from transcription.config import Settings
|
|
||||||
from transcription.config import get_settings
|
|
||||||
from transcription.db.models import Job
|
from transcription.db.models import Job
|
||||||
from transcription.db.models import JobSourceStatus
|
from transcription.db.models import JobSourceStatus
|
||||||
from transcription.db.models import JobStatus
|
from transcription.db.models import JobStatus
|
||||||
from transcription.db.session import session_scope
|
|
||||||
from transcription.services import ServiceBundle
|
from transcription.services import ServiceBundle
|
||||||
from transcription.services.documents import DocumentService
|
from transcription.services.documents import DocumentService
|
||||||
from transcription.services.jobs import JobCancelBlockedError
|
from transcription.services.jobs import JobCancelBlockedError
|
||||||
@@ -26,14 +24,22 @@ from transcription.services.store import create_job_for_document
|
|||||||
from transcription.services.workflows import create_source_retranscription_job
|
from transcription.services.workflows import create_source_retranscription_job
|
||||||
from transcription.ui.components.app_shell import render_navigation_header
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
from transcription.ui.components.cards import archival_card
|
from transcription.ui.components.cards import archival_card
|
||||||
|
from transcription.ui.components.confirm_delete import render_delete_actions
|
||||||
|
from transcription.ui.components.confirm_delete import render_delete_blocked_notice
|
||||||
from transcription.ui.components.data_display import archival_badge
|
from transcription.ui.components.data_display import archival_badge
|
||||||
from transcription.ui.components.data_display import metadata_row
|
from transcription.ui.components.data_display import metadata_row
|
||||||
from transcription.ui.components.error_presenter import show_error
|
from transcription.ui.components.error_presenter import show_error
|
||||||
|
from transcription.ui.components.formatters import parse_uuid
|
||||||
|
from transcription.ui.components.guards import parsed_record_id
|
||||||
|
from transcription.ui.components.guards import render_record_not_found
|
||||||
from transcription.ui.components.primitives import destructive_button
|
from transcription.ui.components.primitives import destructive_button
|
||||||
from transcription.ui.components.primitives import render_empty_state
|
from transcription.ui.components.primitives import render_empty_state
|
||||||
from transcription.ui.components.primitives import section_header_row
|
from transcription.ui.components.primitives import section_header_row
|
||||||
from transcription.ui.components.table.jobs import JobTableRow
|
from transcription.ui.components.table.jobs import JobTableRow
|
||||||
from transcription.ui.components.table.jobs import render_jobs_table
|
from transcription.ui.components.table.jobs import render_jobs_table
|
||||||
|
from transcription.ui.components.upload_panel import SOURCE_UPLOAD_EXTENSIONS
|
||||||
|
from transcription.ui.components.upload_panel import render_upload_picker
|
||||||
|
from transcription.ui.runtime import resolve_runtime_settings
|
||||||
from transcription.ui.theme import page_header
|
from transcription.ui.theme import page_header
|
||||||
from transcription.worker import resolve_worker_notifier
|
from transcription.worker import resolve_worker_notifier
|
||||||
|
|
||||||
@@ -83,7 +89,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
) -> None:
|
) -> None:
|
||||||
documents_service = DocumentService(session_factory=session_factory)
|
documents_service = DocumentService(session_factory=session_factory)
|
||||||
sources_service = SourceService(session_factory=session_factory)
|
sources_service = SourceService(session_factory=session_factory)
|
||||||
settings = _resolve_runtime_settings(request)
|
settings = resolve_runtime_settings(request)
|
||||||
render_navigation_header(current_path="/jobs")
|
render_navigation_header(current_path="/jobs")
|
||||||
|
|
||||||
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
|
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
|
||||||
@@ -91,7 +97,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
"Create Processing Job", subtitle="Queue source files for AI transcription and entity processing."
|
"Create Processing Job", subtitle="Queue source files for AI transcription and entity processing."
|
||||||
)
|
)
|
||||||
|
|
||||||
requested_source_id = _parse_uuid(request.query_params.get("source_id"))
|
requested_source_id = parse_uuid(request.query_params.get("source_id"))
|
||||||
try:
|
try:
|
||||||
locked_source = (
|
locked_source = (
|
||||||
await sources_service.read_source_detail(requested_source_id)
|
await sources_service.read_source_detail(requested_source_id)
|
||||||
@@ -184,13 +190,12 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with session_scope(session_factory=session_factory) as session:
|
|
||||||
result = await create_job_for_document(
|
result = await create_job_for_document(
|
||||||
document_id=document_id,
|
document_id=document_id,
|
||||||
source_files=uploaded_files,
|
source_files=uploaded_files,
|
||||||
provider=(provider_input.value or None),
|
provider=(provider_input.value or None),
|
||||||
model=(model_input.value or None),
|
model=(model_input.value or None),
|
||||||
session=session,
|
session_factory=session_factory,
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
show_error(exc, title="Create job failed", operation="jobs.create")
|
show_error(exc, title="Create job failed", operation="jobs.create")
|
||||||
@@ -211,15 +216,14 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
jobs_service = JobService(session_factory=session_factory)
|
jobs_service = JobService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/jobs")
|
render_navigation_header(current_path="/jobs")
|
||||||
|
|
||||||
parsed_job_id = _parse_uuid(job_id)
|
parsed_job_id = parsed_record_id(job_id, noun="Job")
|
||||||
if parsed_job_id is None:
|
if parsed_job_id is None:
|
||||||
ui.label("Invalid job id").classes("text-h6 ui-text-danger p-4")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
job = await jobs_service.read_job(job_id=parsed_job_id)
|
job = await jobs_service.read_job(job_id=parsed_job_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
ui.label("Job not found").classes("text-h6 ui-text-danger p-4")
|
render_record_not_found("Job")
|
||||||
return
|
return
|
||||||
|
|
||||||
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
|
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
|
||||||
@@ -264,15 +268,14 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
jobs_service = JobService(session_factory=session_factory)
|
jobs_service = JobService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/jobs")
|
render_navigation_header(current_path="/jobs")
|
||||||
|
|
||||||
parsed_job_id = _parse_uuid(job_id)
|
parsed_job_id = parsed_record_id(job_id, noun="Job")
|
||||||
if parsed_job_id is None:
|
if parsed_job_id is None:
|
||||||
ui.label("Invalid job id").classes("text-h6 ui-text-danger p-4")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
job = await jobs_service.read_job(job_id=parsed_job_id)
|
job = await jobs_service.read_job(job_id=parsed_job_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
ui.label("Job not found").classes("text-h6 ui-text-danger p-4")
|
render_record_not_found("Job")
|
||||||
return
|
return
|
||||||
|
|
||||||
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
|
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
|
||||||
@@ -314,15 +317,14 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
jobs_service = JobService(session_factory=session_factory)
|
jobs_service = JobService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/jobs")
|
render_navigation_header(current_path="/jobs")
|
||||||
|
|
||||||
parsed_job_id = _parse_uuid(job_id)
|
parsed_job_id = parsed_record_id(job_id, noun="Job")
|
||||||
if parsed_job_id is None:
|
if parsed_job_id is None:
|
||||||
ui.label("Invalid job id").classes("text-h6 ui-text-danger p-4")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
job = await jobs_service.read_job(job_id=parsed_job_id)
|
job = await jobs_service.read_job(job_id=parsed_job_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
ui.label("Job not found").classes("text-h6 ui-text-danger p-4")
|
render_record_not_found("Job")
|
||||||
return
|
return
|
||||||
|
|
||||||
failed_count = sum(1 for js in job.job_sources if js.status == JobSourceStatus.FAILED)
|
failed_count = sum(1 for js in job.job_sources if js.status == JobSourceStatus.FAILED)
|
||||||
@@ -367,15 +369,14 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
jobs_service = JobService(session_factory=session_factory)
|
jobs_service = JobService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/jobs")
|
render_navigation_header(current_path="/jobs")
|
||||||
|
|
||||||
parsed_job_id = _parse_uuid(job_id)
|
parsed_job_id = parsed_record_id(job_id, noun="Job")
|
||||||
if parsed_job_id is None:
|
if parsed_job_id is None:
|
||||||
ui.label("Invalid job id").classes("text-h6 ui-text-danger p-4")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
job = await jobs_service.read_job(job_id=parsed_job_id)
|
job = await jobs_service.read_job(job_id=parsed_job_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
ui.label("Job not found").classes("text-h6 ui-text-danger p-4")
|
render_record_not_found("Job")
|
||||||
return
|
return
|
||||||
|
|
||||||
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
|
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
|
||||||
@@ -385,18 +386,12 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary")
|
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary")
|
||||||
|
|
||||||
if job.status == JobStatus.PROCESSING:
|
if job.status == JobStatus.PROCESSING:
|
||||||
ui.label("Delete is blocked while the job is processing.").classes(
|
render_delete_blocked_notice(
|
||||||
"text-xs ui-text-danger font-bold mt-2"
|
reason="Delete is blocked while the job is processing.",
|
||||||
)
|
guidance="Wait for processing to complete, then retry delete.",
|
||||||
ui.label("Wait for processing to complete, then retry delete.").classes(
|
back_label="Back to Job",
|
||||||
"text-xs ui-text-muted italic"
|
back_target=f"/jobs/{job.id}",
|
||||||
)
|
secondary_label="Back to Jobs",
|
||||||
with ui.row().classes("w-full items-center gap-2 mt-4"):
|
|
||||||
ui.button(
|
|
||||||
"Back to Job", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back"
|
|
||||||
).classes("ui-btn-primary text-xs")
|
|
||||||
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
|
|
||||||
"flat text-xs"
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -428,11 +423,11 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
ui.notify("Job deleted", type="positive")
|
ui.notify("Job deleted", type="positive")
|
||||||
ui.navigate.to("/jobs")
|
ui.navigate.to("/jobs")
|
||||||
|
|
||||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
render_delete_actions(
|
||||||
destructive_button(
|
confirm_label="Delete job and evidence",
|
||||||
"Delete job and evidence", on_click=submit_delete, icon="delete_forever", variant="solid"
|
on_confirm=submit_delete,
|
||||||
|
cancel_target=f"/jobs/{job.id}",
|
||||||
)
|
)
|
||||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat")
|
|
||||||
|
|
||||||
|
|
||||||
# --- Helper Sub-Components ---
|
# --- Helper Sub-Components ---
|
||||||
@@ -495,17 +490,19 @@ def _render_upload_section(uploaded_files: list[tuple[str, bytes]]) -> None:
|
|||||||
"text-xs ui-text-danger"
|
"text-xs ui-text-danger"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def on_upload(event) -> None:
|
async def on_upload(event: events.UploadEventArguments) -> None:
|
||||||
payload = await event.file.read()
|
payload = await event.file.read()
|
||||||
uploaded_files.append((event.file.name, payload))
|
uploaded_files.append((event.file.name, payload))
|
||||||
ui.notify(f"Added {event.file.name}", type="positive")
|
ui.notify(f"Added {event.file.name}", type="positive")
|
||||||
render_upload_list.refresh()
|
render_upload_list.refresh()
|
||||||
|
|
||||||
ui.upload(
|
render_upload_picker(
|
||||||
on_upload=on_upload,
|
on_upload=on_upload,
|
||||||
auto_upload=True,
|
|
||||||
label="Select source files or a folder",
|
label="Select source files or a folder",
|
||||||
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf" webkitdirectory directory multiple').classes("w-full")
|
extensions=SOURCE_UPLOAD_EXTENSIONS,
|
||||||
|
directory=True,
|
||||||
|
multiple=True,
|
||||||
|
)
|
||||||
|
|
||||||
render_upload_list()
|
render_upload_list()
|
||||||
|
|
||||||
@@ -562,21 +559,5 @@ def _render_job_document_links(job: Job) -> None:
|
|||||||
).props("flat text-xs").classes("ui-link-primary w-full")
|
).props("flat text-xs").classes("ui-link-primary w-full")
|
||||||
|
|
||||||
|
|
||||||
def _parse_uuid(value: str | None) -> UUID | None:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return UUID(value)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
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 _latest_prompt_name(job: Job) -> str | None:
|
def _latest_prompt_name(job: Job) -> str | None:
|
||||||
return job.prompt_name
|
return job.prompt_name
|
||||||
|
|||||||
@@ -2,18 +2,15 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
from urllib.parse import quote
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
from nicegui import events
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from transcription.config import Settings
|
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.people import PeopleError
|
from transcription.services.people import PeopleError
|
||||||
@@ -22,21 +19,47 @@ from transcription.services.people import PersonMediaError
|
|||||||
from transcription.services.people import store_person_portrait
|
from transcription.services.people 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.cards import archival_card
|
from transcription.ui.components.cards import archival_card
|
||||||
|
from transcription.ui.components.confirm_delete import render_delete_actions
|
||||||
from transcription.ui.components.data_display import metadata_row
|
from transcription.ui.components.data_display import metadata_row
|
||||||
from transcription.ui.components.error_presenter import show_error
|
from transcription.ui.components.error_presenter import show_error
|
||||||
from transcription.ui.components.formatters import compact_date
|
from transcription.ui.components.formatters import compact_date
|
||||||
from transcription.ui.components.formatters import family_search_url
|
from transcription.ui.components.formatters import family_search_url
|
||||||
|
from transcription.ui.components.formatters import parse_iso_date
|
||||||
|
from transcription.ui.components.guards import parsed_record_id
|
||||||
|
from transcription.ui.components.guards import render_record_not_found
|
||||||
|
from transcription.ui.components.media_urls import resolve_media_url
|
||||||
from transcription.ui.components.primitives import destructive_button
|
from transcription.ui.components.primitives import destructive_button
|
||||||
from transcription.ui.components.primitives import render_empty_state
|
from transcription.ui.components.primitives import render_empty_state
|
||||||
from transcription.ui.components.primitives import section_header_row
|
from transcription.ui.components.primitives import section_header_row
|
||||||
from transcription.ui.components.table.people import PersonTableRow
|
from transcription.ui.components.table.people import PersonTableRow
|
||||||
from transcription.ui.components.table.people import render_people_table
|
from transcription.ui.components.table.people import render_people_table
|
||||||
|
from transcription.ui.components.upload_panel import IMAGE_UPLOAD_EXTENSIONS
|
||||||
|
from transcription.ui.components.upload_panel import render_upload_picker
|
||||||
from transcription.ui.components.viewers import dark_room_viewer
|
from transcription.ui.components.viewers import dark_room_viewer
|
||||||
|
from transcription.ui.runtime import resolve_runtime_settings
|
||||||
from transcription.ui.theme import page_header
|
from transcription.ui.theme import page_header
|
||||||
|
|
||||||
from ...db.session import SessionFactoryDep
|
from ...db.session import SessionFactoryDep
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PersonFormFields:
|
||||||
|
"""Bound input widgets for the Person create and edit forms."""
|
||||||
|
|
||||||
|
full_name: ui.input
|
||||||
|
display_name: ui.input
|
||||||
|
maiden_name: ui.input
|
||||||
|
birth_date: ui.input
|
||||||
|
birth_date_raw: ui.input
|
||||||
|
birth_place: ui.input
|
||||||
|
death_date: ui.input
|
||||||
|
death_date_raw: ui.input
|
||||||
|
death_place: ui.input
|
||||||
|
biography: ui.textarea
|
||||||
|
portrait_path: ui.input
|
||||||
|
family_search_id: ui.input
|
||||||
|
|
||||||
|
|
||||||
def register_page() -> None: # noqa: PLR0915
|
def register_page() -> None: # noqa: PLR0915
|
||||||
"""Register people list and CRUD routes."""
|
"""Register people list and CRUD routes."""
|
||||||
|
|
||||||
@@ -92,28 +115,28 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def submit_create() -> None:
|
async def submit_create() -> None:
|
||||||
full_name = (form["full_name"].value or "").strip()
|
full_name = (form.full_name.value or "").strip()
|
||||||
if not full_name:
|
if not full_name:
|
||||||
ui.notify("Full name is required.", type="warning")
|
ui.notify("Full name is required.", type="warning")
|
||||||
return
|
return
|
||||||
|
|
||||||
birth_date = _parse_iso_date(form["birth_date"].value)
|
birth_date = parse_iso_date(form.birth_date.value)
|
||||||
death_date = _parse_iso_date(form["death_date"].value)
|
death_date = parse_iso_date(form.death_date.value)
|
||||||
|
|
||||||
candidate = Person(
|
candidate = Person(
|
||||||
id=draft_person_id,
|
id=draft_person_id,
|
||||||
full_name=full_name,
|
full_name=full_name,
|
||||||
display_name=(form["display_name"].value or "").strip() or None,
|
display_name=(form.display_name.value or "").strip() or None,
|
||||||
maiden_name=(form["maiden_name"].value or "").strip() or None,
|
maiden_name=(form.maiden_name.value or "").strip() or None,
|
||||||
birth_date=birth_date,
|
birth_date=birth_date,
|
||||||
birth_date_raw=(form["birth_date_raw"].value or "").strip() or None,
|
birth_date_raw=(form.birth_date_raw.value or "").strip() or None,
|
||||||
birth_place=(form["birth_place"].value or "").strip() or None,
|
birth_place=(form.birth_place.value or "").strip() or None,
|
||||||
death_date=death_date,
|
death_date=death_date,
|
||||||
death_date_raw=(form["death_date_raw"].value or "").strip() or None,
|
death_date_raw=(form.death_date_raw.value or "").strip() or None,
|
||||||
death_place=(form["death_place"].value or "").strip() or None,
|
death_place=(form.death_place.value or "").strip() or None,
|
||||||
biography=(form["biography"].value or "").strip() or None,
|
biography=(form.biography.value or "").strip() or None,
|
||||||
portrait_path=(form["portrait_path"].value or "").strip() or None,
|
portrait_path=(form.portrait_path.value or "").strip() or None,
|
||||||
family_search_id=(form["family_search_id"].value or "").strip() or None,
|
family_search_id=(form.family_search_id.value or "").strip() or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -134,15 +157,14 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
people_service = PeopleService(session_factory=session_factory)
|
people_service = PeopleService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/people")
|
render_navigation_header(current_path="/people")
|
||||||
|
|
||||||
parsed_person_id = _parse_uuid(person_id)
|
parsed_person_id = parsed_record_id(person_id, noun="Person")
|
||||||
if parsed_person_id is None:
|
if parsed_person_id is None:
|
||||||
ui.label("Invalid person id").classes("text-h6 ui-text-danger p-4")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
person = await people_service.read_person_detail(parsed_person_id)
|
person = await people_service.read_person_detail(parsed_person_id)
|
||||||
except PeopleError:
|
except PeopleError:
|
||||||
ui.label("Person not found").classes("text-h6 ui-text-danger p-4")
|
render_record_not_found("Person")
|
||||||
return
|
return
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
show_error(exc, title="Load failed", operation="people.read")
|
show_error(exc, title="Load failed", operation="people.read")
|
||||||
@@ -173,7 +195,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
with ui.grid().classes("w-full grid-cols-12 gap-4"):
|
with ui.grid().classes("w-full grid-cols-12 gap-4"):
|
||||||
_render_person_portrait_zone(
|
_render_person_portrait_zone(
|
||||||
person,
|
person,
|
||||||
settings=_resolve_runtime_settings(request),
|
settings=resolve_runtime_settings(request),
|
||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
_render_person_biographical_zone(person)
|
_render_person_biographical_zone(person)
|
||||||
@@ -184,15 +206,14 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
people_service = PeopleService(session_factory=session_factory)
|
people_service = PeopleService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/people")
|
render_navigation_header(current_path="/people")
|
||||||
|
|
||||||
parsed_person_id = _parse_uuid(person_id)
|
parsed_person_id = parsed_record_id(person_id, noun="Person")
|
||||||
if parsed_person_id is None:
|
if parsed_person_id is None:
|
||||||
ui.label("Invalid person id").classes("text-h6 ui-text-danger p-4")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
person = await people_service.read_person_detail(parsed_person_id)
|
person = await people_service.read_person_detail(parsed_person_id)
|
||||||
except PeopleError:
|
except PeopleError:
|
||||||
ui.label("Person not found").classes("text-h6 ui-text-danger p-4")
|
render_record_not_found("Person")
|
||||||
return
|
return
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
show_error(exc, title="Load failed", operation="people.edit.read")
|
show_error(exc, title="Load failed", operation="people.edit.read")
|
||||||
@@ -208,28 +229,28 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def submit_edit() -> None:
|
async def submit_edit() -> None:
|
||||||
full_name = (form["full_name"].value or "").strip()
|
full_name = (form.full_name.value or "").strip()
|
||||||
if not full_name:
|
if not full_name:
|
||||||
ui.notify("Full name is required.", type="warning")
|
ui.notify("Full name is required.", type="warning")
|
||||||
return
|
return
|
||||||
|
|
||||||
birth_date = _parse_iso_date(form["birth_date"].value)
|
birth_date = parse_iso_date(form.birth_date.value)
|
||||||
death_date = _parse_iso_date(form["death_date"].value)
|
death_date = parse_iso_date(form.death_date.value)
|
||||||
|
|
||||||
candidate = Person(
|
candidate = Person(
|
||||||
id=person.id,
|
id=person.id,
|
||||||
full_name=full_name,
|
full_name=full_name,
|
||||||
display_name=(form["display_name"].value or "").strip() or None,
|
display_name=(form.display_name.value or "").strip() or None,
|
||||||
maiden_name=(form["maiden_name"].value or "").strip() or None,
|
maiden_name=(form.maiden_name.value or "").strip() or None,
|
||||||
birth_date=birth_date,
|
birth_date=birth_date,
|
||||||
birth_date_raw=(form["birth_date_raw"].value or "").strip() or None,
|
birth_date_raw=(form.birth_date_raw.value or "").strip() or None,
|
||||||
birth_place=(form["birth_place"].value or "").strip() or None,
|
birth_place=(form.birth_place.value or "").strip() or None,
|
||||||
death_date=death_date,
|
death_date=death_date,
|
||||||
death_date_raw=(form["death_date_raw"].value or "").strip() or None,
|
death_date_raw=(form.death_date_raw.value or "").strip() or None,
|
||||||
death_place=(form["death_place"].value or "").strip() or None,
|
death_place=(form.death_place.value or "").strip() or None,
|
||||||
biography=(form["biography"].value or "").strip() or None,
|
biography=(form.biography.value or "").strip() or None,
|
||||||
portrait_path=(form["portrait_path"].value or "").strip() or None,
|
portrait_path=(form.portrait_path.value or "").strip() or None,
|
||||||
family_search_id=(form["family_search_id"].value or "").strip() or None,
|
family_search_id=(form.family_search_id.value or "").strip() or None,
|
||||||
metadata_=person.metadata_,
|
metadata_=person.metadata_,
|
||||||
created_at=person.created_at,
|
created_at=person.created_at,
|
||||||
updated_at=person.updated_at,
|
updated_at=person.updated_at,
|
||||||
@@ -255,15 +276,14 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
people_service = PeopleService(session_factory=session_factory)
|
people_service = PeopleService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/people")
|
render_navigation_header(current_path="/people")
|
||||||
|
|
||||||
parsed_person_id = _parse_uuid(person_id)
|
parsed_person_id = parsed_record_id(person_id, noun="Person")
|
||||||
if parsed_person_id is None:
|
if parsed_person_id is None:
|
||||||
ui.label("Invalid person id").classes("text-h6 ui-text-danger p-4")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
person = await people_service.read_person_detail(parsed_person_id)
|
person = await people_service.read_person_detail(parsed_person_id)
|
||||||
except PeopleError:
|
except PeopleError:
|
||||||
ui.label("Person not found").classes("text-h6 ui-text-danger p-4")
|
render_record_not_found("Person")
|
||||||
return
|
return
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
show_error(exc, title="Load failed", operation="people.delete.read")
|
show_error(exc, title="Load failed", operation="people.delete.read")
|
||||||
@@ -301,12 +321,10 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
ui.notify("Person deleted", type="positive")
|
ui.notify("Person deleted", type="positive")
|
||||||
ui.navigate.to("/people")
|
ui.navigate.to("/people")
|
||||||
|
|
||||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
render_delete_actions(
|
||||||
destructive_button(
|
confirm_label="Delete person permanently",
|
||||||
"Delete person permanently", on_click=submit_delete, icon="delete_forever", variant="solid"
|
on_confirm=submit_delete,
|
||||||
)
|
cancel_target=f"/people/{person.id}",
|
||||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props(
|
|
||||||
"flat"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -318,7 +336,7 @@ def _render_person_form_fields(
|
|||||||
request: Request,
|
request: Request,
|
||||||
person: Person | None = None,
|
person: Person | None = None,
|
||||||
person_id: UUID,
|
person_id: UUID,
|
||||||
) -> dict[str, Any]:
|
) -> PersonFormFields:
|
||||||
with archival_card(extra_classes="gap-3"):
|
with archival_card(extra_classes="gap-3"):
|
||||||
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
|
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
|
||||||
full_name_input = (
|
full_name_input = (
|
||||||
@@ -405,28 +423,32 @@ def _render_person_form_fields(
|
|||||||
|
|
||||||
_bind_portrait_file_picker(
|
_bind_portrait_file_picker(
|
||||||
portrait_path_input,
|
portrait_path_input,
|
||||||
settings=_resolve_runtime_settings(request),
|
settings=resolve_runtime_settings(request),
|
||||||
person_id=person_id,
|
person_id=person_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return PersonFormFields(
|
||||||
"full_name": full_name_input,
|
full_name=full_name_input,
|
||||||
"display_name": display_name_input,
|
display_name=display_name_input,
|
||||||
"maiden_name": maiden_name_input,
|
maiden_name=maiden_name_input,
|
||||||
"birth_date": birth_date_input,
|
birth_date=birth_date_input,
|
||||||
"birth_date_raw": birth_date_raw_input,
|
birth_date_raw=birth_date_raw_input,
|
||||||
"birth_place": birth_place_input,
|
birth_place=birth_place_input,
|
||||||
"death_date": death_date_input,
|
death_date=death_date_input,
|
||||||
"death_date_raw": death_date_raw_input,
|
death_date_raw=death_date_raw_input,
|
||||||
"death_place": death_place_input,
|
death_place=death_place_input,
|
||||||
"biography": biography_input,
|
biography=biography_input,
|
||||||
"portrait_path": portrait_path_input,
|
portrait_path=portrait_path_input,
|
||||||
"family_search_id": family_search_id_input,
|
family_search_id=family_search_id_input,
|
||||||
}
|
)
|
||||||
|
|
||||||
|
|
||||||
def _render_person_portrait_zone(person: Person, *, settings: Settings, request: Request) -> None:
|
def _render_person_portrait_zone(person: Person, *, settings: Settings, request: Request) -> None:
|
||||||
portrait_src = _resolve_portrait_src(person.portrait_path, settings=settings, request=request)
|
portrait_src = resolve_media_url(
|
||||||
|
person.portrait_path,
|
||||||
|
upload_dir=settings.upload_dir,
|
||||||
|
base_url=str(request.base_url),
|
||||||
|
)
|
||||||
with ui.column().classes("col-span-12 lg:col-span-4"):
|
with ui.column().classes("col-span-12 lg:col-span-4"):
|
||||||
dark_room_viewer(portrait_src, count_label="Portrait Media")
|
dark_room_viewer(portrait_src, count_label="Portrait Media")
|
||||||
|
|
||||||
@@ -489,7 +511,7 @@ def _render_linked_documents(person: Person) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _bind_portrait_file_picker(portrait_path_input: ui.input, *, settings: Settings, person_id: UUID) -> None:
|
def _bind_portrait_file_picker(portrait_path_input: ui.input, *, settings: Settings, person_id: UUID) -> None:
|
||||||
async def on_portrait_selected(event) -> None:
|
async def on_portrait_selected(event: events.UploadEventArguments) -> None:
|
||||||
payload = await event.file.read()
|
payload = await event.file.read()
|
||||||
try:
|
try:
|
||||||
stored_path = await store_person_portrait(
|
stored_path = await store_person_portrait(
|
||||||
@@ -513,93 +535,10 @@ def _bind_portrait_file_picker(portrait_path_input: ui.input, *, settings: Setti
|
|||||||
portrait_path_input.value = relative_path
|
portrait_path_input.value = relative_path
|
||||||
ui.notify("Portrait uploaded.", type="positive")
|
ui.notify("Portrait uploaded.", type="positive")
|
||||||
|
|
||||||
ui.upload(
|
render_upload_picker(
|
||||||
on_upload=on_portrait_selected,
|
on_upload=on_portrait_selected,
|
||||||
auto_upload=True,
|
|
||||||
label="Choose portrait file",
|
label="Choose portrait file",
|
||||||
).props('accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"').classes("w-full")
|
extensions=IMAGE_UPLOAD_EXTENSIONS,
|
||||||
|
)
|
||||||
portrait_dir = settings.upload_dir / "persons" / str(person_id)
|
portrait_dir = settings.upload_dir / "persons" / str(person_id)
|
||||||
ui.label(f"Portraits are stored under {portrait_dir}.").classes("text-xs ui-text-muted")
|
ui.label(f"Portraits are stored under {portrait_dir}.").classes("text-xs ui-text-muted")
|
||||||
|
|
||||||
|
|
||||||
def _resolve_portrait_src(path: str | None, *, settings: Settings, request: Request) -> str | None:
|
|
||||||
candidate = (path or "").strip()
|
|
||||||
if not candidate:
|
|
||||||
return None
|
|
||||||
|
|
||||||
normalized = candidate.replace("\\", "/")
|
|
||||||
lowered = normalized.casefold()
|
|
||||||
if lowered.startswith(("http://", "https://", "data:")):
|
|
||||||
return normalized
|
|
||||||
if normalized.startswith("/uploads/"):
|
|
||||||
return _to_absolute_upload_url(normalized, request=request)
|
|
||||||
|
|
||||||
upload_dir = settings.upload_dir.resolve()
|
|
||||||
path_obj = Path(candidate)
|
|
||||||
|
|
||||||
if path_obj.is_absolute():
|
|
||||||
absolute_candidates = [path_obj.resolve()]
|
|
||||||
else:
|
|
||||||
absolute_candidates = [
|
|
||||||
(Path.cwd() / path_obj).resolve(),
|
|
||||||
(upload_dir / path_obj).resolve(),
|
|
||||||
]
|
|
||||||
|
|
||||||
for absolute_candidate in absolute_candidates:
|
|
||||||
try:
|
|
||||||
relative = absolute_candidate.relative_to(upload_dir).as_posix()
|
|
||||||
return _to_absolute_upload_url(f"/uploads/{quote(relative)}", request=request)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
upload_name = upload_dir.name.casefold()
|
|
||||||
normalized_parts = Path(normalized).parts
|
|
||||||
lowered_parts = [part.casefold() for part in normalized_parts]
|
|
||||||
if upload_name in lowered_parts:
|
|
||||||
idx = lowered_parts.index(upload_name)
|
|
||||||
relative = Path(*normalized_parts[idx + 1 :]).as_posix()
|
|
||||||
if relative:
|
|
||||||
return _to_absolute_upload_url(f"/uploads/{quote(relative)}", request=request)
|
|
||||||
|
|
||||||
if lowered.startswith("uploads/"):
|
|
||||||
return _to_absolute_upload_url(f"/{normalized}", request=request)
|
|
||||||
if lowered.startswith("data/"):
|
|
||||||
relative = normalized.split("/", 1)[1] if "/" in normalized else ""
|
|
||||||
if relative:
|
|
||||||
return _to_absolute_upload_url(f"/uploads/{quote(relative)}", request=request)
|
|
||||||
if lowered.startswith(("documents/", "persons/")):
|
|
||||||
return _to_absolute_upload_url(f"/uploads/{quote(normalized)}", request=request)
|
|
||||||
|
|
||||||
return _to_absolute_upload_url(f"/uploads/{quote(path_obj.name)}", request=request)
|
|
||||||
|
|
||||||
|
|
||||||
def _to_absolute_upload_url(path: str, *, request: Request) -> str:
|
|
||||||
base = str(request.base_url).rstrip("/")
|
|
||||||
normalized_path = path if path.startswith("/") else f"/{path}"
|
|
||||||
return f"{base}{normalized_path}"
|
|
||||||
|
|
||||||
|
|
||||||
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 _parse_uuid(value: str | None) -> UUID | None:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return UUID(value)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_iso_date(value: str | None) -> date | None:
|
|
||||||
candidate = (value or "").strip()
|
|
||||||
if not candidate:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return date.fromisoformat(candidate)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ from transcription.services.documents import DocumentPrintSource
|
|||||||
from transcription.services.documents import DocumentService
|
from transcription.services.documents import DocumentService
|
||||||
from transcription.ui.components.error_presenter import show_error
|
from transcription.ui.components.error_presenter import show_error
|
||||||
from transcription.ui.components.formatters import compact_date
|
from transcription.ui.components.formatters import compact_date
|
||||||
|
from transcription.ui.components.guards import parsed_record_id
|
||||||
|
from transcription.ui.components.guards import render_record_not_found
|
||||||
from transcription.ui.theme import page_header
|
from transcription.ui.theme import page_header
|
||||||
|
|
||||||
from ...db.session import SessionFactoryDep
|
from ...db.session import SessionFactoryDep
|
||||||
@@ -26,17 +28,15 @@ def register_page() -> None:
|
|||||||
|
|
||||||
@ui.page("/documents/{document_id}/print")
|
@ui.page("/documents/{document_id}/print")
|
||||||
async def document_print_preview_page(document_id: str, session_factory: SessionFactoryDep) -> None:
|
async def document_print_preview_page(document_id: str, session_factory: SessionFactoryDep) -> None:
|
||||||
try:
|
parsed_id = parsed_record_id(document_id, noun="Document")
|
||||||
parsed_id = UUID(document_id)
|
if parsed_id is None:
|
||||||
except ValueError:
|
|
||||||
ui.label("Invalid document id").classes("text-h6 ui-text-danger p-4")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
service = DocumentService(session_factory=session_factory)
|
service = DocumentService(session_factory=session_factory)
|
||||||
try:
|
try:
|
||||||
projection = await service.read_document_print_projection(parsed_id)
|
projection = await service.read_document_print_projection(parsed_id)
|
||||||
except DocumentError:
|
except DocumentError:
|
||||||
ui.label("Document not found").classes("text-h6 ui-text-danger p-4")
|
render_record_not_found("Document")
|
||||||
return
|
return
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
show_error(exc, title="Print preview unavailable", operation="documents.print.read")
|
show_error(exc, title="Print preview unavailable", operation="documents.print.read")
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from transcription.ui.components.error_presenter import show_error
|
|||||||
from transcription.ui.components.primitives import destructive_button
|
from transcription.ui.components.primitives import destructive_button
|
||||||
from transcription.ui.components.primitives import render_empty_state
|
from transcription.ui.components.primitives import render_empty_state
|
||||||
from transcription.ui.components.primitives import section_header_row
|
from transcription.ui.components.primitives import section_header_row
|
||||||
|
from transcription.ui.components.table.registry import render_registry_table
|
||||||
from transcription.ui.theme import page_header
|
from transcription.ui.theme import page_header
|
||||||
|
|
||||||
from ...db.session import SessionFactoryDep
|
from ...db.session import SessionFactoryDep
|
||||||
@@ -62,55 +63,10 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
|
|||||||
}
|
}
|
||||||
for item in document_types
|
for item in document_types
|
||||||
]
|
]
|
||||||
table = ui.table(
|
table = render_registry_table(
|
||||||
columns=[
|
rows,
|
||||||
{
|
count_field="document_count",
|
||||||
"name": "label",
|
count_label="Documents",
|
||||||
"label": "Label",
|
|
||||||
"field": "label",
|
|
||||||
"align": "left",
|
|
||||||
"sortable": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "document_count",
|
|
||||||
"label": "Documents",
|
|
||||||
"field": "document_count",
|
|
||||||
"align": "right",
|
|
||||||
"sortable": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "is_active",
|
|
||||||
"label": "Active",
|
|
||||||
"field": "is_active",
|
|
||||||
"align": "center",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "is_built_in",
|
|
||||||
"label": "Built-in",
|
|
||||||
"field": "is_built_in",
|
|
||||||
"align": "center",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
rows=rows,
|
|
||||||
row_key="id",
|
|
||||||
selection="single",
|
|
||||||
pagination={"rowsPerPage": 0, "sortBy": "label"},
|
|
||||||
).classes("w-full ui-table")
|
|
||||||
table.add_slot(
|
|
||||||
"body-cell-is_active",
|
|
||||||
"""
|
|
||||||
<q-td :props="props" class="text-center">
|
|
||||||
<q-checkbox :model-value="props.value" disable dense />
|
|
||||||
</q-td>
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
table.add_slot(
|
|
||||||
"body-cell-is_built_in",
|
|
||||||
"""
|
|
||||||
<q-td :props="props" class="text-center">
|
|
||||||
{{ props.value ? 'Yes' : 'No' }}
|
|
||||||
</q-td>
|
|
||||||
""",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def save_type(
|
async def save_type(
|
||||||
@@ -214,44 +170,10 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
|
|||||||
}
|
}
|
||||||
for role in roles
|
for role in roles
|
||||||
]
|
]
|
||||||
table = ui.table(
|
table = render_registry_table(
|
||||||
columns=[
|
rows,
|
||||||
{"name": "label", "label": "Label", "field": "label", "align": "left", "sortable": True},
|
count_field="link_count",
|
||||||
{
|
count_label="Links",
|
||||||
"name": "link_count",
|
|
||||||
"label": "Links",
|
|
||||||
"field": "link_count",
|
|
||||||
"align": "right",
|
|
||||||
"sortable": True,
|
|
||||||
},
|
|
||||||
{"name": "is_active", "label": "Active", "field": "is_active", "align": "center"},
|
|
||||||
{
|
|
||||||
"name": "is_built_in",
|
|
||||||
"label": "Built-in",
|
|
||||||
"field": "is_built_in",
|
|
||||||
"align": "center",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
rows=rows,
|
|
||||||
row_key="id",
|
|
||||||
selection="single",
|
|
||||||
pagination={"rowsPerPage": 0, "sortBy": "label"},
|
|
||||||
).classes("w-full ui-table")
|
|
||||||
table.add_slot(
|
|
||||||
"body-cell-is_active",
|
|
||||||
"""
|
|
||||||
<q-td :props="props" class="text-center">
|
|
||||||
<q-checkbox :model-value="props.value" disable dense />
|
|
||||||
</q-td>
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
table.add_slot(
|
|
||||||
"body-cell-is_built_in",
|
|
||||||
"""
|
|
||||||
<q-td :props="props" class="text-center">
|
|
||||||
{{ props.value ? 'Yes' : 'No' }}
|
|
||||||
</q-td>
|
|
||||||
""",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def save_role(
|
async def save_role(
|
||||||
|
|||||||
@@ -4,34 +4,38 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
|
||||||
from urllib.parse import quote
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
from sqlalchemy import inspect as sqlalchemy_inspect
|
|
||||||
|
|
||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
from transcription.config import get_settings
|
|
||||||
from transcription.db.models import ExecutionAttempt
|
from transcription.db.models import ExecutionAttempt
|
||||||
from transcription.db.models import JobSource
|
from transcription.db.models import JobSource
|
||||||
from transcription.db.models import ProcessingArtifact
|
from transcription.db.models import ProcessingArtifact
|
||||||
from transcription.db.models import Source
|
from transcription.db.models import Source
|
||||||
|
from transcription.services.sources import LatestExecutionAttempt
|
||||||
from transcription.services.sources import SourceDeleteBlockedError
|
from transcription.services.sources import SourceDeleteBlockedError
|
||||||
from transcription.services.sources import SourceService
|
from transcription.services.sources import SourceService
|
||||||
from transcription.services.sources import TranscriptionNotFoundError
|
from transcription.services.sources import TranscriptionNotFoundError
|
||||||
from transcription.ui.components.app_shell import render_navigation_header
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
from transcription.ui.components.cards import archival_card
|
from transcription.ui.components.cards import archival_card
|
||||||
|
from transcription.ui.components.confirm_delete import render_delete_actions
|
||||||
|
from transcription.ui.components.confirm_delete import render_delete_blocked_notice
|
||||||
from transcription.ui.components.data_display import archival_badge
|
from transcription.ui.components.data_display import archival_badge
|
||||||
from transcription.ui.components.data_display import metadata_row
|
from transcription.ui.components.data_display import metadata_row
|
||||||
from transcription.ui.components.error_presenter import show_error
|
from transcription.ui.components.error_presenter import show_error
|
||||||
|
from transcription.ui.components.formatters import parse_uuid
|
||||||
|
from transcription.ui.components.guards import parsed_record_id
|
||||||
|
from transcription.ui.components.guards import render_record_not_found
|
||||||
|
from transcription.ui.components.media_urls import resolve_media_url
|
||||||
from transcription.ui.components.primitives import destructive_button
|
from transcription.ui.components.primitives import destructive_button
|
||||||
from transcription.ui.components.primitives import render_empty_state
|
from transcription.ui.components.primitives import render_empty_state
|
||||||
from transcription.ui.components.primitives import section_header_row
|
from transcription.ui.components.primitives import section_header_row
|
||||||
from transcription.ui.components.table.sources import SourceTableRow
|
from transcription.ui.components.table.sources import SourceTableRow
|
||||||
from transcription.ui.components.table.sources import render_sources_table
|
from transcription.ui.components.table.sources import render_sources_table
|
||||||
from transcription.ui.components.viewers import dark_room_viewer
|
from transcription.ui.components.viewers import dark_room_viewer
|
||||||
|
from transcription.ui.runtime import resolve_runtime_settings
|
||||||
from transcription.ui.theme import page_header
|
from transcription.ui.theme import page_header
|
||||||
|
|
||||||
from ...db.session import SessionFactoryDep
|
from ...db.session import SessionFactoryDep
|
||||||
@@ -47,8 +51,8 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
job_id: str | None = None,
|
job_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
sources_service = SourceService(session_factory=session_factory)
|
sources_service = SourceService(session_factory=session_factory)
|
||||||
parsed_doc_id = _parse_uuid(document_id)
|
parsed_doc_id = parse_uuid(document_id)
|
||||||
parsed_job_id = _parse_uuid(job_id)
|
parsed_job_id = parse_uuid(job_id)
|
||||||
|
|
||||||
header_title = "Source Asset Records"
|
header_title = "Source Asset Records"
|
||||||
if parsed_doc_id is not None:
|
if parsed_doc_id is not None:
|
||||||
@@ -108,12 +112,10 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
@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:
|
||||||
sources_service = SourceService(session_factory=session_factory)
|
sources_service = SourceService(session_factory=session_factory)
|
||||||
parsed_source_id = _parse_uuid(source_id)
|
|
||||||
|
|
||||||
render_navigation_header(current_path="/sources")
|
render_navigation_header(current_path="/sources")
|
||||||
|
|
||||||
|
parsed_source_id = parsed_record_id(source_id, noun="Source")
|
||||||
if parsed_source_id is None:
|
if parsed_source_id is None:
|
||||||
ui.label("Invalid source id").classes("text-h6 ui-text-danger p-4")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -130,7 +132,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
)
|
)
|
||||||
attempts = list(await sources_service.list_execution_attempts(source_id=parsed_source_id))
|
attempts = list(await sources_service.list_execution_attempts(source_id=parsed_source_id))
|
||||||
except TranscriptionNotFoundError:
|
except TranscriptionNotFoundError:
|
||||||
ui.label("Source not found").classes("text-h6 ui-text-danger p-4")
|
render_record_not_found("Source")
|
||||||
return
|
return
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
show_error(exc, title="Load failed", operation="sources.read")
|
show_error(exc, title="Load failed", operation="sources.read")
|
||||||
@@ -180,7 +182,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
_render_source_navigation(navigation.previous_id, navigation.next_id)
|
_render_source_navigation(navigation.previous_id, navigation.next_id)
|
||||||
_render_source_viewer_zone(
|
_render_source_viewer_zone(
|
||||||
source,
|
source,
|
||||||
settings=_resolve_runtime_settings(request),
|
settings=resolve_runtime_settings(request),
|
||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
_render_source_transcription_column(
|
_render_source_transcription_column(
|
||||||
@@ -204,18 +206,16 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
@ui.page("/sources/{source_id}/delete")
|
@ui.page("/sources/{source_id}/delete")
|
||||||
async def source_delete_page(source_id: str, session_factory: SessionFactoryDep) -> None:
|
async def source_delete_page(source_id: str, session_factory: SessionFactoryDep) -> None:
|
||||||
sources_service = SourceService(session_factory=session_factory)
|
sources_service = SourceService(session_factory=session_factory)
|
||||||
parsed_source_id = _parse_uuid(source_id)
|
|
||||||
|
|
||||||
render_navigation_header(current_path="/sources")
|
render_navigation_header(current_path="/sources")
|
||||||
|
|
||||||
|
parsed_source_id = parsed_record_id(source_id, noun="Source")
|
||||||
if parsed_source_id is None:
|
if parsed_source_id is None:
|
||||||
ui.label("Invalid source id").classes("text-h6 ui-text-danger p-4")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
source = await sources_service.read_source_detail(parsed_source_id)
|
source = await sources_service.read_source_detail(parsed_source_id)
|
||||||
except TranscriptionNotFoundError:
|
except TranscriptionNotFoundError:
|
||||||
ui.label("Source not found").classes("text-h6 ui-text-danger p-4")
|
render_record_not_found("Source")
|
||||||
return
|
return
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
show_error(exc, title="Load failed", operation="sources.delete.read")
|
show_error(exc, title="Load failed", operation="sources.delete.read")
|
||||||
@@ -228,23 +228,12 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
ui.label(f"Source: {source.upload_name}").classes("text-sm font-semibold ui-text-primary")
|
ui.label(f"Source: {source.upload_name}").classes("text-sm font-semibold ui-text-primary")
|
||||||
|
|
||||||
if source.job_sources:
|
if source.job_sources:
|
||||||
ui.label("Delete is only available for unlinked sources.").classes(
|
render_delete_blocked_notice(
|
||||||
"text-xs ui-text-danger font-bold mt-2"
|
reason="Delete is only available for unlinked sources.",
|
||||||
|
guidance="Open the related job record and remove job links first.",
|
||||||
|
back_label="Back to Source",
|
||||||
|
back_target=f"/sources/{source.id}",
|
||||||
)
|
)
|
||||||
ui.label("Open the related job record and remove job links first.").classes(
|
|
||||||
"text-xs ui-text-muted italic"
|
|
||||||
)
|
|
||||||
with ui.row().classes("w-full items-center gap-2 mt-4"):
|
|
||||||
ui.button(
|
|
||||||
"Back to Source",
|
|
||||||
on_click=lambda: ui.navigate.to(f"/sources/{source.id}"),
|
|
||||||
icon="arrow_back",
|
|
||||||
).classes("ui-btn-primary text-xs")
|
|
||||||
ui.button(
|
|
||||||
"Go to Jobs",
|
|
||||||
on_click=lambda: ui.navigate.to("/jobs"),
|
|
||||||
icon="work_history",
|
|
||||||
).props("flat text-xs")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
ui.label("This action permanently deletes the source record.").classes(
|
ui.label("This action permanently deletes the source record.").classes(
|
||||||
@@ -268,18 +257,16 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
ui.notify("Source deleted", type="positive")
|
ui.notify("Source deleted", type="positive")
|
||||||
ui.navigate.to("/sources")
|
ui.navigate.to("/sources")
|
||||||
|
|
||||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
render_delete_actions(
|
||||||
destructive_button(
|
confirm_label="Delete source permanently",
|
||||||
"Delete source permanently", on_click=submit_delete, icon="delete_forever", variant="solid"
|
on_confirm=submit_delete,
|
||||||
)
|
cancel_target=f"/sources/{source.id}",
|
||||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/sources/{source.id}"), icon="arrow_back").props(
|
|
||||||
"flat"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _render_source_viewer_zone(source: Source, *, settings: Settings, request: Request) -> None:
|
def _render_source_viewer_zone(source: Source, *, settings: Settings, request: Request) -> None:
|
||||||
dark_room_viewer(
|
dark_room_viewer(
|
||||||
_resolve_source_media_src(source.file_path, settings=settings, request=request),
|
resolve_media_url(source.file_path, upload_dir=settings.upload_dir, base_url=str(request.base_url)),
|
||||||
count_label=f"Page {source.page_number}",
|
count_label=f"Page {source.page_number}",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -323,7 +310,7 @@ def _render_source_metadata_column(
|
|||||||
*,
|
*,
|
||||||
source: Source,
|
source: Source,
|
||||||
latest_job_source: JobSource | None,
|
latest_job_source: JobSource | None,
|
||||||
latest_attempt: ExecutionAttempt | None,
|
latest_attempt: LatestExecutionAttempt | None,
|
||||||
source_artifacts: list[ProcessingArtifact],
|
source_artifacts: list[ProcessingArtifact],
|
||||||
) -> None:
|
) -> None:
|
||||||
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
|
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
|
||||||
@@ -349,7 +336,7 @@ def _render_source_metadata_zone(source: Source) -> None:
|
|||||||
def _render_source_job_metadata_zone(
|
def _render_source_job_metadata_zone(
|
||||||
latest_job_source: JobSource | None,
|
latest_job_source: JobSource | None,
|
||||||
*,
|
*,
|
||||||
latest_attempt: ExecutionAttempt | None,
|
latest_attempt: LatestExecutionAttempt | None,
|
||||||
source_artifacts: list[ProcessingArtifact],
|
source_artifacts: list[ProcessingArtifact],
|
||||||
) -> None:
|
) -> None:
|
||||||
with archival_card(title="SourceJob Metadata"):
|
with archival_card(title="SourceJob Metadata"):
|
||||||
@@ -394,7 +381,7 @@ def _render_source_job_metadata_zone(
|
|||||||
def _render_provider_evidence(
|
def _render_provider_evidence(
|
||||||
job_source: JobSource,
|
job_source: JobSource,
|
||||||
*,
|
*,
|
||||||
latest_attempt: ExecutionAttempt | None,
|
latest_attempt: LatestExecutionAttempt | None,
|
||||||
source_artifacts: list[ProcessingArtifact],
|
source_artifacts: list[ProcessingArtifact],
|
||||||
) -> None:
|
) -> None:
|
||||||
ui.label("Provider Evidence").classes("text-xs font-semibold ui-text-primary mt-3")
|
ui.label("Provider Evidence").classes("text-xs font-semibold ui-text-primary mt-3")
|
||||||
@@ -408,11 +395,11 @@ def _render_provider_evidence(
|
|||||||
_render_json_evidence("Derived Artifacts", _artifact_display(source_artifacts))
|
_render_json_evidence("Derived Artifacts", _artifact_display(source_artifacts))
|
||||||
return
|
return
|
||||||
|
|
||||||
attempt = latest_attempt
|
attempt = latest_attempt.attempt
|
||||||
metadata_row("Attempt:", str(attempt.attempt_number))
|
metadata_row("Attempt:", str(attempt.attempt_number))
|
||||||
metadata_row("Duration:", f"{attempt.duration_ms} ms")
|
metadata_row("Duration:", f"{attempt.duration_ms} ms")
|
||||||
_render_json_evidence("Request Manifest", attempt.request_manifest)
|
_render_json_evidence("Request Manifest", attempt.request_manifest)
|
||||||
_render_json_evidence("Transport Response", _transport_display(attempt))
|
_render_json_evidence("Transport Response", _transport_display(latest_attempt))
|
||||||
_render_json_evidence("OpenRouter SDK Response Snapshot", attempt.sdk_response_snapshot)
|
_render_json_evidence("OpenRouter SDK Response Snapshot", attempt.sdk_response_snapshot)
|
||||||
_render_json_evidence("Normalized Metadata", attempt.normalized_metadata)
|
_render_json_evidence("Normalized Metadata", attempt.normalized_metadata)
|
||||||
_render_json_evidence("Software Context", attempt.software_context)
|
_render_json_evidence("Software Context", attempt.software_context)
|
||||||
@@ -434,10 +421,10 @@ def _artifact_display(artifacts: list[ProcessingArtifact]) -> list[dict[str, obj
|
|||||||
return payload or None
|
return payload or None
|
||||||
|
|
||||||
|
|
||||||
def _transport_display(attempt: ExecutionAttempt) -> dict[str, object]:
|
def _transport_display(latest_attempt: LatestExecutionAttempt) -> dict[str, object]:
|
||||||
|
attempt = latest_attempt.attempt
|
||||||
body: object | None = None
|
body: object | None = None
|
||||||
body_is_deferred = "transport_body" in sqlalchemy_inspect(attempt).unloaded
|
if latest_attempt.transport_body_deferred:
|
||||||
if body_is_deferred:
|
|
||||||
body = "Omitted from Source Detail; use Export Evidence to retrieve the exact bytes."
|
body = "Omitted from Source Detail; use Export Evidence to retrieve the exact bytes."
|
||||||
elif attempt.transport_body is not None:
|
elif attempt.transport_body is not None:
|
||||||
try:
|
try:
|
||||||
@@ -706,81 +693,3 @@ def _resolve_original_transcription(*, source: Source, latest_job_source: JobSou
|
|||||||
if source.raw_transcription is None and latest_job_source is not None:
|
if source.raw_transcription is None and latest_job_source is not None:
|
||||||
return latest_job_source.raw_transcription
|
return latest_job_source.raw_transcription
|
||||||
return source.raw_transcription
|
return source.raw_transcription
|
||||||
|
|
||||||
|
|
||||||
def _resolve_source_media_src(path: str | None, *, settings: Settings, request: Request) -> str | None:
|
|
||||||
candidate = (path or "").strip()
|
|
||||||
if not candidate:
|
|
||||||
return None
|
|
||||||
|
|
||||||
normalized = candidate.replace("\\", "/")
|
|
||||||
lowered = normalized.casefold()
|
|
||||||
|
|
||||||
if lowered.startswith(("http://", "https://", "data:")):
|
|
||||||
return normalized
|
|
||||||
if normalized.startswith("/uploads/"):
|
|
||||||
return _to_absolute_upload_url(normalized, request=request)
|
|
||||||
|
|
||||||
upload_dir = settings.upload_dir.resolve()
|
|
||||||
path_obj = Path(candidate)
|
|
||||||
|
|
||||||
# Case 1: absolute filesystem path
|
|
||||||
if path_obj.is_absolute():
|
|
||||||
absolute_candidates = [path_obj.resolve()]
|
|
||||||
else:
|
|
||||||
# Case 2: relative path that may already include data root name (e.g. data/documents/...)
|
|
||||||
absolute_candidates = [
|
|
||||||
(Path.cwd() / path_obj).resolve(),
|
|
||||||
(upload_dir / path_obj).resolve(),
|
|
||||||
]
|
|
||||||
|
|
||||||
for absolute_candidate in absolute_candidates:
|
|
||||||
try:
|
|
||||||
relative = absolute_candidate.relative_to(upload_dir).as_posix()
|
|
||||||
return _to_absolute_upload_url(f"/uploads/{quote(relative)}", request=request)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Fallback: if path contains upload-dir folder name, strip through that segment.
|
|
||||||
upload_name = upload_dir.name.casefold()
|
|
||||||
normalized_parts = Path(normalized).parts
|
|
||||||
lowered_parts = [part.casefold() for part in normalized_parts]
|
|
||||||
if upload_name in lowered_parts:
|
|
||||||
idx = lowered_parts.index(upload_name)
|
|
||||||
relative = Path(*normalized_parts[idx + 1 :]).as_posix()
|
|
||||||
if relative:
|
|
||||||
return _to_absolute_upload_url(f"/uploads/{quote(relative)}", request=request)
|
|
||||||
|
|
||||||
# Final fallback: treat as already relative to upload root.
|
|
||||||
if lowered.startswith("uploads/"):
|
|
||||||
return _to_absolute_upload_url(f"/{normalized}", request=request)
|
|
||||||
if lowered.startswith("data/"):
|
|
||||||
relative = normalized.split("/", 1)[1] if "/" in normalized else ""
|
|
||||||
if relative:
|
|
||||||
return _to_absolute_upload_url(f"/uploads/{quote(relative)}", request=request)
|
|
||||||
if lowered.startswith(("documents/", "persons/")):
|
|
||||||
return _to_absolute_upload_url(f"/uploads/{quote(normalized)}", request=request)
|
|
||||||
|
|
||||||
return _to_absolute_upload_url(f"/uploads/{quote(path_obj.name)}", request=request)
|
|
||||||
|
|
||||||
|
|
||||||
def _to_absolute_upload_url(path: str, *, request: Request) -> str:
|
|
||||||
base = str(request.base_url).rstrip("/")
|
|
||||||
normalized_path = path if path.startswith("/") else f"/{path}"
|
|
||||||
return f"{base}{normalized_path}"
|
|
||||||
|
|
||||||
|
|
||||||
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 _parse_uuid(value: str | None) -> UUID | None:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return UUID(value)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""Request-scoped runtime resolution shared by page modules."""
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from transcription.config import Settings
|
||||||
|
from transcription.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_runtime_settings(request: Request) -> Settings:
|
||||||
|
"""Prefer settings installed on application state, falling back to the default."""
|
||||||
|
app_settings = getattr(request.app.state, "settings", None)
|
||||||
|
if isinstance(app_settings, Settings):
|
||||||
|
return app_settings
|
||||||
|
return get_settings()
|
||||||
@@ -461,46 +461,6 @@ input:focus-visible,
|
|||||||
min-height: 31.25rem;
|
min-height: 31.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-panzoom-filename {
|
|
||||||
max-width: 60%;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-panzoom-host {
|
|
||||||
height: min(70vh, 52rem);
|
|
||||||
overflow: hidden;
|
|
||||||
touch-action: none;
|
|
||||||
border: 1px solid var(--theme-viewer-border);
|
|
||||||
border-radius: 0.125rem;
|
|
||||||
background: var(--theme-viewer);
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-panzoom-surface {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-panzoom-media {
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
display: block;
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
user-select: none;
|
|
||||||
-webkit-user-drag: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-panzoom-iframe {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
background: var(--theme-surface-raised);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.app-shell {
|
.app-shell {
|
||||||
padding-inline: 0.75rem;
|
padding-inline: 0.75rem;
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Structural rules for the NiceGUI page layer.
|
||||||
|
|
||||||
|
`.github/instructions/ui.instructions.md:23,31` forbids pages from owning
|
||||||
|
persistence or query-building concerns, and forbids components from resolving
|
||||||
|
request or application state. HIGH-07 recorded three violations of those rules;
|
||||||
|
these tests keep them from coming back.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
UI_DIR = Path(__file__).resolve().parents[1] / "src" / "transcription" / "ui"
|
||||||
|
PAGES_DIR = UI_DIR / "pages"
|
||||||
|
COMPONENTS_DIR = UI_DIR / "components"
|
||||||
|
|
||||||
|
# Names a page must not pull in: they hand the page a session, a transaction, ORM
|
||||||
|
# loader introspection, or process-global configuration.
|
||||||
|
FORBIDDEN_PAGE_IMPORTS = frozenset(
|
||||||
|
{
|
||||||
|
"session_scope",
|
||||||
|
"transaction_scope",
|
||||||
|
"get_session_factory",
|
||||||
|
"resolve_session_factory",
|
||||||
|
"get_settings",
|
||||||
|
"get_engine",
|
||||||
|
"upgrade_schema",
|
||||||
|
"create_all",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
FORBIDDEN_PAGE_MODULES = frozenset({"sqlalchemy", "sqlmodel"})
|
||||||
|
|
||||||
|
|
||||||
|
def _page_paths() -> list[Path]:
|
||||||
|
return sorted(path for path in PAGES_DIR.glob("*.py") if path.stem != "__init__")
|
||||||
|
|
||||||
|
|
||||||
|
def _component_paths() -> list[Path]:
|
||||||
|
return sorted(COMPONENTS_DIR.rglob("*.py"))
|
||||||
|
|
||||||
|
|
||||||
|
def _imports(tree: ast.Module) -> tuple[set[str], set[str]]:
|
||||||
|
"""Return (imported names, imported root modules)."""
|
||||||
|
names: set[str] = set()
|
||||||
|
modules: set[str] = set()
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.ImportFrom):
|
||||||
|
if node.module and node.level == 0:
|
||||||
|
modules.add(node.module.split(".")[0])
|
||||||
|
names.update(alias.name for alias in node.names)
|
||||||
|
elif isinstance(node, ast.Import):
|
||||||
|
for alias in node.names:
|
||||||
|
modules.add(alias.name.split(".")[0])
|
||||||
|
names.add(alias.name)
|
||||||
|
return names, modules
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_modules_are_discovered():
|
||||||
|
"""Guard the guard: the rules below are meaningless if nothing is scanned."""
|
||||||
|
discovered = {path.stem for path in _page_paths()}
|
||||||
|
assert discovered >= {"documents_page", "jobs_page", "people_page", "sources_page"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_page_imports_persistence_or_process_globals():
|
||||||
|
"""HIGH-07: pages orchestrate services; they do not own sessions or settings."""
|
||||||
|
violations: dict[str, list[str]] = {}
|
||||||
|
for path in _page_paths():
|
||||||
|
names, modules = _imports(ast.parse(path.read_text(encoding="utf-8")))
|
||||||
|
found = sorted((names & FORBIDDEN_PAGE_IMPORTS) | (modules & FORBIDDEN_PAGE_MODULES))
|
||||||
|
if found:
|
||||||
|
violations[path.stem] = found
|
||||||
|
assert violations == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_component_resolves_request_or_application_state():
|
||||||
|
"""`ui.instructions.md:31`: components render, they do not resolve app state."""
|
||||||
|
violations: dict[str, list[str]] = {}
|
||||||
|
for path in _component_paths():
|
||||||
|
names, modules = _imports(ast.parse(path.read_text(encoding="utf-8")))
|
||||||
|
found = sorted((names & FORBIDDEN_PAGE_IMPORTS) | (modules & (FORBIDDEN_PAGE_MODULES | {"fastapi"})))
|
||||||
|
if found:
|
||||||
|
violations[str(path.relative_to(COMPONENTS_DIR))] = found
|
||||||
|
assert violations == {}
|
||||||
@@ -77,6 +77,5 @@ def test_theme_defines_shared_semantic_surfaces():
|
|||||||
"ui-chip-primary",
|
"ui-chip-primary",
|
||||||
"ui-badge-secondary",
|
"ui-badge-secondary",
|
||||||
"ui-status",
|
"ui-status",
|
||||||
"document-panzoom-host",
|
|
||||||
):
|
):
|
||||||
assert f".{class_name}" in theme_css
|
assert f".{class_name}" in theme_css
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ async def test_attempts_are_append_only_and_exported_with_integrity(default_sess
|
|||||||
assert detail.processing_artifacts == []
|
assert detail.processing_artifacts == []
|
||||||
latest_attempt = await sources.read_latest_execution_attempt(job_source_id=latest_job_source.id)
|
latest_attempt = await sources.read_latest_execution_attempt(job_source_id=latest_job_source.id)
|
||||||
assert latest_attempt is not None
|
assert latest_attempt is not None
|
||||||
assert latest_attempt.attempt_number == 2
|
assert latest_attempt.attempt.attempt_number == 2
|
||||||
|
|
||||||
|
|
||||||
def test_benchmark_scoring_preserves_literal_differences():
|
def test_benchmark_scoring_preserves_literal_differences():
|
||||||
|
|||||||
Reference in New Issue
Block a user