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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user