feat(tags): 'Reset content tagging' admin action
Wipe every general + character tag so the operator can re-tag from scratch via
the Camie auto-suggest, while PRESERVING fandoms, series (+ series_page order),
and each image's stored tagger_predictions (so suggestions repopulate
immediately). One set-based DELETE FROM tag WHERE kind IN ('general','character')
— the five tag-referencing tables all cascade, so applications + aliases +
allowlist + rejections + centroids clear automatically; series tags aren't
deleted so series survive; Tag.fandom_id is SET NULL so fandoms are untouched.
Reuses the established dry-run-preview -> confirm pattern: cleanup_service.
reset_content_tagging() + POST /api/admin/tags/reset-content +
TagMaintenanceCard section with a backup-first warning and a red confirm
showing exact counts (tags by kind + image applications). Irreversible except
via DB backup restore; the wipe only fires when the operator confirms.
Tests: service dry-run counts + live delete preserves fandom/series/series_page
while content tags + their image_tag cascade away; API dry-run wiring.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import pytest
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from backend.app.models import Artist, ImageRecord, Tag, TagKind
|
||||
from backend.app.models.series_page import SeriesPage
|
||||
from backend.app.models.tag import image_tag
|
||||
from backend.app.services import cleanup_service
|
||||
|
||||
@@ -333,3 +334,62 @@ def test_prune_unused_tags_commit_deletes_them(db_sync, tmp_path):
|
||||
surviving_names = db_sync.execute(select(Tag.name)).scalars().all()
|
||||
assert "kept" in surviving_names
|
||||
assert "bye" not in surviving_names
|
||||
|
||||
|
||||
# --- reset_content_tagging ------------------------------------------
|
||||
|
||||
|
||||
def test_reset_content_tagging_dry_run_counts_without_deleting(db_sync, tmp_path):
|
||||
a = _make_artist(db_sync, slug="rc")
|
||||
img = _make_image(
|
||||
db_sync, artist=a, path=str(tmp_path / "r.jpg"), sha256="1" * 64,
|
||||
)
|
||||
g = _make_tag(db_sync, name="solo", kind=TagKind.general)
|
||||
c = _make_tag(db_sync, name="naruto", kind=TagKind.character)
|
||||
_make_tag(db_sync, name="Naruto", kind=TagKind.fandom)
|
||||
_make_tag(db_sync, name="my-series", kind=TagKind.series)
|
||||
db_sync.execute(image_tag.insert().values([
|
||||
{"image_record_id": img.id, "tag_id": g.id},
|
||||
{"image_record_id": img.id, "tag_id": c.id},
|
||||
]))
|
||||
db_sync.commit()
|
||||
|
||||
result = cleanup_service.reset_content_tagging(db_sync, dry_run=True)
|
||||
assert result["count"] == 2
|
||||
assert result["by_kind"] == {"general": 1, "character": 1}
|
||||
assert result["applications"] == 2
|
||||
# Nothing deleted — all 4 tags still present.
|
||||
assert db_sync.execute(select(func.count(Tag.id))).scalar_one() == 4
|
||||
|
||||
|
||||
def test_reset_content_tagging_deletes_content_keeps_fandom_series(db_sync, tmp_path):
|
||||
a = _make_artist(db_sync, slug="rc2")
|
||||
img = _make_image(
|
||||
db_sync, artist=a, path=str(tmp_path / "r2.jpg"), sha256="2" * 64,
|
||||
)
|
||||
g = _make_tag(db_sync, name="solo", kind=TagKind.general)
|
||||
c = _make_tag(db_sync, name="naruto", kind=TagKind.character)
|
||||
_make_tag(db_sync, name="Naruto", kind=TagKind.fandom)
|
||||
s = _make_tag(db_sync, name="my-series", kind=TagKind.series)
|
||||
db_sync.execute(image_tag.insert().values([
|
||||
{"image_record_id": img.id, "tag_id": g.id},
|
||||
{"image_record_id": img.id, "tag_id": c.id},
|
||||
{"image_record_id": img.id, "tag_id": s.id}, # series membership
|
||||
]))
|
||||
db_sync.add(SeriesPage(series_tag_id=s.id, image_id=img.id, page_number=1))
|
||||
db_sync.commit()
|
||||
|
||||
result = cleanup_service.reset_content_tagging(db_sync, dry_run=False)
|
||||
assert result["deleted"] == 2
|
||||
|
||||
# general + character gone; fandom + series kept.
|
||||
kinds = db_sync.execute(select(Tag.kind)).scalars().all()
|
||||
kind_vals = {k.value if hasattr(k, "value") else str(k) for k in kinds}
|
||||
assert kind_vals == {"fandom", "series"}
|
||||
# series_page ordering survived.
|
||||
assert db_sync.execute(
|
||||
select(func.count()).select_from(SeriesPage)
|
||||
).scalar_one() == 1
|
||||
# Only the series image_tag association survived (content ones cascaded).
|
||||
remaining = db_sync.execute(select(image_tag.c.tag_id)).scalars().all()
|
||||
assert remaining == [s.id]
|
||||
|
||||
Reference in New Issue
Block a user