generated from john/python-template
v5.0 Introduce centralized homepage & portrait photo management
Quality Gate / gate (push) Failing after 11s
Quality Gate / gate (push) Failing after 11s
This commit is contained in:
@@ -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 ---
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 "")
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
Reference in New Issue
Block a user