generated from john/python-template
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user