From f6d5353b3bb0008b169a17feb7e5a2bd783ca254 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 18 May 2026 20:15:07 -0400 Subject: [PATCH] feat(provenance): per-image artist on gallery scroll payload Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/api/gallery.py | 1 + backend/app/services/gallery_service.py | 46 +++++++++++- tests/test_gallery_artist_payload.py | 94 +++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 tests/test_gallery_artist_payload.py diff --git a/backend/app/api/gallery.py b/backend/app/api/gallery.py index e24c6e0..b14d414 100644 --- a/backend/app/api/gallery.py +++ b/backend/app/api/gallery.py @@ -43,6 +43,7 @@ async def scroll(): "height": i.height, "created_at": i.created_at.isoformat(), "thumbnail_url": i.thumbnail_url, + "artist": i.artist, } for i in page.images ], diff --git a/backend/app/services/gallery_service.py b/backend/app/services/gallery_service.py index 217e5ba..25d5178 100644 --- a/backend/app/services/gallery_service.py +++ b/backend/app/services/gallery_service.py @@ -13,7 +13,7 @@ from datetime import datetime from sqlalchemy import and_, exists, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession -from ..models import ImageProvenance, ImageRecord, Source, Tag +from ..models import Artist, ImageProvenance, ImageRecord, Post, Source, Tag from ..models.tag import image_tag CURSOR_SEPARATOR = "|" @@ -43,6 +43,7 @@ class GalleryImage: height: int | None created_at: datetime thumbnail_url: str + artist: dict | None = None @dataclass(frozen=True) @@ -92,6 +93,47 @@ def _provenance_clause(post_id, artist_id): return None +async def _artists_for(session, image_ids: list[int]) -> dict[int, dict]: + """Map image_id -> {"name","slug"} using the primary-post path first, + then the lowest-id ImageProvenance row as fallback. Bounded by page + size (<= 200) → two indexed selects, no N+1.""" + if not image_ids: + return {} + out: dict[int, dict] = {} + + primary = ( + select(ImageRecord.id, Artist.name, Artist.slug) + .join(Post, Post.id == ImageRecord.primary_post_id) + .join(Source, Source.id == Post.source_id) + .join(Artist, Artist.id == Source.artist_id) + .where(ImageRecord.id.in_(image_ids)) + ) + for img_id, name, slug in (await session.execute(primary)).all(): + out[img_id] = {"name": name, "slug": slug} + + remaining = [i for i in image_ids if i not in out] + if remaining: + fb = ( + select( + ImageProvenance.image_record_id, + ImageProvenance.id, + Artist.name, + Artist.slug, + ) + .join(Source, Source.id == ImageProvenance.source_id) + .join(Artist, Artist.id == Source.artist_id) + .where(ImageProvenance.image_record_id.in_(remaining)) + .order_by( + ImageProvenance.image_record_id.asc(), + ImageProvenance.id.asc(), + ) + ) + for img_id, _pid, name, slug in (await session.execute(fb)).all(): + if img_id not in out: # first (lowest id) wins + out[img_id] = {"name": name, "slug": slug} + return out + + class GalleryService: def __init__(self, session: AsyncSession): self.session = session @@ -135,6 +177,7 @@ class GalleryService: next_cursor = encode_cursor(last.created_at, last.id) rows = rows[:limit] + artists = await _artists_for(self.session, [r.id for r in rows]) images = [ GalleryImage( id=r.id, @@ -145,6 +188,7 @@ class GalleryService: height=r.height, created_at=r.created_at, thumbnail_url=thumbnail_url(r.sha256, r.mime), + artist=artists.get(r.id), ) for r in rows ] diff --git a/tests/test_gallery_artist_payload.py b/tests/test_gallery_artist_payload.py new file mode 100644 index 0000000..ebd7fd6 --- /dev/null +++ b/tests/test_gallery_artist_payload.py @@ -0,0 +1,94 @@ +from datetime import UTC, datetime, timedelta + +import pytest + +from backend.app import create_app +from backend.app.models import ( + Artist, + ImageProvenance, + ImageRecord, + Post, + Source, +) + +pytestmark = pytest.mark.integration + + +@pytest.fixture +async def app(): + return create_app() + + +@pytest.fixture +async def client(app): + async with app.test_client() as c: + yield c + + +async def _img(db, n): + rec = ImageRecord( + path=f"/images/ap/{n}.jpg", sha256=f"ap{n:062d}", + size_bytes=1, mime="image/jpeg", width=1, height=1, + origin="imported_filesystem", integrity_status="unknown", + ) + rec.created_at = datetime.now(UTC) - timedelta(minutes=n) + db.add(rec) + await db.flush() + return rec + + +async def _artist_source(db, name, slug): + a = Artist(name=name, slug=slug) + db.add(a) + await db.flush() + s = Source(artist_id=a.id, platform="patreon", + url=f"https://patreon.test/{slug}") + db.add(s) + await db.flush() + return a, s + + +@pytest.mark.asyncio +async def test_scroll_artist_via_primary_post(client, db): + rec = await _img(db, 1) + _, s = await _artist_source(db, "Alice", "alice") + post = Post(source_id=s.id, external_post_id="1") + db.add(post) + await db.flush() + rec.primary_post_id = post.id + await db.flush() + await db.commit() + + resp = await client.get("/api/gallery/scroll?limit=10") + assert resp.status_code == 200 + body = await resp.get_json() + item = next(i for i in body["images"] if i["id"] == rec.id) + assert item["artist"] == {"name": "Alice", "slug": "alice"} + + +@pytest.mark.asyncio +async def test_scroll_artist_via_provenance_fallback(client, db): + rec = await _img(db, 1) + _, s = await _artist_source(db, "Bob", "bob") + post = Post(source_id=s.id, external_post_id="2") + db.add(post) + await db.flush() + db.add(ImageProvenance(image_record_id=rec.id, post_id=post.id, + source_id=s.id)) + await db.flush() + await db.commit() # rec.primary_post_id stays NULL + + resp = await client.get("/api/gallery/scroll?limit=10") + body = await resp.get_json() + item = next(i for i in body["images"] if i["id"] == rec.id) + assert item["artist"] == {"name": "Bob", "slug": "bob"} + + +@pytest.mark.asyncio +async def test_scroll_artist_null_when_none(client, db): + rec = await _img(db, 1) + await db.commit() + resp = await client.get("/api/gallery/scroll?limit=10") + body = await resp.get_json() + item = next(i for i in body["images"] if i["id"] == rec.id) + assert item["artist"] is None