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
+20
View File
@@ -15,6 +15,7 @@ from transcription.db.models import Job
from transcription.db.models import Person
from transcription.db.models import Source
from transcription.db.models import Tag
from transcription.db.models import Photo
from transcription.services.documents import DocumentDeleteBlockedError
from transcription.services.documents import DocumentError
from transcription.services.documents import DocumentService
@@ -275,6 +276,25 @@ async def test_delete_person_succeeds_when_unlinked(default_session_factory):
await service.read_person_detail(person.id)
@pytest.mark.asyncio
async def test_delete_person_blocks_when_photos_exist(default_session_factory):
service = PeopleService(session_factory=default_session_factory)
person = await service.create_person(Person(full_name="Photo Protected Person"))
async with service._session_scope() as session:
session.add(
Photo(
person_id=person.id,
path="photos/sample.png",
is_primary=True,
)
)
await session.commit()
with pytest.raises(PeopleError, match="Photos"):
await service.delete_person(person)
@pytest.mark.asyncio
async def test_create_document_uses_existing_document_type_registry(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
import pytest
from transcription.config import Settings
from transcription.db.models import Person
from transcription.services.people import PeopleService
from transcription.services.photos import PhotosService
PNG_BYTES = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c6360000002000100"
"05fe02fea7b1b8000000004945"
) + b"NDAE\xae\x42\x60\x82"
@pytest.mark.asyncio
async def test_create_photo_persists_media_and_primary_state(default_session_factory, tmp_path):
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path / "uploads")
people = PeopleService(session_factory=default_session_factory)
photos = PhotosService(session_factory=default_session_factory, settings=settings)
person = await people.create_person(Person(full_name="Photo Person"))
first = await photos.create_photo(person_id=person.id, filename="one.png", file_bytes=PNG_BYTES)
second = await photos.create_photo(person_id=person.id, filename="two.png", file_bytes=PNG_BYTES)
listed = await photos.list_photos(person_id=person.id)
assert first.path.startswith("photos/")
assert (settings.upload_dir / first.path).exists()
assert first.is_primary is True
assert second.is_primary is False
assert listed[0].is_primary is True
@pytest.mark.asyncio
async def test_set_primary_and_delete_promotes_next_photo(default_session_factory, tmp_path):
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path / "uploads")
people = PeopleService(session_factory=default_session_factory)
photos = PhotosService(session_factory=default_session_factory, settings=settings)
person = await people.create_person(Person(full_name="Primary Person"))
first = await photos.create_photo(person_id=person.id, filename="one.png", file_bytes=PNG_BYTES)
second = await photos.create_photo(person_id=person.id, filename="two.png", file_bytes=PNG_BYTES)
await photos.set_primary(photo_id=second.id)
switched = await photos.list_photos(person_id=person.id)
assert switched[0].id == second.id
assert switched[0].is_primary is True
await photos.delete_photo(photo_id=second.id)
remaining = await photos.list_photos(person_id=person.id)
assert len(remaining) == 1
assert remaining[0].id == first.id
assert remaining[0].is_primary is True
+7 -7
View File
@@ -11,7 +11,7 @@ from transcription.db.models import Job
from transcription.db.models import JobSource
from transcription.db.models import Source
from transcription.errors import ErrorCategory
from transcription.services.people import store_person_portrait
from transcription.services.photos import PhotosService
from transcription.services.sources import source_mime_type
from transcription.services.store import SourceStorageError
from transcription.services.store import StoredSourceFile
@@ -121,18 +121,18 @@ async def test_create_document_job_stores_source_under_document_id_directory(asy
@pytest.mark.asyncio
async def test_store_person_portrait_stores_file_under_person_id_directory(tmp_path):
async def test_store_person_photo_stores_file_under_shared_photos_directory(default_session_factory, tmp_path):
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
person_id = uuid4()
service = PhotosService(session_factory=default_session_factory, settings=settings)
stored_path = await store_person_portrait(
person_id=person_id,
created = await service.create_photo(
person_id=None,
filename="portrait.png",
file_bytes=b"portrait-bytes",
settings=settings,
)
assert stored_path.parent == (tmp_path / "persons" / str(person_id))
stored_path = tmp_path / created.path
assert stored_path.parent == (tmp_path / "photos")
assert stored_path.exists()
+28 -7
View File
@@ -3,6 +3,7 @@
import warnings
import pytest
import pytest_asyncio
import sqlalchemy as sa
from sqlalchemy import inspect
from sqlalchemy import text
@@ -25,6 +26,13 @@ from transcription.db.models import PersonRole
from transcription.db.models import Source
@pytest_asyncio.fixture(autouse=True)
async def _reset_database_runtime():
await dispose_database_runtime()
yield
await dispose_database_runtime()
@pytest.mark.asyncio
async def test_create_all_creates_expected_tables(tmp_path):
settings = Settings(
@@ -43,6 +51,7 @@ async def test_create_all_creates_expected_tables(tmp_path):
assert "document_type" in table_names
assert "tag" in table_names
assert "person" in table_names
assert "photo" in table_names
assert "person_role" in table_names
assert "document_person" in table_names
assert "document_tag" in table_names
@@ -205,7 +214,7 @@ async def test_reconcile_legacy_job_source_columns_drops_executed_at(tmp_path):
@pytest.mark.asyncio
async def test_reconcile_canonical_media_paths_normalizes_source_and_person_paths(tmp_path):
async def test_reconcile_canonical_media_paths_normalizes_source_and_photo_paths(tmp_path):
settings = Settings(
openrouter_api_key="test-key",
database=SqliteSettings(path=str(tmp_path / "canonical-paths.db")),
@@ -218,10 +227,22 @@ async def test_reconcile_canonical_media_paths_normalizes_source_and_person_path
async with runtime.engine.begin() as connection:
await connection.execute(
text(
'insert into "person" (id, full_name, portrait_path, created_at, updated_at) '
'values (:id, :full_name, :portrait_path, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)'
'insert into "person" (id, full_name, created_at, updated_at) '
'values (:id, :full_name, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)'
),
{"id": "11" * 16, "full_name": "Portrait", "portrait_path": "portraits/person/seeded.png"},
{"id": "11" * 16, "full_name": "Portrait"},
)
await connection.execute(
text(
'insert into "photo" (id, person_id, path, is_primary, created_at, updated_at) '
'values (:id, :person_id, :path, :is_primary, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)'
),
{
"id": "44" * 16,
"person_id": "11" * 16,
"path": "data\\photos\\seeded.png",
"is_primary": 1,
},
)
await connection.execute(
text(
@@ -253,11 +274,11 @@ async def test_reconcile_canonical_media_paths_normalizes_source_and_person_path
source_path = (
await connection.execute(text('select file_path from "source" where id = :id'), {"id": "33" * 16})
).scalar_one()
portrait_path = (
await connection.execute(text('select portrait_path from "person" where id = :id'), {"id": "11" * 16})
photo_path = (
await connection.execute(text('select path from "photo" where id = :id'), {"id": "44" * 16})
).scalar_one()
assert source_path == "documents/doc-1/page.png"
assert portrait_path == "persons/person/seeded.png"
assert photo_path == "photos/seeded.png"
finally:
await dispose_database_runtime()
+1 -2
View File
@@ -167,7 +167,6 @@ def test_env_example_default_values_match_settings_defaults():
"SQLITE_CHECK_SAME_THREAD": str(defaults.sqlite_check_same_thread).lower(),
"UPLOAD_DIR": str(defaults.upload_dir),
"PROMPT_DIR": str(defaults.prompt_dir),
"HOMEPAGE_DIR": str(defaults.homepage_dir),
"DATABASE_BACKUP_DIR": str(defaults.database_backup_dir),
"WORKER_MAX_RETRIES": str(defaults.worker_max_retries),
"WORKER_PROVIDER_TIMEOUT_SECONDS": str(defaults.worker_provider_timeout_seconds),
@@ -176,7 +175,7 @@ def test_env_example_default_values_match_settings_defaults():
"WORKER_FAIL_ON_FINISH_REASON_LENGTH": str(defaults.worker_fail_on_finish_reason_length).lower(),
}
active = _active_env_example_values()
path_like_keys = {"LOG_DIR", "UPLOAD_DIR", "PROMPT_DIR", "HOMEPAGE_DIR", "DATABASE_BACKUP_DIR"}
path_like_keys = {"LOG_DIR", "UPLOAD_DIR", "PROMPT_DIR", "DATABASE_BACKUP_DIR"}
mismatches = {
key: {
"expected": _normalize_env_path_value(expected_value) if key in path_like_keys else expected_value,
@@ -6,6 +6,7 @@ from pathlib import Path
from uuid import uuid4
from sqlalchemy import create_engine
from sqlalchemy import text
from sqlalchemy import select
from sqlmodel import SQLModel
@@ -114,3 +115,70 @@ def test_export_import_migration_round_trips_db_and_uploads(tmp_path):
copied_media_path = target_upload_dir / "documents" / str(document_id) / filename
assert copied_media_path.read_bytes() == b"sample-image"
def test_export_import_migration_backfills_legacy_portraits_and_homepage_images(tmp_path):
source_db_path = tmp_path / "source-legacy.db"
target_db_path = tmp_path / "target-legacy.db"
source_upload_dir = tmp_path / "source_uploads"
target_upload_dir = tmp_path / "target_uploads"
bundle_dir = tmp_path / "bundle-legacy"
source_db_url = sqlite_url_from_path(source_db_path)
target_db_url = sqlite_url_from_path(target_db_path)
person_id = "11" * 16
portrait_file = source_upload_dir / "persons" / "legacy" / "portrait.png"
portrait_file.parent.mkdir(parents=True, exist_ok=True)
portrait_file.write_bytes(b"portrait")
homepage_file = source_upload_dir / "homepage" / "banner.jpg"
homepage_file.parent.mkdir(parents=True, exist_ok=True)
homepage_file.write_bytes(b"homepage")
(source_upload_dir / "homepage" / "homepage.md").write_text("# Legacy Home", encoding="utf-8")
engine = create_engine(source_db_url)
try:
with engine.begin() as connection:
connection.execute(
text(
'create table "person" ('
"id char(32) primary key, "
"full_name varchar not null, "
"portrait_path varchar, "
"created_at datetime not null, "
"updated_at datetime not null"
")"
)
)
connection.execute(
text(
'insert into "person" (id, full_name, portrait_path, created_at, updated_at) '
'values (:id, :full_name, :portrait_path, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)'
),
{"id": person_id, "full_name": "Legacy Portrait", "portrait_path": "persons/legacy/portrait.png"},
)
finally:
engine.dispose()
export_bundle(source_db_url=source_db_url, source_upload_dir=source_upload_dir, bundle_dir=bundle_dir)
import_bundle(target_db_url=target_db_url, target_upload_dir=target_upload_dir, bundle_dir=bundle_dir)
target_engine = create_engine(target_db_url)
try:
with target_engine.connect() as connection:
photos = connection.execute(
text('select person_id, path, is_primary from "photo" order by person_id is not null desc, created_at asc')
).all()
assert len(photos) == 2
person_photo = next(row for row in photos if row[0] is not None)
homepage_photo = next(row for row in photos if row[0] is None)
assert person_photo[2] == 1
assert homepage_photo[2] == 1
assert str(person_photo[1]).startswith("photos/")
assert str(homepage_photo[1]).startswith("photos/")
finally:
target_engine.dispose()
assert (target_upload_dir / str(person_photo[1])).read_bytes() == b"portrait"
assert (target_upload_dir / str(homepage_photo[1])).read_bytes() == b"homepage"
assert (target_upload_dir / "homepage.md").read_text(encoding="utf-8") == "# Legacy Home"
+2
View File
@@ -30,6 +30,7 @@ from transcription.db.models import JobSource
from transcription.db.models import JobSourceStatus
from transcription.db.models import JobStatus
from transcription.db.models import Person
from transcription.db.models import Photo
from transcription.db.models import Source
from transcription.db.models import Tag
@@ -80,6 +81,7 @@ async def clear_ui_database(
await session.exec(delete(Source))
await session.exec(delete(Job))
await session.exec(delete(Document))
await session.exec(delete(Photo))
await session.exec(delete(Person))
await session.exec(delete(Tag))
await session.commit()
+12 -40
View File
@@ -1,64 +1,36 @@
"""Homepage storage resolves its root from settings rather than from `__file__`.
"""Homepage markdown storage tests."""
The previous module derived its directory from ``Path(__file__).parents[3]``,
which could not be configured and resolved into the installed package directory
outside a source checkout.
"""
import pytest
from pathlib import Path
from transcription.config import Settings
from transcription.ui.homepage_store import homepage_dir
from transcription.ui.homepage_store import latest_homepage_image
from transcription.ui.homepage_store import list_homepage_images
from transcription.ui.homepage_store import homepage_markdown_path
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
PNG_BYTES = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c6360000002000100"
"05fe02fea7b1b8000000004945"
) + b"NDAE\xae\x42\x60\x82"
def _settings(tmp_path) -> Settings:
return Settings(openrouter_api_key="test-key-abc123", homepage_dir=tmp_path / "homepage")
def _settings(tmp_path: Path) -> Settings:
return Settings(openrouter_api_key="test-key-abc123", upload_dir=tmp_path / "uploads")
def test_homepage_dir_follows_the_configured_setting(tmp_path):
def test_markdown_path_uses_upload_dir_root(tmp_path):
settings = _settings(tmp_path)
assert homepage_dir(settings) == tmp_path / "homepage"
assert homepage_markdown_path(settings) == tmp_path / "uploads" / "homepage.md"
def test_markdown_round_trips_through_the_configured_directory(tmp_path):
def test_markdown_round_trip_uses_upload_dir_root(tmp_path):
settings = _settings(tmp_path)
assert read_homepage_markdown(settings) == ""
save_homepage_markdown("# Archive", settings)
assert (tmp_path / "homepage" / "homepage.md").read_text(encoding="utf-8") == "# Archive"
assert (tmp_path / "uploads" / "homepage.md").read_text(encoding="utf-8") == "# Archive"
assert read_homepage_markdown(settings) == "# Archive"
@pytest.mark.asyncio
async def test_images_are_stored_and_listed_from_the_configured_directory(tmp_path):
settings = _settings(tmp_path)
assert list_homepage_images(settings) == []
assert latest_homepage_image(settings) is None
stored = await store_homepage_image(filename="banner.png", file_bytes=PNG_BYTES, settings=settings)
assert stored.parent == tmp_path / "homepage"
assert list_homepage_images(settings) == [stored]
assert latest_homepage_image(settings) == stored
def test_two_configurations_do_not_share_storage(tmp_path):
first = Settings(openrouter_api_key="test-key-abc123", homepage_dir=tmp_path / "a")
second = Settings(openrouter_api_key="test-key-abc123", homepage_dir=tmp_path / "b")
def test_two_configurations_do_not_share_markdown_storage(tmp_path):
first = Settings(openrouter_api_key="test-key-abc123", upload_dir=tmp_path / "a")
second = Settings(openrouter_api_key="test-key-abc123", upload_dir=tmp_path / "b")
save_homepage_markdown("first", first)
+14 -9
View File
@@ -12,6 +12,7 @@ from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import Person
from transcription.db.models import PersonRole
from transcription.db.models import Photo
from transcription.db.models import Source
@@ -99,7 +100,6 @@ class TestPeoplePageRendering:
death_date_raw="1992",
death_place="Arlington",
biography="Computer pioneer",
portrait_path="/images/grace.jpg",
family_search_id="G8T4-MDQ",
)
session.add(person)
@@ -132,20 +132,25 @@ class TestPeoplePageRendering:
assert "No linked documents yet." in response.text
@pytest.mark.asyncio
async def test_person_detail_page_resolves_relative_portrait_path(self, app_client):
async def test_person_detail_page_resolves_relative_photo_path(self, app_client):
app, client = app_client
upload_dirs = {app.state.settings.upload_dir, get_settings().upload_dir}
for upload_dir in upload_dirs:
portrait_file = upload_dir / "persons" / "person" / "seeded.png"
portrait_file.parent.mkdir(parents=True, exist_ok=True)
portrait_file.write_bytes(b"portrait")
photo_file = upload_dir / "photos" / "seeded.png"
photo_file.parent.mkdir(parents=True, exist_ok=True)
photo_file.write_bytes(b"portrait")
async with session_scope() as session:
person = Person(
full_name="Portrait Person",
portrait_path="persons/person/seeded.png",
)
person = Person(full_name="Portrait Person")
session.add(person)
await session.flush()
session.add(
Photo(
person_id=person.id,
path="photos/seeded.png",
is_primary=True,
)
)
await session.commit()
person_id = str(person.id)