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:
zoltan57
2026-08-17 17:44:39 -05:00
co-authored by Copilot App
parent 97b3d0fd62
commit 6a3ee26733
25 changed files with 729 additions and 775 deletions
@@ -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)