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 ValidationError
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import inspect as sqlalchemy_inspect
|
||||
from sqlalchemy import tuple_
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
from sqlalchemy.orm import defer
|
||||
@@ -124,6 +125,14 @@ class ProviderInput:
|
||||
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):
|
||||
"""Manage source records, media payloads, revisions, and page execution output."""
|
||||
|
||||
@@ -207,8 +216,13 @@ class SourceService(ServiceBase):
|
||||
*,
|
||||
job_source_id: UUID,
|
||||
session: AsyncSession | None = None,
|
||||
) -> ExecutionAttempt | None:
|
||||
"""Read only the latest immutable attempt for one compatibility projection."""
|
||||
) -> LatestExecutionAttempt | None:
|
||||
"""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:
|
||||
query = (
|
||||
select(ExecutionAttempt)
|
||||
@@ -220,7 +234,11 @@ class SourceService(ServiceBase):
|
||||
)
|
||||
.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(
|
||||
self,
|
||||
|
||||
@@ -21,6 +21,8 @@ from ..db.models import Job
|
||||
from ..db.models import JobSource
|
||||
from ..db.models import JobSourceStatus
|
||||
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 write_media_bytes
|
||||
from .sources import TranscriptionError
|
||||
@@ -68,10 +70,15 @@ async def create_document_job(
|
||||
*,
|
||||
filename: str,
|
||||
file_bytes: bytes,
|
||||
session: AsyncSession,
|
||||
session: AsyncSession | None = None,
|
||||
session_factory: SessionFactory | None = None,
|
||||
settings: Settings | None = None,
|
||||
) -> 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()
|
||||
prompt_execution = build_prompt_execution(settings=runtime_settings)
|
||||
document_id = uuid4()
|
||||
@@ -85,16 +92,21 @@ async def create_document_job(
|
||||
)
|
||||
file_hash, file_size_bytes = _compute_file_metadata(file_bytes)
|
||||
try:
|
||||
document, job = await _create_document_job_records(
|
||||
async with session_scope(
|
||||
session_factory=session_factory,
|
||||
session=session,
|
||||
document_id=document_id,
|
||||
source_id=source_id,
|
||||
original_filename=filename,
|
||||
stored_path=stored_path,
|
||||
file_hash=file_hash,
|
||||
file_size_bytes=file_size_bytes,
|
||||
prompt_execution=prompt_execution,
|
||||
)
|
||||
settings=runtime_settings,
|
||||
) as _session:
|
||||
document, job = await _create_document_job_records(
|
||||
session=_session,
|
||||
document_id=document_id,
|
||||
source_id=source_id,
|
||||
original_filename=filename,
|
||||
stored_path=stored_path,
|
||||
file_hash=file_hash,
|
||||
file_size_bytes=file_size_bytes,
|
||||
prompt_execution=prompt_execution,
|
||||
)
|
||||
except Exception as exc:
|
||||
_best_effort_delete(stored_path)
|
||||
raise SourceStorageError(
|
||||
@@ -117,12 +129,17 @@ async def create_job_for_document(
|
||||
*,
|
||||
document_id: UUID,
|
||||
source_files: Sequence[tuple[str, bytes]],
|
||||
session: AsyncSession,
|
||||
session: AsyncSession | None = None,
|
||||
session_factory: SessionFactory | None = None,
|
||||
provider: str | None = None,
|
||||
model: str | None = None,
|
||||
settings: Settings | None = None,
|
||||
) -> 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:
|
||||
raise SourceStorageError(
|
||||
"At least one Source file is required to create a Job",
|
||||
@@ -154,14 +171,19 @@ async def create_job_for_document(
|
||||
)
|
||||
|
||||
try:
|
||||
job, source_ids = await _create_job_for_document_records(
|
||||
async with session_scope(
|
||||
session_factory=session_factory,
|
||||
session=session,
|
||||
document_id=document_id,
|
||||
stored_sources=stored_sources,
|
||||
provider=provider,
|
||||
model=model,
|
||||
prompt_execution=prompt_execution,
|
||||
)
|
||||
settings=runtime_settings,
|
||||
) as _session:
|
||||
job, source_ids = await _create_job_for_document_records(
|
||||
session=_session,
|
||||
document_id=document_id,
|
||||
stored_sources=stored_sources,
|
||||
provider=provider,
|
||||
model=model,
|
||||
prompt_execution=prompt_execution,
|
||||
)
|
||||
except Exception as exc:
|
||||
for source in stored_sources:
|
||||
_best_effort_delete(source.stored_path)
|
||||
|
||||
@@ -560,7 +560,7 @@ async def _write_page_outcome(
|
||||
warnings = analyze_transcription_quality(result.text)
|
||||
await services.sources.create_json_artifact(
|
||||
source_id=source.id,
|
||||
execution_attempt_id=attempt.id,
|
||||
execution_attempt_id=attempt.attempt.id,
|
||||
artifact_type="transcription_quality_warnings",
|
||||
schema_name=QUALITY_ANALYSIS_SCHEMA,
|
||||
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 render_app_shell
|
||||
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 render_empty_state
|
||||
from transcription.ui.components.primitives import section_header_row
|
||||
@@ -12,7 +11,6 @@ __all__ = [
|
||||
"NAV_ITEMS",
|
||||
"destructive_button",
|
||||
"render_app_shell",
|
||||
"render_document_panzoom",
|
||||
"render_empty_state",
|
||||
"render_navigation_header",
|
||||
"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
|
||||
from datetime import date
|
||||
from uuid import UUID
|
||||
|
||||
from transcription.db.models import Person
|
||||
|
||||
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:
|
||||
"""Prefer an exact date, then an approximate value, then an unknown marker."""
|
||||
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 PersonRole
|
||||
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.primitives import destructive_button
|
||||
|
||||
@@ -106,8 +107,8 @@ class LinkedPeopleEditor:
|
||||
role_input.value = str(current.role_id)
|
||||
|
||||
def save() -> None:
|
||||
person_id = self._parse_uuid(person_input.value)
|
||||
role_id = self._parse_uuid(role_input.value)
|
||||
person_id = parse_uuid(person_input.value)
|
||||
role_id = parse_uuid(role_input.value)
|
||||
if person_id is None or role_id is None:
|
||||
ui.notify("Select both a Person and Person Role.", type="warning")
|
||||
return
|
||||
@@ -171,10 +172,3 @@ class LinkedPeopleEditor:
|
||||
@staticmethod
|
||||
def _role_option_label(role: PersonRole) -> str:
|
||||
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,
|
||||
search_placeholder: str = "Search records...",
|
||||
on_row_click_id: Callable[[str], None] | None = None,
|
||||
selection: str | None = None,
|
||||
rows_per_page: int = 25,
|
||||
) -> Any:
|
||||
"""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:
|
||||
pagination["sortBy"] = default_sort_by
|
||||
pagination["descending"] = default_descending
|
||||
@@ -65,13 +67,17 @@ def build_table(
|
||||
.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 = (
|
||||
ui.table(
|
||||
rows=rows,
|
||||
columns=columns,
|
||||
row_key="id",
|
||||
pagination=pagination,
|
||||
)
|
||||
ui.table(**table_kwargs)
|
||||
.classes(f"w-full ui-table {classes}".strip())
|
||||
.props(
|
||||
'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 datetime import date
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
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.ui.components.app_shell import render_navigation_header
|
||||
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 metadata_row
|
||||
from transcription.ui.components.error_presenter import show_error
|
||||
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 StagedLinkedPerson
|
||||
from transcription.ui.components.primitives import destructive_button
|
||||
@@ -38,6 +45,20 @@ from transcription.ui.theme import page_header
|
||||
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
|
||||
"""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())
|
||||
role_catalog = list(await people_service.list_person_roles(active_only=False))
|
||||
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] = []
|
||||
if requested_person_id is not None and any(person.id == requested_person_id for person in people):
|
||||
try:
|
||||
@@ -82,8 +103,8 @@ def register_page() -> None: # noqa: PLR0915
|
||||
return_to = request.query_params.get("return_to")
|
||||
|
||||
async def submit_create() -> None:
|
||||
candidate_name = (form["name"].value or "").strip()
|
||||
candidate_type_id = _resolve_selected_document_type_id(form["type"].value, form["type_options"])
|
||||
candidate_name = (form.name.value or "").strip()
|
||||
candidate_type_id = _resolve_selected_document_type_id(form.document_type.value, form.type_options)
|
||||
if not candidate_name:
|
||||
ui.notify("Document name is required.", type="warning")
|
||||
return
|
||||
@@ -91,8 +112,8 @@ def register_page() -> None: # noqa: PLR0915
|
||||
ui.notify("Document type is required.", type="warning")
|
||||
return
|
||||
|
||||
parsed_date = _parse_iso_date(form["date"].value)
|
||||
if form["date"].value and parsed_date is None:
|
||||
parsed_date = parse_iso_date(form.document_date.value)
|
||||
if form.document_date.value and parsed_date is None:
|
||||
ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
|
||||
return
|
||||
|
||||
@@ -100,10 +121,10 @@ def register_page() -> None: # noqa: PLR0915
|
||||
name=candidate_name,
|
||||
document_type_id=candidate_type_id,
|
||||
document_date=parsed_date,
|
||||
document_date_raw=(form["date_raw"].value or "").strip() or None,
|
||||
location_created=(form["location"].value or "").strip() or None,
|
||||
notes=(form["notes"].value or "").strip() or None,
|
||||
archive_identifier=(form["archive"].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,
|
||||
notes=(form.notes.value or "").strip() or None,
|
||||
archive_identifier=(form.archive.value or "").strip() or None,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -169,15 +190,14 @@ def register_page() -> None: # noqa: PLR0915
|
||||
document_service = DocumentService(session_factory=session_factory)
|
||||
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:
|
||||
ui.label("Invalid document id").classes("text-h6 ui-text-danger p-4")
|
||||
return
|
||||
|
||||
try:
|
||||
document = await document_service.read_document_detail(document_id=parsed_doc_id)
|
||||
except DocumentError:
|
||||
ui.label("Document not found").classes("text-h6 ui-text-danger p-4")
|
||||
render_record_not_found("Document")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
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)
|
||||
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:
|
||||
ui.label("Invalid document id").classes("text-h6 ui-text-danger p-4")
|
||||
return
|
||||
|
||||
try:
|
||||
document = await document_service.read_document_detail(document_id=parsed_doc_id)
|
||||
except DocumentError:
|
||||
ui.label("Document not found").classes("text-h6 ui-text-danger p-4")
|
||||
render_record_not_found("Document")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
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)
|
||||
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:
|
||||
ui.label("Invalid document id").classes("text-h6 ui-text-danger p-4")
|
||||
return
|
||||
|
||||
try:
|
||||
document = await document_service.read_document_detail(document_id=parsed_doc_id)
|
||||
except DocumentError:
|
||||
ui.label("Document not found").classes("text-h6 ui-text-danger p-4")
|
||||
render_record_not_found("Document")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
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:
|
||||
candidate_name = (form["name"].value or "").strip()
|
||||
candidate_type_id = _resolve_selected_document_type_id(form["type"].value, form["type_options"])
|
||||
candidate_name = (form.name.value or "").strip()
|
||||
candidate_type_id = _resolve_selected_document_type_id(form.document_type.value, form.type_options)
|
||||
if not candidate_name:
|
||||
ui.notify("Document name is required.", type="warning")
|
||||
return
|
||||
@@ -313,8 +331,8 @@ def register_page() -> None: # noqa: PLR0915
|
||||
ui.notify("Document type is required.", type="warning")
|
||||
return
|
||||
|
||||
parsed_date = _parse_iso_date(form["date"].value)
|
||||
if form["date"].value and parsed_date is None:
|
||||
parsed_date = parse_iso_date(form.document_date.value)
|
||||
if form.document_date.value and parsed_date is None:
|
||||
ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
|
||||
return
|
||||
|
||||
@@ -323,10 +341,10 @@ def register_page() -> None: # noqa: PLR0915
|
||||
name=candidate_name,
|
||||
document_type_id=candidate_type_id,
|
||||
document_date=parsed_date,
|
||||
document_date_raw=(form["date_raw"].value or "").strip() or None,
|
||||
location_created=(form["location"].value or "").strip() or None,
|
||||
notes=(form["notes"].value or "").strip() or None,
|
||||
archive_identifier=(form["archive"].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,
|
||||
notes=(form.notes.value or "").strip() or None,
|
||||
archive_identifier=(form.archive.value or "").strip() or None,
|
||||
created_at=document.created_at,
|
||||
updated_at=document.updated_at,
|
||||
)
|
||||
@@ -356,15 +374,14 @@ def register_page() -> None: # noqa: PLR0915
|
||||
document_service = DocumentService(session_factory=session_factory)
|
||||
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:
|
||||
ui.label("Invalid document id").classes("text-h6 ui-text-danger p-4")
|
||||
return
|
||||
|
||||
try:
|
||||
document = await document_service.read_document_detail(document_id=parsed_doc_id)
|
||||
except DocumentError:
|
||||
ui.label("Document not found").classes("text-h6 ui-text-danger p-4")
|
||||
render_record_not_found("Document")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Load failed", operation="documents.delete.read")
|
||||
@@ -377,28 +394,15 @@ def register_page() -> None: # noqa: PLR0915
|
||||
ui.label(f"Document: {document.name}").classes("text-sm font-semibold ui-text-primary")
|
||||
|
||||
if document.sources or document.jobs:
|
||||
ui.label("Delete is blocked because related records exist.").classes(
|
||||
"text-xs ui-text-danger font-bold mt-2"
|
||||
render_delete_blocked_notice(
|
||||
reason="Delete is blocked because related records exist.",
|
||||
detail=dependency_summary(
|
||||
[("Sources", bool(document.sources)), ("Jobs", bool(document.jobs))]
|
||||
),
|
||||
guidance="Remove related records first, then retry deletion.",
|
||||
back_label="Back to Document",
|
||||
back_target=f"/documents/{document.id}",
|
||||
)
|
||||
deps = [
|
||||
cat
|
||||
for cat, present in [("Sources", bool(document.sources)), ("Jobs", bool(document.jobs))]
|
||||
if present
|
||||
]
|
||||
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
|
||||
|
||||
ui.label("This action permanently deletes the document.").classes("text-xs ui-text-danger font-medium")
|
||||
@@ -424,13 +428,11 @@ def register_page() -> None: # noqa: PLR0915
|
||||
ui.notify("Document deleted", type="positive")
|
||||
ui.navigate.to("/documents")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||
destructive_button(
|
||||
"Delete document permanently", on_click=submit_delete, icon="delete_forever", variant="solid"
|
||||
)
|
||||
ui.button(
|
||||
"Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back"
|
||||
).props("flat")
|
||||
render_delete_actions(
|
||||
confirm_label="Delete document permanently",
|
||||
on_confirm=submit_delete,
|
||||
cancel_target=f"/documents/{document.id}",
|
||||
)
|
||||
|
||||
|
||||
# --- Helper Sub-Components ---
|
||||
@@ -441,7 +443,7 @@ def _render_document_form_fields(
|
||||
document: Document | None = None,
|
||||
type_options: dict[str, str],
|
||||
linked_people: LinkedPeopleEditor,
|
||||
) -> dict[str, Any]:
|
||||
) -> DocumentFormFields:
|
||||
with archival_card(extra_classes="gap-3"):
|
||||
name_input = (
|
||||
ui.input(label="Document name", value=document.name if document else "")
|
||||
@@ -504,16 +506,16 @@ def _render_document_form_fields(
|
||||
|
||||
linked_people.render()
|
||||
|
||||
return {
|
||||
"name": name_input,
|
||||
"type": type_input,
|
||||
"type_options": type_display_to_id,
|
||||
"date": date_input,
|
||||
"date_raw": date_raw_input,
|
||||
"location": location_input,
|
||||
"archive": archive_input,
|
||||
"notes": notes_input,
|
||||
}
|
||||
return DocumentFormFields(
|
||||
name=name_input,
|
||||
document_type=type_input,
|
||||
type_options=type_display_to_id,
|
||||
document_date=date_input,
|
||||
document_date_raw=date_raw_input,
|
||||
location=location_input,
|
||||
archive=archive_input,
|
||||
notes=notes_input,
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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:
|
||||
candidate = str(selected_value).strip() if selected_value is not None else ""
|
||||
if not candidate:
|
||||
return None
|
||||
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]]:
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from nicegui import events
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.ui.components.app_shell import render_navigation_header
|
||||
from transcription.ui.components.cards import archival_card
|
||||
from transcription.ui.components.primitives import render_empty_state
|
||||
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.homepage_store import latest_homepage_image
|
||||
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.column().classes("col-span-12 lg:col-span-4 gap-4"):
|
||||
with archival_card(title="Homepage Image"):
|
||||
ui.upload(on_upload=on_upload, auto_upload=True, label="Upload image").props(
|
||||
'accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"'
|
||||
).classes("w-full")
|
||||
render_upload_picker(
|
||||
on_upload=on_upload,
|
||||
label="Upload image",
|
||||
extensions=IMAGE_UPLOAD_EXTENSIONS,
|
||||
)
|
||||
render_image_panel()
|
||||
|
||||
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:
|
||||
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()
|
||||
preview_image[0] = await store_homepage_image(filename=event.file.name, file_bytes=payload)
|
||||
ui.notify(f"Uploaded {event.file.name}", type="positive")
|
||||
|
||||
@@ -7,14 +7,12 @@ from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request
|
||||
from nicegui import events
|
||||
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 JobSourceStatus
|
||||
from transcription.db.models import JobStatus
|
||||
from transcription.db.session import session_scope
|
||||
from transcription.services import ServiceBundle
|
||||
from transcription.services.documents import DocumentService
|
||||
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.ui.components.app_shell import render_navigation_header
|
||||
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 metadata_row
|
||||
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 render_empty_state
|
||||
from transcription.ui.components.primitives import section_header_row
|
||||
from transcription.ui.components.table.jobs import JobTableRow
|
||||
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.worker import resolve_worker_notifier
|
||||
|
||||
@@ -83,7 +89,7 @@ def register_page() -> None: # noqa: PLR0915
|
||||
) -> None:
|
||||
documents_service = DocumentService(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")
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
requested_source_id = _parse_uuid(request.query_params.get("source_id"))
|
||||
requested_source_id = parse_uuid(request.query_params.get("source_id"))
|
||||
try:
|
||||
locked_source = (
|
||||
await sources_service.read_source_detail(requested_source_id)
|
||||
@@ -184,14 +190,13 @@ def register_page() -> None: # noqa: PLR0915
|
||||
return
|
||||
|
||||
try:
|
||||
async with session_scope(session_factory=session_factory) as session:
|
||||
result = await create_job_for_document(
|
||||
document_id=document_id,
|
||||
source_files=uploaded_files,
|
||||
provider=(provider_input.value or None),
|
||||
model=(model_input.value or None),
|
||||
session=session,
|
||||
)
|
||||
result = await create_job_for_document(
|
||||
document_id=document_id,
|
||||
source_files=uploaded_files,
|
||||
provider=(provider_input.value or None),
|
||||
model=(model_input.value or None),
|
||||
session_factory=session_factory,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Create job failed", operation="jobs.create")
|
||||
return
|
||||
@@ -211,15 +216,14 @@ def register_page() -> None: # noqa: PLR0915
|
||||
jobs_service = JobService(session_factory=session_factory)
|
||||
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:
|
||||
ui.label("Invalid job id").classes("text-h6 ui-text-danger p-4")
|
||||
return
|
||||
|
||||
try:
|
||||
job = await jobs_service.read_job(job_id=parsed_job_id)
|
||||
except ValueError:
|
||||
ui.label("Job not found").classes("text-h6 ui-text-danger p-4")
|
||||
render_record_not_found("Job")
|
||||
return
|
||||
|
||||
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)
|
||||
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:
|
||||
ui.label("Invalid job id").classes("text-h6 ui-text-danger p-4")
|
||||
return
|
||||
|
||||
try:
|
||||
job = await jobs_service.read_job(job_id=parsed_job_id)
|
||||
except ValueError:
|
||||
ui.label("Job not found").classes("text-h6 ui-text-danger p-4")
|
||||
render_record_not_found("Job")
|
||||
return
|
||||
|
||||
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)
|
||||
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:
|
||||
ui.label("Invalid job id").classes("text-h6 ui-text-danger p-4")
|
||||
return
|
||||
|
||||
try:
|
||||
job = await jobs_service.read_job(job_id=parsed_job_id)
|
||||
except ValueError:
|
||||
ui.label("Job not found").classes("text-h6 ui-text-danger p-4")
|
||||
render_record_not_found("Job")
|
||||
return
|
||||
|
||||
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)
|
||||
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:
|
||||
ui.label("Invalid job id").classes("text-h6 ui-text-danger p-4")
|
||||
return
|
||||
|
||||
try:
|
||||
job = await jobs_service.read_job(job_id=parsed_job_id)
|
||||
except ValueError:
|
||||
ui.label("Job not found").classes("text-h6 ui-text-danger p-4")
|
||||
render_record_not_found("Job")
|
||||
return
|
||||
|
||||
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
|
||||
@@ -385,19 +386,13 @@ def register_page() -> None: # noqa: PLR0915
|
||||
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary")
|
||||
|
||||
if job.status == JobStatus.PROCESSING:
|
||||
ui.label("Delete is blocked while the job is processing.").classes(
|
||||
"text-xs ui-text-danger font-bold mt-2"
|
||||
render_delete_blocked_notice(
|
||||
reason="Delete is blocked while the job is processing.",
|
||||
guidance="Wait for processing to complete, then retry delete.",
|
||||
back_label="Back to Job",
|
||||
back_target=f"/jobs/{job.id}",
|
||||
secondary_label="Back to Jobs",
|
||||
)
|
||||
ui.label("Wait for processing to complete, then retry delete.").classes(
|
||||
"text-xs ui-text-muted italic"
|
||||
)
|
||||
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
|
||||
|
||||
ui.label("This action permanently deletes the job and its immutable execution evidence.").classes(
|
||||
@@ -428,11 +423,11 @@ def register_page() -> None: # noqa: PLR0915
|
||||
ui.notify("Job deleted", type="positive")
|
||||
ui.navigate.to("/jobs")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||
destructive_button(
|
||||
"Delete job and evidence", on_click=submit_delete, icon="delete_forever", variant="solid"
|
||||
)
|
||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat")
|
||||
render_delete_actions(
|
||||
confirm_label="Delete job and evidence",
|
||||
on_confirm=submit_delete,
|
||||
cancel_target=f"/jobs/{job.id}",
|
||||
)
|
||||
|
||||
|
||||
# --- Helper Sub-Components ---
|
||||
@@ -495,17 +490,19 @@ def _render_upload_section(uploaded_files: list[tuple[str, bytes]]) -> None:
|
||||
"text-xs ui-text-danger"
|
||||
)
|
||||
|
||||
async def on_upload(event) -> None:
|
||||
async def on_upload(event: events.UploadEventArguments) -> None:
|
||||
payload = await event.file.read()
|
||||
uploaded_files.append((event.file.name, payload))
|
||||
ui.notify(f"Added {event.file.name}", type="positive")
|
||||
render_upload_list.refresh()
|
||||
|
||||
ui.upload(
|
||||
render_upload_picker(
|
||||
on_upload=on_upload,
|
||||
auto_upload=True,
|
||||
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()
|
||||
|
||||
@@ -562,21 +559,5 @@ def _render_job_document_links(job: Job) -> None:
|
||||
).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:
|
||||
return job.prompt_name
|
||||
|
||||
@@ -2,18 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import Request
|
||||
from nicegui import events
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.config import Settings
|
||||
from transcription.config import get_settings
|
||||
from transcription.db.models import Person
|
||||
from transcription.errors import ErrorCategory
|
||||
from transcription.services.people import PeopleError
|
||||
@@ -22,21 +19,47 @@ from transcription.services.people import PersonMediaError
|
||||
from transcription.services.people import store_person_portrait
|
||||
from transcription.ui.components.app_shell import render_navigation_header
|
||||
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.error_presenter import show_error
|
||||
from transcription.ui.components.formatters import compact_date
|
||||
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 render_empty_state
|
||||
from transcription.ui.components.primitives import section_header_row
|
||||
from transcription.ui.components.table.people import PersonTableRow
|
||||
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.runtime import resolve_runtime_settings
|
||||
from transcription.ui.theme import page_header
|
||||
|
||||
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
|
||||
"""Register people list and CRUD routes."""
|
||||
|
||||
@@ -92,28 +115,28 @@ def register_page() -> None: # noqa: PLR0915
|
||||
)
|
||||
|
||||
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:
|
||||
ui.notify("Full name is required.", type="warning")
|
||||
return
|
||||
|
||||
birth_date = _parse_iso_date(form["birth_date"].value)
|
||||
death_date = _parse_iso_date(form["death_date"].value)
|
||||
birth_date = parse_iso_date(form.birth_date.value)
|
||||
death_date = parse_iso_date(form.death_date.value)
|
||||
|
||||
candidate = Person(
|
||||
id=draft_person_id,
|
||||
full_name=full_name,
|
||||
display_name=(form["display_name"].value or "").strip() or None,
|
||||
maiden_name=(form["maiden_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,
|
||||
birth_date=birth_date,
|
||||
birth_date_raw=(form["birth_date_raw"].value or "").strip() or None,
|
||||
birth_place=(form["birth_place"].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,
|
||||
death_date=death_date,
|
||||
death_date_raw=(form["death_date_raw"].value or "").strip() or None,
|
||||
death_place=(form["death_place"].value or "").strip() or None,
|
||||
biography=(form["biography"].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,
|
||||
death_date_raw=(form.death_date_raw.value or "").strip() or None,
|
||||
death_place=(form.death_place.value or "").strip() or None,
|
||||
biography=(form.biography.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,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -134,15 +157,14 @@ def register_page() -> None: # noqa: PLR0915
|
||||
people_service = PeopleService(session_factory=session_factory)
|
||||
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:
|
||||
ui.label("Invalid person id").classes("text-h6 ui-text-danger p-4")
|
||||
return
|
||||
|
||||
try:
|
||||
person = await people_service.read_person_detail(parsed_person_id)
|
||||
except PeopleError:
|
||||
ui.label("Person not found").classes("text-h6 ui-text-danger p-4")
|
||||
render_record_not_found("Person")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
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"):
|
||||
_render_person_portrait_zone(
|
||||
person,
|
||||
settings=_resolve_runtime_settings(request),
|
||||
settings=resolve_runtime_settings(request),
|
||||
request=request,
|
||||
)
|
||||
_render_person_biographical_zone(person)
|
||||
@@ -184,15 +206,14 @@ def register_page() -> None: # noqa: PLR0915
|
||||
people_service = PeopleService(session_factory=session_factory)
|
||||
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:
|
||||
ui.label("Invalid person id").classes("text-h6 ui-text-danger p-4")
|
||||
return
|
||||
|
||||
try:
|
||||
person = await people_service.read_person_detail(parsed_person_id)
|
||||
except PeopleError:
|
||||
ui.label("Person not found").classes("text-h6 ui-text-danger p-4")
|
||||
render_record_not_found("Person")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
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:
|
||||
full_name = (form["full_name"].value or "").strip()
|
||||
full_name = (form.full_name.value or "").strip()
|
||||
if not full_name:
|
||||
ui.notify("Full name is required.", type="warning")
|
||||
return
|
||||
|
||||
birth_date = _parse_iso_date(form["birth_date"].value)
|
||||
death_date = _parse_iso_date(form["death_date"].value)
|
||||
birth_date = parse_iso_date(form.birth_date.value)
|
||||
death_date = parse_iso_date(form.death_date.value)
|
||||
|
||||
candidate = Person(
|
||||
id=person.id,
|
||||
full_name=full_name,
|
||||
display_name=(form["display_name"].value or "").strip() or None,
|
||||
maiden_name=(form["maiden_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,
|
||||
birth_date=birth_date,
|
||||
birth_date_raw=(form["birth_date_raw"].value or "").strip() or None,
|
||||
birth_place=(form["birth_place"].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,
|
||||
death_date=death_date,
|
||||
death_date_raw=(form["death_date_raw"].value or "").strip() or None,
|
||||
death_place=(form["death_place"].value or "").strip() or None,
|
||||
biography=(form["biography"].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,
|
||||
death_date_raw=(form.death_date_raw.value or "").strip() or None,
|
||||
death_place=(form.death_place.value or "").strip() or None,
|
||||
biography=(form.biography.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,
|
||||
metadata_=person.metadata_,
|
||||
created_at=person.created_at,
|
||||
updated_at=person.updated_at,
|
||||
@@ -255,15 +276,14 @@ def register_page() -> None: # noqa: PLR0915
|
||||
people_service = PeopleService(session_factory=session_factory)
|
||||
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:
|
||||
ui.label("Invalid person id").classes("text-h6 ui-text-danger p-4")
|
||||
return
|
||||
|
||||
try:
|
||||
person = await people_service.read_person_detail(parsed_person_id)
|
||||
except PeopleError:
|
||||
ui.label("Person not found").classes("text-h6 ui-text-danger p-4")
|
||||
render_record_not_found("Person")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Load failed", operation="people.delete.read")
|
||||
@@ -301,13 +321,11 @@ def register_page() -> None: # noqa: PLR0915
|
||||
ui.notify("Person deleted", type="positive")
|
||||
ui.navigate.to("/people")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||
destructive_button(
|
||||
"Delete person permanently", on_click=submit_delete, icon="delete_forever", variant="solid"
|
||||
)
|
||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props(
|
||||
"flat"
|
||||
)
|
||||
render_delete_actions(
|
||||
confirm_label="Delete person permanently",
|
||||
on_confirm=submit_delete,
|
||||
cancel_target=f"/people/{person.id}",
|
||||
)
|
||||
|
||||
|
||||
# --- Helper Sub-Components & Form Builders ---
|
||||
@@ -318,7 +336,7 @@ def _render_person_form_fields(
|
||||
request: Request,
|
||||
person: Person | None = None,
|
||||
person_id: UUID,
|
||||
) -> dict[str, Any]:
|
||||
) -> PersonFormFields:
|
||||
with archival_card(extra_classes="gap-3"):
|
||||
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
|
||||
full_name_input = (
|
||||
@@ -405,28 +423,32 @@ def _render_person_form_fields(
|
||||
|
||||
_bind_portrait_file_picker(
|
||||
portrait_path_input,
|
||||
settings=_resolve_runtime_settings(request),
|
||||
settings=resolve_runtime_settings(request),
|
||||
person_id=person_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"full_name": full_name_input,
|
||||
"display_name": display_name_input,
|
||||
"maiden_name": maiden_name_input,
|
||||
"birth_date": birth_date_input,
|
||||
"birth_date_raw": birth_date_raw_input,
|
||||
"birth_place": birth_place_input,
|
||||
"death_date": death_date_input,
|
||||
"death_date_raw": death_date_raw_input,
|
||||
"death_place": death_place_input,
|
||||
"biography": biography_input,
|
||||
"portrait_path": portrait_path_input,
|
||||
"family_search_id": family_search_id_input,
|
||||
}
|
||||
return PersonFormFields(
|
||||
full_name=full_name_input,
|
||||
display_name=display_name_input,
|
||||
maiden_name=maiden_name_input,
|
||||
birth_date=birth_date_input,
|
||||
birth_date_raw=birth_date_raw_input,
|
||||
birth_place=birth_place_input,
|
||||
death_date=death_date_input,
|
||||
death_date_raw=death_date_raw_input,
|
||||
death_place=death_place_input,
|
||||
biography=biography_input,
|
||||
portrait_path=portrait_path_input,
|
||||
family_search_id=family_search_id_input,
|
||||
)
|
||||
|
||||
|
||||
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"):
|
||||
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:
|
||||
async def on_portrait_selected(event) -> None:
|
||||
async def on_portrait_selected(event: events.UploadEventArguments) -> None:
|
||||
payload = await event.file.read()
|
||||
try:
|
||||
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
|
||||
ui.notify("Portrait uploaded.", type="positive")
|
||||
|
||||
ui.upload(
|
||||
render_upload_picker(
|
||||
on_upload=on_portrait_selected,
|
||||
auto_upload=True,
|
||||
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)
|
||||
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.ui.components.error_presenter import show_error
|
||||
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 ...db.session import SessionFactoryDep
|
||||
@@ -26,17 +28,15 @@ def register_page() -> None:
|
||||
|
||||
@ui.page("/documents/{document_id}/print")
|
||||
async def document_print_preview_page(document_id: str, session_factory: SessionFactoryDep) -> None:
|
||||
try:
|
||||
parsed_id = UUID(document_id)
|
||||
except ValueError:
|
||||
ui.label("Invalid document id").classes("text-h6 ui-text-danger p-4")
|
||||
parsed_id = parsed_record_id(document_id, noun="Document")
|
||||
if parsed_id is None:
|
||||
return
|
||||
|
||||
service = DocumentService(session_factory=session_factory)
|
||||
try:
|
||||
projection = await service.read_document_print_projection(parsed_id)
|
||||
except DocumentError:
|
||||
ui.label("Document not found").classes("text-h6 ui-text-danger p-4")
|
||||
render_record_not_found("Document")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
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 render_empty_state
|
||||
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 ...db.session import SessionFactoryDep
|
||||
@@ -62,55 +63,10 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
|
||||
}
|
||||
for item in document_types
|
||||
]
|
||||
table = ui.table(
|
||||
columns=[
|
||||
{
|
||||
"name": "label",
|
||||
"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>
|
||||
""",
|
||||
table = render_registry_table(
|
||||
rows,
|
||||
count_field="document_count",
|
||||
count_label="Documents",
|
||||
)
|
||||
|
||||
async def save_type(
|
||||
@@ -214,44 +170,10 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
|
||||
}
|
||||
for role in roles
|
||||
]
|
||||
table = ui.table(
|
||||
columns=[
|
||||
{"name": "label", "label": "Label", "field": "label", "align": "left", "sortable": True},
|
||||
{
|
||||
"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>
|
||||
""",
|
||||
table = render_registry_table(
|
||||
rows,
|
||||
count_field="link_count",
|
||||
count_label="Links",
|
||||
)
|
||||
|
||||
async def save_role(
|
||||
|
||||
@@ -4,34 +4,38 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request
|
||||
from nicegui import ui
|
||||
from sqlalchemy import inspect as sqlalchemy_inspect
|
||||
|
||||
from transcription.config import Settings
|
||||
from transcription.config import get_settings
|
||||
from transcription.db.models import ExecutionAttempt
|
||||
from transcription.db.models import JobSource
|
||||
from transcription.db.models import ProcessingArtifact
|
||||
from transcription.db.models import Source
|
||||
from transcription.services.sources import LatestExecutionAttempt
|
||||
from transcription.services.sources import SourceDeleteBlockedError
|
||||
from transcription.services.sources import SourceService
|
||||
from transcription.services.sources import TranscriptionNotFoundError
|
||||
from transcription.ui.components.app_shell import render_navigation_header
|
||||
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 metadata_row
|
||||
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 render_empty_state
|
||||
from transcription.ui.components.primitives import section_header_row
|
||||
from transcription.ui.components.table.sources import SourceTableRow
|
||||
from transcription.ui.components.table.sources import render_sources_table
|
||||
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 ...db.session import SessionFactoryDep
|
||||
@@ -47,8 +51,8 @@ def register_page() -> None: # noqa: PLR0915
|
||||
job_id: str | None = None,
|
||||
) -> None:
|
||||
sources_service = SourceService(session_factory=session_factory)
|
||||
parsed_doc_id = _parse_uuid(document_id)
|
||||
parsed_job_id = _parse_uuid(job_id)
|
||||
parsed_doc_id = parse_uuid(document_id)
|
||||
parsed_job_id = parse_uuid(job_id)
|
||||
|
||||
header_title = "Source Asset Records"
|
||||
if parsed_doc_id is not None:
|
||||
@@ -108,12 +112,10 @@ def register_page() -> None: # noqa: PLR0915
|
||||
@ui.page("/sources/{source_id}")
|
||||
async def source_detail_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
|
||||
sources_service = SourceService(session_factory=session_factory)
|
||||
parsed_source_id = _parse_uuid(source_id)
|
||||
|
||||
render_navigation_header(current_path="/sources")
|
||||
|
||||
parsed_source_id = parsed_record_id(source_id, noun="Source")
|
||||
if parsed_source_id is None:
|
||||
ui.label("Invalid source id").classes("text-h6 ui-text-danger p-4")
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -130,7 +132,7 @@ def register_page() -> None: # noqa: PLR0915
|
||||
)
|
||||
attempts = list(await sources_service.list_execution_attempts(source_id=parsed_source_id))
|
||||
except TranscriptionNotFoundError:
|
||||
ui.label("Source not found").classes("text-h6 ui-text-danger p-4")
|
||||
render_record_not_found("Source")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
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_viewer_zone(
|
||||
source,
|
||||
settings=_resolve_runtime_settings(request),
|
||||
settings=resolve_runtime_settings(request),
|
||||
request=request,
|
||||
)
|
||||
_render_source_transcription_column(
|
||||
@@ -204,18 +206,16 @@ def register_page() -> None: # noqa: PLR0915
|
||||
@ui.page("/sources/{source_id}/delete")
|
||||
async def source_delete_page(source_id: str, session_factory: SessionFactoryDep) -> None:
|
||||
sources_service = SourceService(session_factory=session_factory)
|
||||
parsed_source_id = _parse_uuid(source_id)
|
||||
|
||||
render_navigation_header(current_path="/sources")
|
||||
|
||||
parsed_source_id = parsed_record_id(source_id, noun="Source")
|
||||
if parsed_source_id is None:
|
||||
ui.label("Invalid source id").classes("text-h6 ui-text-danger p-4")
|
||||
return
|
||||
|
||||
try:
|
||||
source = await sources_service.read_source_detail(parsed_source_id)
|
||||
except TranscriptionNotFoundError:
|
||||
ui.label("Source not found").classes("text-h6 ui-text-danger p-4")
|
||||
render_record_not_found("Source")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
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")
|
||||
|
||||
if source.job_sources:
|
||||
ui.label("Delete is only available for unlinked sources.").classes(
|
||||
"text-xs ui-text-danger font-bold mt-2"
|
||||
render_delete_blocked_notice(
|
||||
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
|
||||
|
||||
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.navigate.to("/sources")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||
destructive_button(
|
||||
"Delete source permanently", on_click=submit_delete, icon="delete_forever", variant="solid"
|
||||
)
|
||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/sources/{source.id}"), icon="arrow_back").props(
|
||||
"flat"
|
||||
)
|
||||
render_delete_actions(
|
||||
confirm_label="Delete source permanently",
|
||||
on_confirm=submit_delete,
|
||||
cancel_target=f"/sources/{source.id}",
|
||||
)
|
||||
|
||||
|
||||
def _render_source_viewer_zone(source: Source, *, settings: Settings, request: Request) -> None:
|
||||
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}",
|
||||
)
|
||||
|
||||
@@ -323,7 +310,7 @@ def _render_source_metadata_column(
|
||||
*,
|
||||
source: Source,
|
||||
latest_job_source: JobSource | None,
|
||||
latest_attempt: ExecutionAttempt | None,
|
||||
latest_attempt: LatestExecutionAttempt | None,
|
||||
source_artifacts: list[ProcessingArtifact],
|
||||
) -> None:
|
||||
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(
|
||||
latest_job_source: JobSource | None,
|
||||
*,
|
||||
latest_attempt: ExecutionAttempt | None,
|
||||
latest_attempt: LatestExecutionAttempt | None,
|
||||
source_artifacts: list[ProcessingArtifact],
|
||||
) -> None:
|
||||
with archival_card(title="SourceJob Metadata"):
|
||||
@@ -394,7 +381,7 @@ def _render_source_job_metadata_zone(
|
||||
def _render_provider_evidence(
|
||||
job_source: JobSource,
|
||||
*,
|
||||
latest_attempt: ExecutionAttempt | None,
|
||||
latest_attempt: LatestExecutionAttempt | None,
|
||||
source_artifacts: list[ProcessingArtifact],
|
||||
) -> None:
|
||||
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))
|
||||
return
|
||||
|
||||
attempt = latest_attempt
|
||||
attempt = latest_attempt.attempt
|
||||
metadata_row("Attempt:", str(attempt.attempt_number))
|
||||
metadata_row("Duration:", f"{attempt.duration_ms} ms")
|
||||
_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("Normalized Metadata", attempt.normalized_metadata)
|
||||
_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
|
||||
|
||||
|
||||
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_is_deferred = "transport_body" in sqlalchemy_inspect(attempt).unloaded
|
||||
if body_is_deferred:
|
||||
if latest_attempt.transport_body_deferred:
|
||||
body = "Omitted from Source Detail; use Export Evidence to retrieve the exact bytes."
|
||||
elif attempt.transport_body is not None:
|
||||
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:
|
||||
return latest_job_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;
|
||||
}
|
||||
|
||||
.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) {
|
||||
.app-shell {
|
||||
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-badge-secondary",
|
||||
"ui-status",
|
||||
"document-panzoom-host",
|
||||
):
|
||||
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 == []
|
||||
latest_attempt = await sources.read_latest_execution_attempt(job_source_id=latest_job_source.id)
|
||||
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():
|
||||
|
||||
Reference in New Issue
Block a user