9276b31b99
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>
123 lines
4.1 KiB
Python
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,
|
|
}
|