This repository has been archived on 2026-05-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
imagerepo/app/tasks/maintenance.py
T
bvandeusen 9276b31b99 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>
2026-04-24 08:58:51 -04:00

123 lines
4.1 KiB
Python

"""Maintenance-queue Celery tasks.
Currently holds the blocklist cleanup task that retroactively removes
blocklisted tag names from the library. The earlier sync_character_fandoms
task was removed on 2026-04-21 as part of the bare-name refactor.
"""
import logging
from celery import shared_task
from sqlalchemy import text
from app import db
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,
time_limit=120,
)
def sweep_blocklisted_tag_from_images(name: str) -> dict:
"""Remove a blocklisted tag name from every image that has it attached,
then delete the Tag row itself.
Scope is limited to kind='user' tags — that's the kind WD14's general
category gets materialized as when accepted, which is the vast majority
of blocklist hits. Character / fandom / artist / series / post / archive
tags sharing the same name are left alone: those are deliberate, curated
entities, and removing them silently because of a blocklist text match
would be destructive.
Returns a summary dict so the Celery result is introspectable.
"""
tag = Tag.query.filter_by(kind='user', name=name).first()
if tag is None:
log.info("sweep_blocklisted_tag_from_images: no kind='user' tag named %r; nothing to do", name)
return {'name': name, 'tag_found': False, 'image_tags_deleted': 0, 'tag_deleted': False}
result = db.session.execute(
text("DELETE FROM image_tags WHERE tag_id = :tid"),
{'tid': tag.id},
)
rowcount = result.rowcount or 0
db.session.delete(tag)
db.session.commit()
log.info(
"sweep_blocklisted_tag_from_images: removed %r (tag_id=%d) from %d images",
name, tag.id, rowcount,
)
return {
'name': name,
'tag_found': True,
'image_tags_deleted': rowcount,
'tag_deleted': True,
}