feat(tags): legacy-tag purge also catches source:* general tags

Operator-confirmed 2026-05-28: the BlenderKnight:* tags are `archive`
kind (caught by the kind purge), but the `source:patreon`-style tags
are IR's old `source` kind that fell back to `general` during migration
(FC's enum has no `source` kind) — so they can't be matched by kind.

Broadened the purge to a two-rule match and renamed it for accuracy
(all dev-only, unreleased):
- cleanup_service.purge_tags_by_kind → purge_legacy_tags. Predicate is
  now `kind IN (archive, post, artist) OR name LIKE 'source:%'`
  (LEGACY_NAME_PREFIXES). Preview classifies each row into by_kind OR
  by_prefix (source:* counts once under the prefix bucket regardless of
  its general kind).
- endpoint /tags/purge-retired-kinds → /tags/purge-legacy. dry-run
  returns by_kind + by_prefix + count + sample.
- store purgeRetiredKindTags → purgeLegacyTags.
- Tag Maintenance card copy + breakdown updated to show both buckets;
  button reads "Preview/Delete legacy tags".

Tests updated + extended: dry-run reports by_kind {archive,post,artist}
AND by_prefix {source:*}, plain general/character tags survive; commit
deletes both the kind-matched and source:*-matched rows and leaves the
rest.
This commit is contained in:
2026-05-28 01:20:41 -04:00
parent e1fc65bd1b
commit bf8eb4468f
5 changed files with 104 additions and 66 deletions
+24 -17
View File
@@ -367,50 +367,57 @@ async def test_prune_unused_commit_deletes_and_returns_count(client, db):
@pytest.mark.asyncio
async def test_purge_retired_kinds_dry_run_counts_by_kind(client, db):
async def test_purge_legacy_dry_run_counts_by_kind_and_prefix(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),
# IR `source` kind fell back to general during migration —
# caught by the source:* name prefix, not by kind.
Tag(name="source:patreon", kind=TagKind.general),
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},
"/api/admin/tags/purge-legacy", json={"dry_run": True},
)
body = await resp.get_json()
assert body["count"] == 3
assert body["count"] == 4
assert body["by_kind"] == {"archive": 1, "post": 1, "artist": 1}
assert body["by_prefix"] == {"source:*": 1}
assert "blonde hair" not in body["sample_names"]
assert "source:patreon" in body["sample_names"]
@pytest.mark.asyncio
async def test_purge_retired_kinds_commit_deletes_only_retired(client, db):
async def test_purge_legacy_commit_deletes_only_legacy(client, db):
from sqlalchemy import func, select
db.add_all([
Tag(name="arch1", kind=TagKind.archive),
Tag(name="post1", kind=TagKind.post),
Tag(name="source:fanbox", kind=TagKind.general),
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},
"/api/admin/tags/purge-legacy", json={"dry_run": False},
)
body = await resp.get_json()
assert body["deleted"] == 2
assert body["deleted"] == 2 # arch1 + source:fanbox
# general + character tags survive.
surviving = (await db.execute(
select(func.count()).select_from(Tag)
.where(Tag.kind.in_([TagKind.general, TagKind.character]))
# The plain general + character tags survive.
keep = (await db.execute(
select(Tag.name).where(Tag.name.in_(["keepme", "char1"]))
)).scalars().all()
assert set(keep) == {"keepme", "char1"}
# No archive/post/artist or source:* left.
gone = (await db.execute(
select(func.count()).select_from(Tag).where(
(Tag.kind.in_([TagKind.archive, TagKind.post, TagKind.artist]))
| (Tag.name.like("source:%"))
)
)).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
assert gone == 0