"""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//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, }