feat(provenance+tags): collapse provenance descriptions; purge retired-kind tags

Operator-flagged 2026-05-28, two asks.

**1. Provenance posts ate the panel.** Each post card rendered its full
description (180px scroll box) inline, so a few posts pushed everything
else off-screen. ProvenancePanel now collapses the description by
default behind a per-post "Show description ▾ / Hide ▴" toggle (state
keyed by provenance_id, reset when the viewed image changes). Cards stay
compact — platform/date/title/meta/actions — and the operator expands
only the descriptions they want.

**2. Purge tags of retired/system kinds.** The IR migration left
`archive`/`post`/`artist`-kind tags (e.g. `BlenderKnight:Hannah_BJ_Loops`)
that FC no longer creates — the tag input only makes
character/fandom/series/general, and provenance + artists are their own
systems now. (meta/rating were already hard-deleted by alembic 0023.)

- cleanup_service.purge_tags_by_kind(kinds=PURGEABLE_TAG_KINDS) — counts
  (dry_run) or deletes tags whose kind ∈ (archive, post, artist).
  CASCADE clears the image_tag / alias / allowlist / etc. rows.
- POST /api/admin/tags/purge-retired-kinds (Tier-A, dry-run preview
  returns per-kind counts + sample names — the preview IS the
  verification of exactly what'll be deleted before committing).
- Tag Maintenance card gets a second section: "Preview retired-kind
  tags" → per-kind breakdown + sample → "Delete N retired-kind tag(s)".

Tests: dry-run counts by kind (general survives), commit deletes only
the retired kinds (general + character survive, retired count → 0).

NOTE: a dry-run preview will show exactly which kinds/counts are
present. If the operator's noisy tags turn out to be `general` (e.g. an
IR `source:patreon` that fell back to general during migration), they
won't be caught by the kind purge — the preview makes that visible so
we can decide on a name-based pass separately.
This commit is contained in:
2026-05-28 01:12:20 -04:00
parent 104cac5dca
commit e1fc65bd1b
6 changed files with 203 additions and 2 deletions
+50
View File
@@ -364,3 +364,53 @@ async def test_prune_unused_commit_deletes_and_returns_count(client, db):
body = await resp.get_json()
assert body["deleted"] >= 1
assert "byebye" in body["sample_names"]
@pytest.mark.asyncio
async def test_purge_retired_kinds_dry_run_counts_by_kind(client, db):
db.add_all([
Tag(name="BlenderKnight:Hannah_BJ_Loops", kind=TagKind.archive),
Tag(name="BlenderKnight:May Animation", kind=TagKind.post),
Tag(name="SomeArtist", kind=TagKind.artist),
Tag(name="blonde hair", kind=TagKind.general), # must survive
])
await db.commit()
resp = await client.post(
"/api/admin/tags/purge-retired-kinds", json={"dry_run": True},
)
body = await resp.get_json()
assert body["count"] == 3
assert body["by_kind"] == {"archive": 1, "post": 1, "artist": 1}
assert "blonde hair" not in body["sample_names"]
@pytest.mark.asyncio
async def test_purge_retired_kinds_commit_deletes_only_retired(client, db):
from sqlalchemy import func, select
db.add_all([
Tag(name="arch1", kind=TagKind.archive),
Tag(name="post1", kind=TagKind.post),
Tag(name="keepme", kind=TagKind.general),
Tag(name="char1", kind=TagKind.character),
])
await db.commit()
resp = await client.post(
"/api/admin/tags/purge-retired-kinds", json={"dry_run": False},
)
body = await resp.get_json()
assert body["deleted"] == 2
# general + character tags survive.
surviving = (await db.execute(
select(func.count()).select_from(Tag)
.where(Tag.kind.in_([TagKind.general, TagKind.character]))
)).scalar_one()
assert surviving >= 2
retired_left = (await db.execute(
select(func.count()).select_from(Tag)
.where(Tag.kind.in_([TagKind.archive, TagKind.post, TagKind.artist]))
)).scalar_one()
assert retired_left == 0