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:
@@ -72,6 +72,7 @@ def make_celery(app=None):
|
|||||||
'app.tasks.scan.update_system_stats': {'queue': 'maintenance'},
|
'app.tasks.scan.update_system_stats': {'queue': 'maintenance'},
|
||||||
'app.tasks.scan.update_batch_stats': {'queue': 'maintenance'},
|
'app.tasks.scan.update_batch_stats': {'queue': 'maintenance'},
|
||||||
'app.tasks.maintenance.sweep_blocklisted_tag_from_images': {'queue': 'maintenance'},
|
'app.tasks.maintenance.sweep_blocklisted_tag_from_images': {'queue': 'maintenance'},
|
||||||
|
'app.tasks.maintenance.sync_character_fandoms_to_images': {'queue': 'maintenance'},
|
||||||
|
|
||||||
# Import tasks - handled by worker (heavy processing)
|
# Import tasks - handled by worker (heavy processing)
|
||||||
'app.tasks.import_file.*': {'queue': 'import'},
|
'app.tasks.import_file.*': {'queue': 'import'},
|
||||||
|
|||||||
@@ -641,6 +641,13 @@ def trigger_recompute_all_centroids():
|
|||||||
return redirect(url_for('main.settings', tab='maintenance'))
|
return redirect(url_for('main.settings', tab='maintenance'))
|
||||||
|
|
||||||
|
|
||||||
|
@main.post('/settings/maintenance/sync-character-fandoms-to-images')
|
||||||
|
def trigger_sync_character_fandoms_to_images():
|
||||||
|
from app.tasks.maintenance import sync_character_fandoms_to_images
|
||||||
|
sync_character_fandoms_to_images.apply_async(queue='maintenance')
|
||||||
|
return redirect(url_for('main.settings', tab='maintenance'))
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
# Tag add/remove endpoints
|
# Tag add/remove endpoints
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
|
|||||||
@@ -15,6 +15,70 @@ from app.models import Tag
|
|||||||
log = logging.getLogger(__name__)
|
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(
|
@shared_task(
|
||||||
name='app.tasks.maintenance.sweep_blocklisted_tag_from_images',
|
name='app.tasks.maintenance.sweep_blocklisted_tag_from_images',
|
||||||
soft_time_limit=60,
|
soft_time_limit=60,
|
||||||
|
|||||||
@@ -456,6 +456,18 @@
|
|||||||
<button type="submit" class="btn secondary-btn">Recompute all centroids</button>
|
<button type="submit" class="btn secondary-btn">Recompute all centroids</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<p class="settings-hint mt-paragraph">
|
||||||
|
For every character tag with a fandom assigned, attach the fandom
|
||||||
|
tag to every image already tagged with that character. Additive
|
||||||
|
only — never removes fandom attachments. Safe to re-run. Backfills
|
||||||
|
data migrated from pre-refactor tag names that embedded the
|
||||||
|
"(Fandom)" suffix, since the migration set tag.fandom_id but did
|
||||||
|
not retroactively attach the fandom row to image_tags.
|
||||||
|
</p>
|
||||||
|
<form action="{{ url_for('main.trigger_sync_character_fandoms_to_images') }}" method="post" onsubmit="return confirm('Attach each character\\'s fandom tag to every image with that character?');">
|
||||||
|
<button type="submit" class="btn secondary-btn">Sync fandoms to images</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
|
|||||||
Reference in New Issue
Block a user