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:
@@ -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)
|
||||
Reference in New Issue
Block a user