v5.0 Introduce centralized homepage & portrait photo management
Quality Gate / gate (push) Failing after 11s

This commit is contained in:
Jim Lancaster
2026-08-23 09:11:36 -05:00
parent efe7785392
commit 86b8e83ff4
26 changed files with 835 additions and 343 deletions
-1
View File
@@ -107,7 +107,6 @@ class Settings(BaseSettings):
# --- filesystem paths ---
upload_dir: Path = Path("./data")
prompt_dir: Path = Path("./prompts")
homepage_dir: Path = Path("./data/homepage")
database_backup_dir: Path = Path("./data/backups")
# --- worker reliability ---
+113 -5
View File
@@ -9,6 +9,7 @@ from datetime import datetime
from pathlib import Path
from typing import Any
from uuid import UUID
from uuid import uuid4
from sqlalchemy import URL
from sqlalchemy import MetaData
@@ -32,6 +33,7 @@ EXPORT_TABLE_ORDER = (
"tag",
"document",
"person",
"photo",
"document_person",
"document_tag",
"job",
@@ -65,9 +67,12 @@ def export_bundle(*, source_db_url: str, source_upload_dir: Path, bundle_dir: Pa
}
engine = create_engine(source_db_url)
legacy_portrait_rows: list[dict[str, Any]] = []
source_has_photo_table = False
try:
inspector = sqlalchemy_inspect(engine)
source_tables = set(inspector.get_table_names())
source_has_photo_table = "photo" in source_tables
metadata = MetaData()
metadata.reflect(bind=engine)
current_metadata = SQLModel.metadata
@@ -83,6 +88,12 @@ def export_bundle(*, source_db_url: str, source_upload_dir: Path, bundle_dir: Pa
export_columns = [
column.name for column in target_table.columns if column.name in source_table.columns
]
if table_name == "person" and "portrait_path" in source_table.columns:
legacy_portrait_rows = connection.execute(
select(source_table.c["id"], source_table.c["portrait_path"]).where(
source_table.c["portrait_path"].is_not(None)
)
).mappings().all()
rows = connection.execute(select(*(source_table.c[name] for name in export_columns))).mappings().all()
payload["tables"][table_name] = [
_serialize_row(row, table_name=table_name, source_upload_dir=source_upload_dir) for row in rows
@@ -90,8 +101,6 @@ def export_bundle(*, source_db_url: str, source_upload_dir: Path, bundle_dir: Pa
finally:
engine.dispose()
export_json.write_text(json.dumps(payload, indent=2), encoding="utf-8")
if uploads_bundle_dir.exists():
shutil.rmtree(uploads_bundle_dir)
if source_upload_dir.exists():
@@ -99,6 +108,16 @@ def export_bundle(*, source_db_url: str, source_upload_dir: Path, bundle_dir: Pa
else:
uploads_bundle_dir.mkdir(parents=True, exist_ok=True)
_prepare_photo_payload_and_uploads(
payload=payload,
uploads_bundle_dir=uploads_bundle_dir,
source_has_photo_table=source_has_photo_table,
legacy_portrait_rows=legacy_portrait_rows,
)
_relocate_homepage_markdown(uploads_bundle_dir=uploads_bundle_dir)
export_json.write_text(json.dumps(payload, indent=2), encoding="utf-8")
def import_bundle(*, target_db_url: str, target_upload_dir: Path, bundle_dir: Path) -> None:
export_json = bundle_dir / "database.json"
@@ -162,11 +181,11 @@ def _serialize_row(row: dict[str, Any], *, table_name: str, source_upload_dir: P
preferred_prefix="documents/",
)
continue
if table_name == "person" and key == "portrait_path" and isinstance(serialized_value, str):
if table_name == "photo" and key == "path" and isinstance(serialized_value, str):
serialized[key] = _canonical_media_relative_path(
serialized_value,
source_upload_dir=source_upload_dir,
preferred_prefix="persons/",
preferred_prefix="photos/",
)
continue
serialized[key] = serialized_value
@@ -237,7 +256,7 @@ def _canonical_media_relative_path(value: str, *, source_upload_dir: Path, prefe
normalized = "persons/" + normalized[len("portraits/") :]
lowered = normalized.casefold()
for prefix in ("documents/", "persons/"):
for prefix in ("documents/", "photos/", "persons/"):
marker = f"/{prefix}"
index = lowered.find(marker)
if index >= 0:
@@ -248,3 +267,92 @@ def _canonical_media_relative_path(value: str, *, source_upload_dir: Path, prefe
if not lowered.startswith(preferred_prefix):
return normalized
return Path(normalized).as_posix()
def _prepare_photo_payload_and_uploads(
*,
payload: dict[str, Any],
uploads_bundle_dir: Path,
source_has_photo_table: bool,
legacy_portrait_rows: list[dict[str, Any]],
) -> None:
photo_rows = payload.setdefault("tables", {}).setdefault("photo", [])
photos_dir = uploads_bundle_dir / "photos"
photos_dir.mkdir(parents=True, exist_ok=True)
if source_has_photo_table:
return
now_iso = datetime.now().isoformat()
for row in legacy_portrait_rows:
portrait_path = row.get("portrait_path")
person_id = row.get("id")
if not isinstance(portrait_path, str) or not portrait_path.strip():
continue
if person_id is None:
continue
canonical = _canonical_media_relative_path(
portrait_path,
source_upload_dir=uploads_bundle_dir,
preferred_prefix="persons/",
)
source_file = uploads_bundle_dir / canonical
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
photo_rows.append(
{
"id": photo_id,
"person_id": str(person_id),
"path": relative_path,
"description": None,
"is_primary": True,
"created_at": now_iso,
"updated_at": now_iso,
}
)
legacy_homepage_dir = uploads_bundle_dir / "homepage"
if not legacy_homepage_dir.exists():
return
homepage_images = sorted(
[
path
for path in legacy_homepage_dir.iterdir()
if path.is_file() and path.suffix.lower() in {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff"}
],
key=lambda path: (path.stat().st_mtime, path.name),
)
for index, image_path in enumerate(homepage_images):
photo_id = str(uuid4())
relative_path = f"photos/{photo_id}{image_path.suffix.lower()}"
target_file = uploads_bundle_dir / relative_path
target_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(image_path, target_file)
photo_rows.append(
{
"id": photo_id,
"person_id": None,
"path": relative_path,
"description": None,
"is_primary": index == 0,
"created_at": now_iso,
"updated_at": now_iso,
}
)
def _relocate_homepage_markdown(*, uploads_bundle_dir: Path) -> None:
legacy_markdown = uploads_bundle_dir / "homepage" / "homepage.md"
target_markdown = uploads_bundle_dir / "homepage.md"
if not legacy_markdown.exists() or target_markdown.exists():
return
target_markdown.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(legacy_markdown, target_markdown)
+26 -1
View File
@@ -186,7 +186,6 @@ class Person(SQLModel, table=True):
death_date_raw: str | None = None
death_place: str | None = None
biography: str | None = None
portrait_path: str | None = None
family_search_id: str | None = Field(default=None, unique=True)
metadata_: dict[str, JsonValue] | None = Field(
default=None,
@@ -201,6 +200,32 @@ class Person(SQLModel, table=True):
document_people: list["DocumentPerson"] = Relationship(
back_populates="person", sa_relationship_kwargs={"lazy": "raise"}
)
photos: list["Photo"] = Relationship(
back_populates="person",
sa_relationship_kwargs={"lazy": "raise"},
)
class Photo(SQLModel, table=True):
"""A reusable image record for Person and homepage galleries."""
__tablename__ = "photo"
id: UUID = Field(default_factory=uuid4, primary_key=True)
person_id: UUID | None = Field(default=None, foreign_key="person.id", index=True)
path: str
description: str | None = None
is_primary: bool = False
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(UTC),
sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)},
)
person: Optional["Person"] = Relationship(
back_populates="photos",
sa_relationship_kwargs={"lazy": "raise"},
)
class DocumentPerson(SQLModel, table=True):
+7 -9
View File
@@ -93,18 +93,16 @@ async def reconcile_canonical_media_paths(*, engine: AsyncEngine | None = None)
)
rows_changed += 1
if "person" in table_names:
rows = sync_connection.execute(
text('select id, portrait_path from "person" where portrait_path is not null')
).mappings().all()
if "photo" in table_names:
rows = sync_connection.execute(text('select id, path from "photo" where path is not null')).mappings().all()
for row in rows:
original = str(row["portrait_path"])
normalized = _canonical_relative_path(original, preferred_prefix="persons/")
original = str(row["path"])
normalized = _canonical_relative_path(original, preferred_prefix="photos/")
if normalized is None or normalized == original:
continue
sync_connection.execute(
text('update "person" set portrait_path = :portrait_path where id = :id'),
{"id": row["id"], "portrait_path": normalized},
text('update "photo" set path = :path where id = :id'),
{"id": row["id"], "path": normalized},
)
rows_changed += 1
return rows_changed
@@ -135,7 +133,7 @@ def _canonical_relative_path(value: str, *, preferred_prefix: str) -> str | None
normalized = normalized[len("data/") :]
lowered = normalized.casefold()
for prefix in ("documents/", "persons/", "portraits/"):
for prefix in ("documents/", "photos/", "persons/", "portraits/"):
marker = f"/{prefix}"
index = lowered.find(marker)
if index >= 0:
+4
View File
@@ -12,6 +12,7 @@ from .documents import DocumentService
from .evidence import EvidenceService
from .jobs import JobService
from .people import PeopleService
from .photos import PhotosService
from .prompts import PromptStore
from .sources import SourceService
@@ -20,6 +21,7 @@ __all__ = [
"EvidenceService",
"JobService",
"PeopleService",
"PhotosService",
"PromptStore",
"ServiceBundle",
"SourceService",
@@ -34,6 +36,7 @@ class ServiceBundle:
sources: SourceService = field(default_factory=SourceService)
jobs: JobService = field(default_factory=JobService)
people: PeopleService = field(default_factory=PeopleService)
photos: PhotosService = field(default_factory=PhotosService)
evidence: EvidenceService = field(default_factory=EvidenceService)
@classmethod
@@ -51,6 +54,7 @@ class ServiceBundle:
sources=SourceService(session_factory=session_factory, settings=settings),
jobs=JobService(session_factory=session_factory, settings=settings),
people=PeopleService(session_factory=session_factory, settings=settings),
photos=PhotosService(session_factory=session_factory, settings=settings),
evidence=EvidenceService(session_factory=session_factory, settings=settings),
)
+9 -44
View File
@@ -6,7 +6,6 @@ import logging
import re
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from uuid import UUID
@@ -18,23 +17,21 @@ from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..config import Settings
from ..config import get_settings
from ..db.loading import orm_attribute
from ..db.loading import selectinload
from ..db.models import Document
from ..db.models import DocumentPerson
from ..db.models import Photo
from ..db.models import Person
from ..db.models import PersonRole
from ..errors import AppError
from ..errors import ErrorCategory
from .base import ServiceBase
from .media_storage import persist_named_media
from .registry import RegistryService
from .registry import RegistrySummary
logger = logging.getLogger(__name__)
PORTRAIT_EXTENSIONS = frozenset({".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff"})
FAMILY_SEARCH_ID_PATTERN = re.compile(r"^[A-Z0-9]{4}-[A-Z0-9]{3}$")
@@ -42,10 +39,6 @@ class PeopleError(AppError):
"""Raised when a Person or document-person relationship operation fails."""
class PersonMediaError(PeopleError):
"""Raised when Person portrait media cannot be validated or persisted."""
class PersonRoleError(PeopleError):
"""Raised when Person Role maintenance fails."""
@@ -137,10 +130,16 @@ class PeopleService(ServiceBase):
existing = await _session.get(
Person,
person.id,
options=(selectinload(Person.document_people),),
options=(selectinload(Person.document_people), selectinload(Person.photos)),
)
if existing is None:
raise self._not_found(f"Person with id {person.id} not found")
if existing.photos:
raise PeopleError(
"Person delete blocked by related records: Photos",
category=ErrorCategory.VALIDATION,
suggestion="Delete or reassign Person photos before deleting this record.",
)
for link in list(existing.document_people):
await _session.delete(link)
await _session.delete(existing)
@@ -206,6 +205,7 @@ class PeopleService(ServiceBase):
.selectinload(orm_attribute(DocumentPerson.document))
.selectinload(orm_attribute(Document.sources)),
selectinload(Person.document_people).selectinload(orm_attribute(DocumentPerson.role_ref)),
selectinload(Person.photos),
)
.where(Person.id == person_id)
.execution_options(populate_existing=True)
@@ -513,38 +513,3 @@ class PeopleService(ServiceBase):
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the requested Person or relationship id and retry.",
)
async def store_person_portrait(
*,
person_id: UUID,
filename: str,
file_bytes: bytes,
settings: Settings | None = None,
) -> Path:
"""Persist Person portrait media under persons/<person_id>."""
if not file_bytes:
raise PersonMediaError(
"Person portrait content is empty",
category=ErrorCategory.VALIDATION,
suggestion="Select a non-empty portrait file and retry.",
)
suffix = Path(filename).suffix.lower()
if suffix not in PORTRAIT_EXTENSIONS:
raise PersonMediaError(
f"Unsupported portrait format: {suffix or '<none>'}",
category=ErrorCategory.USER_INPUT,
suggestion="Use JPG, JPEG, PNG, GIF, WEBP, BMP, or TIFF portrait media.",
)
runtime_settings = settings or get_settings()
return await persist_named_media(
root=runtime_settings.upload_dir,
namespace=Path("persons") / str(person_id),
filename=filename,
file_bytes=file_bytes,
error=PersonMediaError,
failure_message="Failed to persist Person portrait media",
failure_suggestion="Check media directory permissions and available disk space, then retry.",
log_label="Person portrait media",
)
+209
View File
@@ -0,0 +1,209 @@
"""Photo service for Person and homepage image records."""
from __future__ import annotations
import asyncio
import random
from pathlib import Path
from uuid import UUID
from uuid import uuid4
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..config import Settings
from ..db.models import Photo
from ..errors import AppError
from ..errors import ErrorCategory
from .base import ServiceBase
from .media_storage import persist_named_media
PHOTO_EXTENSIONS = frozenset({".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff"})
class PhotoError(AppError):
"""Raised when photo operations fail."""
class PhotosService(ServiceBase):
"""Manage homepage and Person photos."""
def __init__(
self,
session_factory: async_sessionmaker[AsyncSession] | None = None,
settings: Settings | None = None,
) -> None:
super().__init__(session_factory, settings)
async def create_photo(
self,
*,
filename: str,
file_bytes: bytes,
person_id: UUID | None,
description: str | None = None,
is_primary: bool | None = None,
session: AsyncSession | None = None,
) -> Photo:
if not file_bytes:
raise PhotoError(
"Photo content is empty",
category=ErrorCategory.VALIDATION,
suggestion="Select a non-empty image file and retry.",
)
suffix = Path(filename).suffix.lower()
if suffix not in PHOTO_EXTENSIONS:
raise PhotoError(
f"Unsupported photo format: {suffix or '<none>'}",
category=ErrorCategory.USER_INPUT,
suggestion="Use JPG, JPEG, PNG, GIF, WEBP, BMP, or TIFF image files.",
)
photo_id = uuid4()
async with self._session_scope(session) as _session:
existing = await self._list_owner_photos(session=_session, person_id=person_id)
should_be_primary = bool(is_primary) if is_primary is not None else len(existing) == 0
if should_be_primary:
await self._clear_owner_primary(session=_session, person_id=person_id)
stored_path = await persist_named_media(
root=self.settings.upload_dir,
namespace=Path("photos"),
filename=filename,
filename_stem=str(photo_id),
file_bytes=file_bytes,
error=PhotoError,
failure_message="Failed to persist photo media",
failure_suggestion="Check media directory permissions and available disk space, then retry.",
log_label="photo media",
)
relative_path = self._relative_upload_path(stored_path)
photo = Photo(
id=photo_id,
person_id=person_id,
path=relative_path,
description=(description or "").strip() or None,
is_primary=should_be_primary,
)
_session.add(photo)
await self._finalize(session=_session, caller_session=session, refresh=(photo,))
return photo
async def list_photos(
self,
*,
person_id: UUID | None,
session: AsyncSession | None = None,
) -> list[Photo]:
async with self._session_scope(session) as _session:
photos = await self._list_owner_photos(session=_session, person_id=person_id)
primary = [photo for photo in photos if photo.is_primary]
non_primary = [photo for photo in photos if not photo.is_primary]
random.shuffle(non_primary)
return [*primary[:1], *non_primary]
async def set_primary(
self,
*,
photo_id: UUID,
session: AsyncSession | None = None,
) -> Photo:
async with self._session_scope(session) as _session:
photo = await self._get_or_raise(
Photo,
photo_id,
session=_session,
error=PhotoError,
noun="Photo",
suggestion="Refresh and retry with a valid photo record.",
)
await self._clear_owner_primary(session=_session, person_id=photo.person_id)
photo.is_primary = True
await self._finalize(session=_session, caller_session=session, refresh=(photo,))
return photo
async def update_description(
self,
*,
photo_id: UUID,
description: str | None,
session: AsyncSession | None = None,
) -> Photo:
async with self._session_scope(session) as _session:
photo = await self._get_or_raise(
Photo,
photo_id,
session=_session,
error=PhotoError,
noun="Photo",
suggestion="Refresh and retry with a valid photo record.",
)
photo.description = (description or "").strip() or None
await self._finalize(session=_session, caller_session=session, refresh=(photo,))
return photo
async def delete_photo(
self,
*,
photo_id: UUID,
session: AsyncSession | None = None,
) -> None:
async with self._session_scope(session) as _session:
photo = await self._get_or_raise(
Photo,
photo_id,
session=_session,
error=PhotoError,
noun="Photo",
suggestion="Refresh and retry with a valid photo record.",
)
owner_person_id = photo.person_id
deleted_primary = photo.is_primary
media_path = self.settings.upload_dir / Path(photo.path)
await _session.delete(photo)
if deleted_primary:
replacement = await self._owner_oldest_photo(session=_session, person_id=owner_person_id)
if replacement is not None:
replacement.is_primary = True
await self._finalize(session=_session, caller_session=session)
await asyncio.to_thread(media_path.unlink, missing_ok=True)
async def _list_owner_photos(self, *, session: AsyncSession, person_id: UUID | None) -> list[Photo]:
query = select(Photo)
if person_id is None:
query = query.where(Photo.person_id.is_(None))
else:
query = query.where(Photo.person_id == person_id)
query = query.order_by(Photo.created_at.asc(), Photo.id.asc())
return list((await session.exec(query)).all())
async def _owner_oldest_photo(self, *, session: AsyncSession, person_id: UUID | None) -> Photo | None:
query = select(Photo)
if person_id is None:
query = query.where(Photo.person_id.is_(None))
else:
query = query.where(Photo.person_id == person_id)
query = query.order_by(Photo.created_at.asc(), Photo.id.asc()).limit(1)
return (await session.exec(query)).first()
async def _clear_owner_primary(self, *, session: AsyncSession, person_id: UUID | None) -> None:
query = select(Photo).where(Photo.is_primary.is_(True))
if person_id is None:
query = query.where(Photo.person_id.is_(None))
else:
query = query.where(Photo.person_id == person_id)
for current in (await session.exec(query)).all():
current.is_primary = False
def _relative_upload_path(self, absolute_path: Path) -> str:
try:
return absolute_path.resolve().relative_to(self.settings.upload_dir.resolve()).as_posix()
except ValueError:
return absolute_path.name
@@ -7,7 +7,7 @@ from urllib.parse import quote
_ABSOLUTE_SCHEMES = ("http://", "https://", "data:")
_UPLOAD_ROUTE_PREFIX = "/uploads/"
_CANONICAL_PREFIXES = ("documents/", "persons/")
_CANONICAL_PREFIXES = ("documents/", "persons/", "photos/")
def absolute_upload_url(path: str, *, base_url: str) -> str:
+8 -74
View File
@@ -1,11 +1,4 @@
"""File-backed storage helpers for the homepage content.
The homepage storage root is a configured setting (``homepage_dir``) like every
other storage root, rather than a path derived from this module's location. The
previous ``Path(__file__).parents[3]`` form was both unconfigurable and wrong
outside a source checkout, since an installed distribution would resolve it into
the package directory.
"""
"""File-backed storage helpers for homepage markdown content."""
from __future__ import annotations
@@ -13,38 +6,20 @@ from pathlib import Path
from transcription.config import Settings
from transcription.config import get_settings
from transcription.errors import AppError
from transcription.services.media_storage import persist_named_media
HOME_PAGE_MARKDOWN_NAME = "homepage.md"
SUPPORTED_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff"}
class HomepageStorageError(AppError):
"""Raised when homepage media cannot be persisted."""
def homepage_dir(settings: Settings | None = None) -> Path:
"""Return the configured homepage storage directory."""
return (settings or get_settings()).homepage_dir
def homepage_markdown_path(settings: Settings | None = None) -> Path:
"""Return the configured homepage markdown file path."""
return homepage_dir(settings) / HOME_PAGE_MARKDOWN_NAME
def ensure_homepage_storage(settings: Settings | None = None) -> Path:
"""Create the homepage storage directory when needed and return it."""
directory = homepage_dir(settings)
directory.mkdir(parents=True, exist_ok=True)
return directory
"""Return the homepage markdown file path under upload storage."""
runtime_settings = settings or get_settings()
return runtime_settings.upload_dir / HOME_PAGE_MARKDOWN_NAME
def read_homepage_markdown(settings: Settings | None = None) -> str:
"""Read the saved homepage markdown text."""
ensure_homepage_storage(settings)
path = homepage_markdown_path(settings)
path.parent.mkdir(parents=True, exist_ok=True)
if not path.exists():
return ""
return path.read_text(encoding="utf-8")
@@ -52,47 +27,6 @@ def read_homepage_markdown(settings: Settings | None = None) -> str:
def save_homepage_markdown(markdown_text: str, settings: Settings | None = None) -> None:
"""Persist the homepage markdown text."""
ensure_homepage_storage(settings)
homepage_markdown_path(settings).write_text(markdown_text, encoding="utf-8")
async def store_homepage_image(
*,
filename: str,
file_bytes: bytes,
settings: Settings | None = None,
) -> Path:
"""Persist an uploaded homepage image in the shared homepage folder."""
safe_name = Path(filename).name
if not safe_name:
msg = "Homepage image filename is required"
raise ValueError(msg)
return await persist_named_media(
root=homepage_dir(settings),
filename=safe_name,
preserve_original_name=True,
file_bytes=file_bytes,
error=HomepageStorageError,
failure_message="Failed to persist homepage image",
failure_suggestion="Check homepage directory permissions and available disk space, then retry.",
log_label="homepage image",
)
def list_homepage_images(settings: Settings | None = None) -> list[Path]:
"""List stored homepage images in the order they were last updated."""
directory = ensure_homepage_storage(settings)
image_paths = [
path for path in directory.iterdir() if path.is_file() and path.suffix.lower() in SUPPORTED_IMAGE_SUFFIXES
]
return sorted(image_paths, key=lambda path: (path.stat().st_mtime, path.name))
def latest_homepage_image(settings: Settings | None = None) -> Path | None:
"""Return the most recently updated homepage image, if one exists."""
image_paths = list_homepage_images(settings)
if not image_paths:
return None
return image_paths[-1]
path = homepage_markdown_path(settings)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(markdown_text, encoding="utf-8")
+99 -28
View File
@@ -3,54 +3,71 @@
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from fastapi import Request
from nicegui import events
from nicegui import ui
from transcription.config import Settings
from transcription.db.models import Photo
from transcription.services.photos import PhotoError
from transcription.services.photos import PhotosService
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card
from transcription.ui.components.media_urls import resolve_media_url
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 list_homepage_images
from transcription.ui.homepage_store import read_homepage_markdown
from transcription.ui.homepage_store import save_homepage_markdown
from transcription.ui.homepage_store import store_homepage_image
from transcription.ui.runtime import resolve_runtime_settings
from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep
def _shift_gallery_index(*, image_paths: list[Path], active_index: list[int], step: int) -> None:
if len(image_paths) < 2:
def _shift_gallery_index(*, photos: list[Photo], active_index: list[int], step: int) -> None:
if len(photos) < 2:
active_index[0] = 0
return
active_index[0] = (active_index[0] + step) % len(image_paths)
active_index[0] = (active_index[0] + step) % len(photos)
def _render_homepage_gallery(
*,
image_paths: list[Path],
photos: list[Photo],
active_index: list[int],
settings: Settings,
base_url: str,
enable_rotation: bool = False,
rotate_enabled: list[bool] | None = None,
on_change: Callable[[], None] | None = None,
) -> None:
if not image_paths:
if not photos:
render_empty_state("No homepage image uploaded yet.")
return
if active_index[0] >= len(image_paths):
active_index[0] = len(image_paths) - 1
if active_index[0] >= len(photos):
active_index[0] = len(photos) - 1
if active_index[0] < 0:
active_index[0] = 0
current_path = image_paths[active_index[0]]
dark_room_viewer(str(current_path), count_label="Homepage Image")
current_photo = photos[active_index[0]]
dark_room_viewer(
resolve_media_url(current_photo.path, upload_dir=settings.upload_dir, base_url=base_url),
count_label="Homepage Image",
)
if current_photo.description:
ui.label(current_photo.description).classes("text-xs")
else:
ui.label("No description").classes("text-xs ui-text-muted")
if current_photo.is_primary:
ui.label("Featured image").classes("text-[11px] font-semibold ui-text-primary")
def move(step: int) -> None:
_shift_gallery_index(image_paths=image_paths, active_index=active_index, step=step)
_shift_gallery_index(photos=photos, active_index=active_index, step=step)
if on_change is not None:
on_change()
@@ -65,10 +82,10 @@ def _render_homepage_gallery(
on_click=lambda: move(1),
icon="chevron_right",
).props("flat dense icon-right")
if len(image_paths) < 2:
if len(photos) < 2:
previous.props("disable")
following.props("disable")
ui.label(f"{active_index[0] + 1} of {len(image_paths)}").classes("text-xs ui-text-muted")
ui.label(f"{active_index[0] + 1} of {len(photos)}").classes("text-xs ui-text-muted")
if enable_rotation and rotate_enabled is not None:
def set_rotation(enabled: bool) -> None:
@@ -124,17 +141,21 @@ def register_page() -> None:
"""Register the homepage routes."""
@ui.page("/homepage", title="VibeScribe Home")
def homepage_page() -> None:
async def homepage_page(request: Request, session_factory: SessionFactoryDep) -> None:
photos_service = PhotosService(session_factory=session_factory)
settings = resolve_runtime_settings(request)
render_navigation_header(current_path="/homepage")
image_paths = list_homepage_images()
active_index = [len(image_paths) - 1 if image_paths else 0]
photos = await photos_service.list_photos(person_id=None)
active_index = [0]
@ui.refreshable
def render_image_panel() -> None:
with archival_card(title="Homepage Images"):
_render_homepage_gallery(
image_paths=image_paths,
photos=photos,
active_index=active_index,
settings=settings,
base_url=str(request.base_url),
on_change=render_image_panel.refresh,
)
@@ -152,11 +173,13 @@ def register_page() -> None:
)
@ui.page("/homepage/edit", title="Edit Homepage")
def homepage_edit_page() -> None:
async def homepage_edit_page(request: Request, session_factory: SessionFactoryDep) -> None:
photos_service = PhotosService(session_factory=session_factory)
settings = resolve_runtime_settings(request)
render_navigation_header(current_path="/homepage")
preview_images = [*list_homepage_images()]
active_index = [len(preview_images) - 1 if preview_images else 0]
photos = await photos_service.list_photos(person_id=None)
active_index = [0]
rotate_enabled = [False]
markdown_input = [None]
@@ -164,28 +187,76 @@ def register_page() -> None:
def render_image_panel() -> None:
with archival_card(title="Homepage Images"):
_render_homepage_gallery(
image_paths=preview_images,
photos=photos,
active_index=active_index,
settings=settings,
base_url=str(request.base_url),
enable_rotation=True,
rotate_enabled=rotate_enabled,
on_change=render_image_panel.refresh,
)
if photos:
current_photo = photos[active_index[0]]
description_input = ui.input(
label="Image description",
value=current_photo.description or "",
).props("outlined dense").classes("w-full")
async def save_description() -> None:
try:
await photos_service.update_description(
photo_id=current_photo.id,
description=(description_input.value or "").strip() or None,
)
except PhotoError as exc:
ui.notify(str(exc), type="negative")
return
ui.navigate.to("/homepage/edit")
async def set_featured() -> None:
try:
await photos_service.set_primary(photo_id=current_photo.id)
except PhotoError as exc:
ui.notify(str(exc), type="negative")
return
ui.navigate.to("/homepage/edit")
async def delete_photo() -> None:
try:
await photos_service.delete_photo(photo_id=current_photo.id)
except PhotoError as exc:
ui.notify(str(exc), type="negative")
return
ui.navigate.to("/homepage/edit")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Save description", on_click=save_description, icon="save").props("flat")
if not current_photo.is_primary:
ui.button("Set featured", on_click=set_featured, icon="star").props("flat")
ui.button("Delete image", on_click=delete_photo, icon="delete").props("flat color=negative")
def rotate_gallery() -> None:
if not rotate_enabled[0]:
return
_shift_gallery_index(image_paths=preview_images, active_index=active_index, step=1)
_shift_gallery_index(photos=photos, active_index=active_index, step=1)
render_image_panel.refresh()
ui.timer(interval=600, callback=rotate_gallery)
async def on_upload(event: events.UploadEventArguments) -> None:
payload = await event.file.read()
stored = await store_homepage_image(filename=event.file.name, file_bytes=payload)
preview_images.append(stored)
active_index[0] = len(preview_images) - 1
try:
await photos_service.create_photo(
person_id=None,
filename=event.file.name,
file_bytes=payload,
)
except PhotoError as exc:
ui.notify(str(exc), type="negative")
return
ui.notify(f"Uploaded {event.file.name}", type="positive")
render_image_panel.refresh()
ui.navigate.to("/homepage/edit")
async def save_homepage() -> None:
save_homepage_markdown((markdown_input[0].value if markdown_input[0] is not None else "") or "")
+91 -67
View File
@@ -7,7 +7,6 @@ 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
@@ -15,8 +14,8 @@ from transcription.db.models import Person
from transcription.errors import ErrorCategory
from transcription.services.people import PeopleError
from transcription.services.people import PeopleService
from transcription.services.people import PersonMediaError
from transcription.services.people import store_person_portrait
from transcription.services.photos import PhotoError
from transcription.services.photos import PhotosService
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
@@ -60,7 +59,6 @@ class PersonFormFields:
death_date_raw: ui.input
death_place: ui.input
biography: ui.textarea
portrait_path: ui.input
family_search_id: ui.input
@@ -117,8 +115,6 @@ def register_page() -> None: # noqa: PLR0915
page_header("Create Person Record", subtitle="Full name is required.")
form = _render_person_form_fields(
request=request,
person_id=draft_person_id,
)
async def submit_create() -> None:
@@ -142,7 +138,6 @@ def register_page() -> None: # noqa: PLR0915
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,
)
@@ -165,6 +160,7 @@ def register_page() -> None: # noqa: PLR0915
@ui.page("/people/{person_id}")
async def person_detail_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
people_service = PeopleService(session_factory=session_factory)
photos_service = PhotosService(session_factory=session_factory)
render_navigation_header(current_path="/people")
parsed_person_id = parsed_record_id(person_id, noun="Person")
@@ -203,8 +199,9 @@ def register_page() -> None: # noqa: PLR0915
)
with ui.grid().classes("w-full grid-cols-12 gap-4"):
_render_person_portrait_zone(
await _render_person_photo_zone(
person,
photos_service=photos_service,
settings=resolve_runtime_settings(request),
request=request,
)
@@ -233,9 +230,7 @@ def register_page() -> None: # noqa: PLR0915
page_header("Edit Person Record", subtitle="Full name is required.")
form = _render_person_form_fields(
request=request,
person=person,
person_id=person.id,
)
async def submit_edit() -> None:
@@ -259,7 +254,6 @@ def register_page() -> None: # noqa: PLR0915
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,
@@ -345,9 +339,7 @@ def register_page() -> None: # noqa: PLR0915
def _render_person_form_fields(
*,
request: Request,
person: Person | None = None,
person_id: UUID,
) -> PersonFormFields:
with archival_card(extra_classes="gap-3"):
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
@@ -418,11 +410,6 @@ def _render_person_form_fields(
.props("outlined autogrow")
.classes("w-full ui-form-surface")
)
portrait_path_input = (
ui.input(label="Portrait path", value=person.portrait_path if person and person.portrait_path else "")
.props("outlined")
.classes("w-full ui-form-surface")
)
family_search_id_input = (
ui.input(
label="FamilySearch ID",
@@ -433,12 +420,6 @@ def _render_person_form_fields(
.classes("w-full ui-form-surface")
)
_bind_portrait_file_picker(
portrait_path_input,
settings=resolve_runtime_settings(request),
person_id=person_id,
)
return PersonFormFields(
full_name=full_name_input,
display_name=display_name_input,
@@ -450,19 +431,96 @@ def _render_person_form_fields(
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_media_url(
person.portrait_path,
upload_dir=settings.upload_dir,
base_url=str(request.base_url),
)
async def _render_person_photo_zone(
person: Person,
*,
photos_service: PhotosService,
settings: Settings,
request: Request,
) -> None:
photos = await photos_service.list_photos(person_id=person.id)
with ui.column().classes("col-span-12 lg:col-span-4"):
dark_room_viewer(portrait_src, count_label="Portrait Media")
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,
)
if not photos:
render_empty_state("No portrait photo uploaded yet.")
return
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}")
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}")
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}")
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")
def _render_person_biographical_zone(person: Person) -> None:
@@ -577,38 +635,4 @@ def _render_linked_documents(person: Person) -> None:
)
# --- Utilities & Input Binding Helpers ---
def _bind_portrait_file_picker(portrait_path_input: ui.input, *, settings: Settings, person_id: UUID) -> None:
async def on_portrait_selected(event: events.UploadEventArguments) -> None:
payload = await event.file.read()
try:
stored_path = await store_person_portrait(
person_id=person_id,
filename=event.file.name,
file_bytes=payload,
settings=settings,
)
except PersonMediaError as exc:
ui.notify(str(exc), type="negative")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Upload failed", operation="people.portrait.store")
return
try:
relative_path = stored_path.resolve().relative_to(settings.upload_dir.resolve()).as_posix()
except ValueError:
relative_path = stored_path.name
portrait_path_input.value = relative_path
ui.notify("Portrait uploaded.", type="positive")
render_upload_picker(
on_upload=on_portrait_selected,
label="Choose portrait file",
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")
# --- Utilities ---