From 159044730185b1e34cf3eebedb05845ddf026bad Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 18 May 2026 18:08:54 -0400 Subject: [PATCH] feat(provenance): post_id/artist_id gallery filters via EXISTS (mutually exclusive) Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/api/gallery.py | 36 ++++++- backend/app/services/gallery_service.py | 53 +++++++++- tests/test_gallery_provenance_filter.py | 130 ++++++++++++++++++++++++ 3 files changed, 211 insertions(+), 8 deletions(-) create mode 100644 tests/test_gallery_provenance_filter.py diff --git a/backend/app/api/gallery.py b/backend/app/api/gallery.py index 5401368..e24c6e0 100644 --- a/backend/app/api/gallery.py +++ b/backend/app/api/gallery.py @@ -17,11 +17,18 @@ async def scroll(): return jsonify({"error": "limit must be an integer"}), 400 tag_id_raw = request.args.get("tag_id") tag_id = int(tag_id_raw) if tag_id_raw else None + post_id_raw = request.args.get("post_id") + post_id = int(post_id_raw) if post_id_raw else None + artist_id_raw = request.args.get("artist_id") + artist_id = int(artist_id_raw) if artist_id_raw else None async with get_session() as session: svc = GalleryService(session) try: - page = await svc.scroll(cursor=cursor, limit=limit, tag_id=tag_id) + page = await svc.scroll( + cursor=cursor, limit=limit, tag_id=tag_id, + post_id=post_id, artist_id=artist_id, + ) except ValueError as exc: return jsonify({"error": str(exc)}), 400 @@ -51,10 +58,21 @@ async def scroll(): async def timeline(): tag_id_raw = request.args.get("tag_id") tag_id = int(tag_id_raw) if tag_id_raw else None + post_id_raw = request.args.get("post_id") + post_id = int(post_id_raw) if post_id_raw else None + artist_id_raw = request.args.get("artist_id") + artist_id = int(artist_id_raw) if artist_id_raw else None async with get_session() as session: svc = GalleryService(session) - buckets = await svc.timeline(tag_id=tag_id) - return jsonify([{"year": b.year, "month": b.month, "count": b.count} for b in buckets]) + try: + buckets = await svc.timeline( + tag_id=tag_id, post_id=post_id, artist_id=artist_id + ) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + return jsonify( + [{"year": b.year, "month": b.month, "count": b.count} for b in buckets] + ) @gallery_bp.route("/jump", methods=["GET"]) @@ -66,9 +84,19 @@ async def jump(): return jsonify({"error": "year and month query params required"}), 400 tag_id_raw = request.args.get("tag_id") tag_id = int(tag_id_raw) if tag_id_raw else None + post_id_raw = request.args.get("post_id") + post_id = int(post_id_raw) if post_id_raw else None + artist_id_raw = request.args.get("artist_id") + artist_id = int(artist_id_raw) if artist_id_raw else None async with get_session() as session: svc = GalleryService(session) - cursor = await svc.jump_cursor(year=year, month=month, tag_id=tag_id) + try: + cursor = await svc.jump_cursor( + year=year, month=month, tag_id=tag_id, + post_id=post_id, artist_id=artist_id, + ) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 return jsonify({"cursor": cursor}) diff --git a/backend/app/services/gallery_service.py b/backend/app/services/gallery_service.py index 88d11eb..217e5ba 100644 --- a/backend/app/services/gallery_service.py +++ b/backend/app/services/gallery_service.py @@ -10,10 +10,10 @@ import base64 from dataclasses import dataclass from datetime import datetime -from sqlalchemy import and_, func, or_, select +from sqlalchemy import and_, exists, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession -from ..models import ImageRecord, Tag +from ..models import ImageProvenance, ImageRecord, Source, Tag from ..models.tag import image_tag CURSOR_SEPARATOR = "|" @@ -67,6 +67,31 @@ def thumbnail_url(sha256_hex: str, mime: str) -> str: return f"/images/thumbs/{bucket}/{sha256_hex}{ext}" +def _require_single_filter(tag_id, post_id, artist_id) -> None: + if sum(x is not None for x in (tag_id, post_id, artist_id)) > 1: + raise ValueError( + "tag_id, post_id, artist_id are mutually exclusive" + ) + + +def _provenance_clause(post_id, artist_id): + """Correlated EXISTS clause (NOT a join) so an image with multiple + matching provenance rows is returned exactly once and the + (created_at DESC, id DESC) cursor ordering is unaffected.""" + if post_id is not None: + return exists().where( + ImageProvenance.image_record_id == ImageRecord.id, + ImageProvenance.post_id == post_id, + ) + if artist_id is not None: + return exists().where( + ImageProvenance.image_record_id == ImageRecord.id, + ImageProvenance.source_id == Source.id, + Source.artist_id == artist_id, + ) + return None + + class GalleryService: def __init__(self, session: AsyncSession): self.session = session @@ -76,15 +101,21 @@ class GalleryService: cursor: str | None, limit: int = 50, tag_id: int | None = None, + post_id: int | None = None, + artist_id: int | None = None, ) -> GalleryPage: if limit < 1 or limit > 200: raise ValueError("limit must be between 1 and 200") + _require_single_filter(tag_id, post_id, artist_id) stmt = select(ImageRecord) if tag_id is not None: stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where( image_tag.c.tag_id == tag_id ) + prov = _provenance_clause(post_id, artist_id) + if prov is not None: + stmt = stmt.where(prov) if cursor: cur_ts, cur_id = decode_cursor(cursor) @@ -123,22 +154,32 @@ class GalleryService: date_groups=_group_by_year_month(images), ) - async def timeline(self, tag_id: int | None = None) -> list[TimelineBucket]: + async def timeline( + self, + tag_id: int | None = None, + post_id: int | None = None, + artist_id: int | None = None, + ) -> list[TimelineBucket]: year_col = func.date_part("year", ImageRecord.created_at).label("yr") month_col = func.date_part("month", ImageRecord.created_at).label("mo") stmt = select( year_col, month_col, func.count(ImageRecord.id).label("cnt") ) + _require_single_filter(tag_id, post_id, artist_id) if tag_id is not None: stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where( image_tag.c.tag_id == tag_id ) + prov = _provenance_clause(post_id, artist_id) + if prov is not None: + stmt = stmt.where(prov) stmt = stmt.group_by(year_col, month_col).order_by(year_col.desc(), month_col.desc()) rows = (await self.session.execute(stmt)).all() return [TimelineBucket(year=int(r.yr), month=int(r.mo), count=int(r.cnt)) for r in rows] async def jump_cursor( - self, year: int, month: int, tag_id: int | None = None + self, year: int, month: int, tag_id: int | None = None, + post_id: int | None = None, artist_id: int | None = None, ) -> str | None: """Returns a cursor that, when passed to scroll(), positions at the first image of the given year-month. None if the bucket is empty. @@ -149,10 +190,14 @@ class GalleryService: extract("year", ImageRecord.created_at) == year, extract("month", ImageRecord.created_at) == month, ) + _require_single_filter(tag_id, post_id, artist_id) if tag_id is not None: stmt = stmt.join(image_tag, image_tag.c.image_record_id == ImageRecord.id).where( image_tag.c.tag_id == tag_id ) + prov = _provenance_clause(post_id, artist_id) + if prov is not None: + stmt = stmt.where(prov) stmt = stmt.order_by(ImageRecord.created_at.desc(), ImageRecord.id.desc()).limit(1) first = (await self.session.execute(stmt)).scalar_one_or_none() if first is None: diff --git a/tests/test_gallery_provenance_filter.py b/tests/test_gallery_provenance_filter.py new file mode 100644 index 0000000..2ee5d87 --- /dev/null +++ b/tests/test_gallery_provenance_filter.py @@ -0,0 +1,130 @@ +from datetime import UTC, datetime, timedelta + +import pytest + +from backend.app import create_app +from backend.app.models import ( + Artist, + ImageProvenance, + ImageRecord, + Post, + Source, +) +from backend.app.services.gallery_service import GalleryService + +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): + base = datetime.now(UTC) + rec = ImageRecord( + path=f"/images/f/{n}.jpg", sha256=f"f{n:063d}", + size_bytes=1, mime="image/jpeg", width=1, height=1, + origin="imported_filesystem", integrity_status="unknown", + ) + rec.created_at = base - timedelta(minutes=n) + db.add(rec) + await db.flush() + return rec + + +async def _post(db, artist_name, slug, ext): + a = Artist(name=artist_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() + p = Post(source_id=s.id, external_post_id=ext) + db.add(p) + await db.flush() + return a, s, p + + +@pytest.mark.asyncio +async def test_scroll_post_id_filter(db): + i1, i2, i3 = await _img(db, 1), await _img(db, 2), await _img(db, 3) + _, s, p = await _post(db, "A", "a", "10") + db.add(ImageProvenance(image_record_id=i1.id, post_id=p.id, + source_id=s.id)) + db.add(ImageProvenance(image_record_id=i2.id, post_id=p.id, + source_id=s.id)) + await db.flush() + svc = GalleryService(db) + page = await svc.scroll(cursor=None, limit=10, post_id=p.id) + assert {x.id for x in page.images} == {i1.id, i2.id} + + +@pytest.mark.asyncio +async def test_scroll_post_id_dedups_multi_rows(db): + i1 = await _img(db, 1) + _, s, p = await _post(db, "A", "a", "10") + # two provenance rows, same image+post (enrich-on-duplicate shape) + db.add(ImageProvenance(image_record_id=i1.id, post_id=p.id, + source_id=s.id)) + await db.flush() + db.add(ImageProvenance(image_record_id=i1.id, post_id=p.id, + source_id=s.id)) + await db.flush() + svc = GalleryService(db) + page = await svc.scroll(cursor=None, limit=10, post_id=p.id) + assert [x.id for x in page.images] == [i1.id] # appears once + + +@pytest.mark.asyncio +async def test_scroll_artist_id_filter(db): + i1, i2 = await _img(db, 1), await _img(db, 2) + a, s, p = await _post(db, "A", "a", "10") + db.add(ImageProvenance(image_record_id=i1.id, post_id=p.id, + source_id=s.id)) + await db.flush() + svc = GalleryService(db) + page = await svc.scroll(cursor=None, limit=10, artist_id=a.id) + assert [x.id for x in page.images] == [i1.id] + + +@pytest.mark.asyncio +async def test_mutually_exclusive_filters_raise(db): + svc = GalleryService(db) + with pytest.raises(ValueError): + await svc.scroll(cursor=None, limit=10, tag_id=1, post_id=2) + + +@pytest.mark.asyncio +async def test_timeline_and_jump_accept_post_id(db): + i1 = await _img(db, 1) + _, s, p = await _post(db, "A", "a", "10") + db.add(ImageProvenance(image_record_id=i1.id, post_id=p.id, + source_id=s.id)) + await db.flush() + svc = GalleryService(db) + buckets = await svc.timeline(post_id=p.id) + assert sum(b.count for b in buckets) == 1 + cur = await svc.jump_cursor( + year=i1.created_at.year, month=i1.created_at.month, post_id=p.id + ) + assert cur is not None + + +@pytest.mark.asyncio +async def test_api_scroll_rejects_combined_filters(client): + resp = await client.get("/api/gallery/scroll?tag_id=1&post_id=2") + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_api_timeline_rejects_combined_filters(client): + resp = await client.get("/api/gallery/timeline?post_id=1&artist_id=2") + assert resp.status_code == 400