fix(tags): fandom views aggregate images via their characters
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m24s

A fandom owns characters via Tag.fandom_id, but every image<->tag query
went purely through direct image_tag rows, so a fandom only surfaced
images literally tagged with it — images carrying one of its characters
were invisible to its browse count, previews, and gallery filter.

Derive membership at query time instead of materializing fandom rows
(which would drift on every reassign/merge/remove). Add one shared
predicate in tag_query.py — image_in_tag_scope / image_in_any_tag_scope:
an image belongs to a tag if tagged with it directly OR (when the tag is
a fandom) carrying a character whose fandom_id is that tag. The character
leg is empty for non-fandom tags, so it applies uniformly with no kind
branching. Route all read sites through it:

- gallery _apply_scope: include, OR-groups, and symmetric exclude
- directory image_count: correlated COUNT(DISTINCT) scalar subquery
- directory previews: UNION direct + via-character, then ROW_NUMBER<=3
- cleanup count_tag_associations: Tier-B delete prompt now reports a
  fandom's true blast radius (was 0 for fandoms with no direct rows)

find_unused_tags already protected fandoms via used_via_fandom; left as is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 00:17:25 -04:00
parent b85327a79d
commit 10434509d3
7 changed files with 198 additions and 41 deletions
+21
View File
@@ -146,6 +146,27 @@ def test_count_tag_associations_counts_image_tag_rows(db_sync, tmp_path):
assert cleanup_service.count_tag_associations(db_sync, tag_id=tag.id) == 1
def test_count_tag_associations_fandom_includes_character_images(db_sync, tmp_path):
# Deleting a fandom affects images carrying its characters too, so the
# Tier-B blast-radius prompt must count them — not just direct image_tag
# rows on the fandom (a fandom usually has zero of those). DISTINCT: an
# image with both the fandom and its character counts once.
a = _make_artist(db_sync, slug="fc")
img0 = _make_image(db_sync, artist=a, path=str(tmp_path / "0.jpg"), sha256="e" * 64)
img1 = _make_image(db_sync, artist=a, path=str(tmp_path / "1.jpg"), sha256="f" * 64)
fandom = _make_tag(db_sync, name="Creux", kind=TagKind.fandom)
char = _make_tag(db_sync, name="OcChar", kind=TagKind.character)
char.fandom_id = fandom.id
db_sync.flush()
db_sync.execute(image_tag.insert().values([
{"image_record_id": img0.id, "tag_id": char.id},
{"image_record_id": img1.id, "tag_id": char.id},
{"image_record_id": img1.id, "tag_id": fandom.id},
]))
db_sync.commit()
assert cleanup_service.count_tag_associations(db_sync, tag_id=fandom.id) == 2
def test_find_unused_tags_returns_only_unreferenced(db_sync, tmp_path):
a = _make_artist(db_sync, slug="fu")
img = _make_image(
+26
View File
@@ -103,6 +103,32 @@ async def test_scroll_with_tag_filter(db):
assert page.images[0].id == images[0].id
@pytest.mark.asyncio
async def test_scroll_fandom_includes_images_via_its_characters(db):
# A fandom owns a character (Tag.fandom_id). Filtering by the fandom must
# surface images that carry the CHARACTER but were never tagged with the
# fandom directly — plus images tagged with the fandom directly. Excluding
# the fandom is symmetric: it hides both.
images = await _seed_images(db, 4)
fandom = Tag(name="Naruto", kind=TagKind.fandom)
db.add(fandom)
await db.flush()
char = Tag(name="Sasuke", kind=TagKind.character, fandom_id=fandom.id)
other = Tag(name="unrelated", kind=TagKind.general)
db.add_all([char, other])
await db.flush()
await db.execute(image_tag.insert().values([
{"image_record_id": images[0].id, "tag_id": char.id, "source": "manual"},
{"image_record_id": images[1].id, "tag_id": fandom.id, "source": "manual"},
{"image_record_id": images[2].id, "tag_id": other.id, "source": "manual"},
]))
svc = GalleryService(db)
via = await svc.scroll(cursor=None, limit=10, tag_ids=[fandom.id])
assert {i.id for i in via.images} == {images[0].id, images[1].id}
excluded = await svc.scroll(cursor=None, limit=10, tag_exclude=[fandom.id])
assert {i.id for i in excluded.images} == {images[2].id, images[3].id}
@pytest.mark.asyncio
async def test_timeline_groups_by_month(db):
await _seed_images(db, 3)
+27
View File
@@ -36,6 +36,33 @@ async def test_directory_lists_tags_with_counts_and_previews(db):
assert card["preview_thumbnails"][0].startswith("/images/thumbs/")
@pytest.mark.asyncio
async def test_directory_fandom_counts_and_previews_via_characters(db):
# A fandom card aggregates images of its characters (Tag.fandom_id), not
# just images tagged with the fandom directly. DISTINCT: an image carrying
# both the fandom and one of its characters counts once.
fandom = Tag(name="Bleach", kind=TagKind.fandom)
db.add(fandom)
await db.flush()
char = Tag(name="Ichigo", kind=TagKind.character, fandom_id=fandom.id)
db.add(char)
await db.flush()
# img0: character only; img1: fandom direct; img2: BOTH (dedup → counts once)
img0, img1, img2 = await _img(db, 0), await _img(db, 1), await _img(db, 2)
await db.execute(image_tag.insert().values([
{"image_record_id": img0.id, "tag_id": char.id, "source": "manual"},
{"image_record_id": img1.id, "tag_id": fandom.id, "source": "manual"},
{"image_record_id": img2.id, "tag_id": char.id, "source": "manual"},
{"image_record_id": img2.id, "tag_id": fandom.id, "source": "manual"},
]))
await db.flush()
svc = TagDirectoryService(db)
page = await svc.list_tags(kind="fandom", q=None, cursor=None, limit=10)
card = next(c for c in page.cards if c["name"] == "Bleach")
assert card["image_count"] == 3
assert len(card["preview_thumbnails"]) == 3
@pytest.mark.asyncio
async def test_directory_kind_filter_and_search(db):
db.add_all([