feat(maintenance): backfill fandom tags onto legacy character attachments

New Celery task sync_character_fandoms_to_images on the maintenance
queue. For every character tag with a non-null fandom_id, runs the
same INSERT ... SELECT ... ON CONFLICT DO NOTHING that set_tag_fandom
already runs inline, attaching the fandom tag to every image that
has the character attached.

Why this exists: migration j26042101 extracted the '(Fandom)' suffix
from pre-refactor character names into tag.fandom_id, but it did NOT
walk image_tags to attach the fandom rows to every image that had
the character. Post-refactor auto-apply (in add_tag /
accept_image_suggestion / set_tag_fandom) only fires on NEW
add/accept events — pre-existing character attachments from before
the migration still show the character pill in the modal but no
fandom pill, because the fandom row was never added to image_tags.

Surfaced by Settings → Maintenance → 'Sync fandoms to images'.
Safe, additive, idempotent. Run once after the migration to close
the gap; subsequent set-fandom operations continue to maintain the
invariant inline.

Verified locally: seeded a character+fandom pair with only the
character attached to image 1; task added the fandom to image_tags;
final state has both rows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 08:58:51 -04:00
parent 3b472cf519
commit 9276b31b99
4 changed files with 84 additions and 0 deletions
+64
View File
@@ -15,6 +15,70 @@ from app.models import Tag
log = logging.getLogger(__name__)
@shared_task(
name='app.tasks.maintenance.sync_character_fandoms_to_images',
soft_time_limit=300,
time_limit=600,
)
def sync_character_fandoms_to_images() -> dict:
"""For every character tag with a non-null fandom_id, ensure the fandom
tag is attached to every image that has the character attached.
Backfills the gap left by migration j26042101 — the migration set
tag.fandom_id from the old '(Fandom)' suffix but did not retroactively
add fandom rows to image_tags. Set operations made AFTER the migration
via /api/tag/<id>/set-fandom do this inline, so running this task once
(or whenever the user suspects drift) is sufficient.
Additive only — never removes fandom attachments. Idempotent via
ON CONFLICT DO NOTHING.
Returns a summary with per-character counts so the result is
introspectable.
"""
chars = (
Tag.query
.filter_by(kind='character')
.filter(Tag.fandom_id.isnot(None))
.all()
)
total_links_added = 0
characters_processed = 0
failures = 0
for char in chars:
try:
result = db.session.execute(
text("""
INSERT INTO image_tags (image_id, tag_id)
SELECT it.image_id, :fandom_id
FROM image_tags it
WHERE it.tag_id = :char_id
ON CONFLICT DO NOTHING
"""),
{'char_id': char.id, 'fandom_id': char.fandom_id},
)
total_links_added += result.rowcount or 0
db.session.commit()
characters_processed += 1
except Exception as e:
db.session.rollback()
failures += 1
log.warning(
"sync_character_fandoms_to_images: char tag %s (%s) failed: %s",
char.id, char.name, e,
)
log.info(
"sync_character_fandoms_to_images: processed %d characters, added %d links, %d failures",
characters_processed, total_links_added, failures,
)
return {
'characters_scanned': len(chars),
'characters_processed': characters_processed,
'links_added': total_links_added,
'characters_failed': failures,
}
@shared_task(
name='app.tasks.maintenance.sweep_blocklisted_tag_from_images',
soft_time_limit=60,