V5.0 fixes and revisions
Quality Gate / gate (push) Failing after 11s

This commit is contained in:
Jim Lancaster
2026-08-23 10:32:20 -05:00
parent 86b8e83ff4
commit 0f30d902b9
10 changed files with 492 additions and 102 deletions
+78 -16
View File
@@ -18,6 +18,7 @@ from sqlalchemy import create_engine
from sqlalchemy import inspect as sqlalchemy_inspect
from sqlalchemy import select
from sqlalchemy.engine import Engine
from sqlalchemy.engine import make_url
from sqlmodel import SQLModel
from transcription.config import Settings
@@ -124,6 +125,14 @@ def import_bundle(*, target_db_url: str, target_upload_dir: Path, bundle_dir: Pa
uploads_bundle_dir = bundle_dir / "uploads"
payload = json.loads(export_json.read_text(encoding="utf-8"))
if target_upload_dir.exists():
shutil.rmtree(target_upload_dir)
target_upload_dir.mkdir(parents=True, exist_ok=True)
if uploads_bundle_dir.exists():
shutil.copytree(uploads_bundle_dir, target_upload_dir, dirs_exist_ok=True)
_reset_sqlite_target_file(target_db_url)
_ensure_sqlite_target_parent_exists(target_db_url)
engine = create_engine(target_db_url)
try:
SQLModel.metadata.create_all(engine)
@@ -141,11 +150,27 @@ def import_bundle(*, target_db_url: str, target_upload_dir: Path, bundle_dir: Pa
finally:
engine.dispose()
if target_upload_dir.exists():
shutil.rmtree(target_upload_dir)
target_upload_dir.mkdir(parents=True, exist_ok=True)
if uploads_bundle_dir.exists():
shutil.copytree(uploads_bundle_dir, target_upload_dir, dirs_exist_ok=True)
def _ensure_sqlite_target_parent_exists(target_db_url: str) -> None:
parsed = make_url(target_db_url)
if not parsed.drivername.startswith("sqlite"):
return
database = parsed.database
if not database or database == ":memory:":
return
Path(database).parent.mkdir(parents=True, exist_ok=True)
def _reset_sqlite_target_file(target_db_url: str) -> None:
parsed = make_url(target_db_url)
if not parsed.drivername.startswith("sqlite"):
return
database = parsed.database
if not database or database == ":memory:":
return
target = Path(database)
if target.exists():
target.unlink()
def migrate_via_bundle(paths: MigrationPaths) -> None:
@@ -280,8 +305,37 @@ def _prepare_photo_payload_and_uploads(
photos_dir = uploads_bundle_dir / "photos"
photos_dir.mkdir(parents=True, exist_ok=True)
if source_has_photo_table:
return
# Keep only photo rows whose referenced media exists inside the uploads tree.
# This prevents stale/injected rows from blocking legacy backfill.
retained_rows: list[dict[str, Any]] = []
for row in photo_rows:
path_value = row.get("path")
if not isinstance(path_value, str) or not path_value.strip():
continue
canonical_path = _canonical_media_relative_path(
path_value,
source_upload_dir=uploads_bundle_dir,
preferred_prefix="photos/",
)
candidate = uploads_bundle_dir / canonical_path
if not candidate.exists():
continue
row["path"] = canonical_path
retained_rows.append(row)
photo_rows[:] = retained_rows
existing_homepage_rows = [row for row in photo_rows if row.get("person_id") is None]
existing_person_ids = {
str(row["person_id"])
for row in photo_rows
if row.get("person_id") is not None
}
existing_primary_person_ids = {
str(row["person_id"])
for row in photo_rows
if row.get("person_id") is not None and bool(row.get("is_primary"))
}
has_homepage_primary = any(bool(row.get("is_primary")) for row in existing_homepage_rows)
now_iso = datetime.now().isoformat()
for row in legacy_portrait_rows:
@@ -297,26 +351,32 @@ def _prepare_photo_payload_and_uploads(
preferred_prefix="persons/",
)
source_file = uploads_bundle_dir / canonical
if not source_file.exists():
continue
person_key = str(person_id)
if person_key in existing_person_ids:
continue
suffix = Path(canonical).suffix.lower() or ".jpg"
photo_id = str(uuid4())
relative_path = f"photos/{photo_id}{suffix}"
if source_file.exists():
target_file = uploads_bundle_dir / relative_path
target_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source_file, target_file)
else:
relative_path = canonical
target_file = uploads_bundle_dir / relative_path
target_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source_file, target_file)
is_primary = person_key not in existing_primary_person_ids
photo_rows.append(
{
"id": photo_id,
"person_id": str(person_id),
"person_id": person_key,
"path": relative_path,
"description": None,
"is_primary": True,
"is_primary": is_primary,
"created_at": now_iso,
"updated_at": now_iso,
}
)
existing_person_ids.add(person_key)
if is_primary:
existing_primary_person_ids.add(person_key)
legacy_homepage_dir = uploads_bundle_dir / "homepage"
if not legacy_homepage_dir.exists():
@@ -330,6 +390,8 @@ def _prepare_photo_payload_and_uploads(
],
key=lambda path: (path.stat().st_mtime, path.name),
)
if existing_homepage_rows:
return
for index, image_path in enumerate(homepage_images):
photo_id = str(uuid4())
relative_path = f"photos/{photo_id}{image_path.suffix.lower()}"
@@ -342,7 +404,7 @@ def _prepare_photo_payload_and_uploads(
"person_id": None,
"path": relative_path,
"description": None,
"is_primary": index == 0,
"is_primary": (not has_homepage_primary) and index == 0,
"created_at": now_iso,
"updated_at": now_iso,
}
+1 -1
View File
@@ -35,7 +35,6 @@ def _register_global_styles(app: FastAPI) -> None:
def register_pages(app: FastAPI) -> None:
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
_register_global_styles(app)
register_home_page()
register_documents_page()
register_tags_page()
@@ -45,3 +44,4 @@ def register_pages(app: FastAPI) -> None:
register_jobs_page()
register_settings_page(settings=getattr(app.state, "settings", None) or get_settings())
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False)
_register_global_styles(app)
+196 -69
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from uuid import UUID
from uuid import uuid4
@@ -37,7 +38,6 @@ from transcription.ui.components.table.common import build_table
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
@@ -191,6 +191,11 @@ def register_page() -> None: # noqa: PLR0915
on_click=lambda: ui.navigate.to(f"/people/{person.id}/edit"),
icon="edit",
).classes("ui-btn-primary text-xs")
ui.button(
"Edit Photo(s)",
on_click=lambda: ui.navigate.to(f"/people/{person.id}/photos"),
icon="photo_library",
).props("flat").classes("text-xs ui-link-primary")
destructive_button(
"Delete",
on_click=lambda: ui.navigate.to(f"/people/{person.id}/delete"),
@@ -208,6 +213,140 @@ def register_page() -> None: # noqa: PLR0915
_render_person_biographical_zone(person)
_render_person_biography_zone(person)
@ui.page("/people/{person_id}/photos")
async def person_photos_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
people_service = PeopleService(session_factory=session_factory)
photos_service = PhotosService(session_factory=session_factory)
settings = resolve_runtime_settings(request)
render_navigation_header(current_path="/people")
parsed_person_id = parsed_record_id(person_id, noun="Person")
if parsed_person_id is None:
return
try:
person = await people_service.read_person_detail(parsed_person_id)
except PeopleError:
render_record_not_found("Person")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.photos.read")
return
photos = await photos_service.list_photos(person_id=person.id)
async def on_photo_selected(event) -> None:
payload = await event.file.read()
try:
await photos_service.create_photo(
person_id=person.id,
filename=event.file.name,
file_bytes=payload,
)
except PhotoError as exc:
ui.notify(str(exc), type="negative")
return
ui.notify("Photo uploaded.", type="positive")
ui.navigate.to(f"/people/{person.id}/photos")
@ui.refreshable
def render_gallery() -> None:
with archival_card(title="Photo Gallery", extra_classes="gap-3"):
if not photos:
render_empty_state("No portrait photo uploaded yet.")
return
with ui.grid().classes("w-full grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"):
for photo in photos:
with archival_card(extra_classes="gap-2"):
photo_url = resolve_media_url(
photo.path,
upload_dir=settings.upload_dir,
base_url=str(request.base_url),
)
with ui.element("div").classes("relative w-full"):
ui.image(photo_url).classes("w-full rounded-md")
description_text = photo.description or "No description"
ui.label(description_text).classes(
"absolute inset-x-0 bottom-0 text-center text-white text-xs font-semibold px-2 py-1 bg-black/60 rounded-b-md"
)
if photo.is_primary:
ui.label("Primary").classes(
"absolute top-2 right-2 text-[11px] text-white font-semibold px-2 py-1 bg-primary/80 rounded"
)
description_input = (
ui.input(
label="Description",
value=photo.description or "",
)
.props("outlined dense")
.classes("w-full")
)
async def save_description(
*,
photo_id: UUID = photo.id,
input_control: ui.input = description_input,
) -> None:
try:
await photos_service.update_description(
photo_id=photo_id,
description=(input_control.value or "").strip() or None,
)
except PhotoError as exc:
ui.notify(str(exc), type="negative")
return
ui.notify("Description saved.", type="positive")
ui.navigate.to(f"/people/{person.id}/photos")
async def set_primary(*, photo_id: UUID = photo.id) -> None:
try:
await photos_service.set_primary(photo_id=photo_id)
except PhotoError as exc:
ui.notify(str(exc), type="negative")
return
ui.notify("Primary photo updated.", type="positive")
ui.navigate.to(f"/people/{person.id}/photos")
async def delete_photo(*, photo_id: UUID = photo.id) -> None:
try:
await photos_service.delete_photo(photo_id=photo_id)
except PhotoError as exc:
ui.notify(str(exc), type="negative")
return
ui.notify("Photo deleted.", type="positive")
ui.navigate.to(f"/people/{person.id}/photos")
with ui.row().classes("w-full items-center gap-1"):
ui.button("Save Description", on_click=save_description, icon="save").props("flat dense")
if not photo.is_primary:
ui.button("Set Primary", on_click=set_primary, icon="star").props("flat dense")
ui.button("Delete Photo", on_click=delete_photo, icon="delete").props("flat dense color=negative")
with ui.column().classes("w-full max-w-[1400px] mx-auto p-4 gap-4"):
with section_header_row():
page_header("Edit Photos", subtitle=f"{person.full_name} ({person.id})")
with ui.row().classes("items-center gap-2"):
ui.button("Back to Person", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props(
"flat"
)
ui.upload(
label="",
on_upload=on_photo_selected,
auto_upload=True,
).props(
f'multiple accept="{",".join(sorted(IMAGE_UPLOAD_EXTENSIONS))}"'
).classes("hidden person-photo-upload")
ui.button(
"Upload Photo(s)",
on_click=lambda: ui.run_javascript(
"document.querySelector('.person-photo-upload input[type=file]')?.click()"
),
icon="upload",
).props("flat")
render_gallery()
@ui.page("/people/{person_id}/edit")
async def person_edit_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
people_service = PeopleService(session_factory=session_factory)
@@ -443,84 +582,72 @@ async def _render_person_photo_zone(
request: Request,
) -> None:
photos = await photos_service.list_photos(person_id=person.id)
active_index = [0]
with ui.column().classes("col-span-12 lg:col-span-4"):
with archival_card(title="Photos", extra_classes="gap-3"):
async def on_photo_selected(event) -> None:
payload = await event.file.read()
try:
await photos_service.create_photo(
person_id=person.id,
filename=event.file.name,
file_bytes=payload,
)
except PhotoError as exc:
ui.notify(str(exc), type="negative")
return
ui.notify("Photo uploaded.", type="positive")
ui.navigate.to(f"/people/{person.id}")
render_upload_picker(
on_upload=on_photo_selected,
label="Upload photo(s)",
extensions=IMAGE_UPLOAD_EXTENSIONS,
multiple=True,
_render_photo_viewer_with_navigation(
photos=photos,
active_index=active_index,
settings=settings,
request=request,
empty_message="No portrait photo uploaded yet.",
)
if not photos:
render_empty_state("No portrait photo uploaded yet.")
return
def _shift_gallery_index(*, photos: list, active_index: list[int], step: int) -> None:
if len(photos) < 2:
active_index[0] = 0
return
active_index[0] = (active_index[0] + step) % len(photos)
for photo in photos:
dark_room_viewer(
resolve_media_url(
photo.path,
upload_dir=settings.upload_dir,
base_url=str(request.base_url),
),
count_label="Primary Portrait" if photo.is_primary else "Portrait Media",
)
description_input = (
ui.input(
label="Description",
value=photo.description or "",
)
.props("outlined dense")
.classes("w-full")
)
async def save_description(*, photo_id: UUID = photo.id, input_control: ui.input = description_input) -> None:
try:
await photos_service.update_description(
photo_id=photo_id,
description=(input_control.value or "").strip() or None,
)
except PhotoError as exc:
ui.notify(str(exc), type="negative")
return
ui.navigate.to(f"/people/{person.id}")
def _render_photo_viewer_with_navigation(
*,
photos: list,
active_index: list[int],
settings: Settings,
request: Request,
empty_message: str,
on_change: Callable[[], None] | None = None,
) -> None:
if not photos:
render_empty_state(empty_message)
return
async def set_primary(*, photo_id: UUID = photo.id) -> None:
try:
await photos_service.set_primary(photo_id=photo_id)
except PhotoError as exc:
ui.notify(str(exc), type="negative")
return
ui.navigate.to(f"/people/{person.id}")
if active_index[0] >= len(photos):
active_index[0] = len(photos) - 1
if active_index[0] < 0:
active_index[0] = 0
async def delete_photo(*, photo_id: UUID = photo.id) -> None:
try:
await photos_service.delete_photo(photo_id=photo_id)
except PhotoError as exc:
ui.notify(str(exc), type="negative")
return
ui.navigate.to(f"/people/{person.id}")
current_photo = photos[active_index[0]]
photo_url = resolve_media_url(
current_photo.path,
upload_dir=settings.upload_dir,
base_url=str(request.base_url),
)
dark_room_viewer(
photo_url,
count_label="Primary Portrait" if current_photo.is_primary else "Portrait Media",
)
with ui.row().classes("w-full items-center gap-2 mb-4"):
ui.button("Save description", on_click=save_description, icon="save").props("flat")
if not photo.is_primary:
ui.button("Set primary", on_click=set_primary, icon="star").props("flat")
ui.button("Delete photo", on_click=delete_photo, icon="delete").props("flat color=negative")
description_text = current_photo.description or "No description"
with ui.element("div").classes("relative w-full -mt-16 z-10 pointer-events-none"):
ui.label(description_text).classes(
"mx-auto w-fit max-w-[90%] text-center text-white text-xs font-semibold px-2 py-1 rounded bg-black/60"
)
def move(step: int) -> None:
_shift_gallery_index(photos=photos, active_index=active_index, step=step)
if on_change is not None:
on_change()
with ui.row().classes("w-full items-center justify-between mt-2"):
previous = ui.button("Previous", on_click=lambda: move(-1), icon="chevron_left").props("flat dense")
following = ui.button("Next", on_click=lambda: move(1), icon="chevron_right").props("flat dense icon-right")
if len(photos) < 2:
previous.props("disable")
following.props("disable")
ui.label(f"{active_index[0] + 1} of {len(photos)}").classes("text-xs ui-text-muted")
def _render_person_biographical_zone(person: Person) -> None: