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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user