feat(provenance): post_id/artist_id gallery filters via EXISTS (mutually exclusive)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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})
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user