From 2ae01d27e397851a293d323441698750131d1ddd Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 25 May 2026 00:38:57 -0400 Subject: [PATCH] =?UTF-8?q?fc3k:=20cleanup=5Fservice=20projections=20?= =?UTF-8?q?=E2=80=94=20artist=20cascade,=20bulk=20delete,=20tag=20usage,?= =?UTF-8?q?=20unused=20tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/services/cleanup_service.py | 156 ++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 backend/app/services/cleanup_service.py diff --git a/backend/app/services/cleanup_service.py b/backend/app/services/cleanup_service.py new file mode 100644 index 0000000..d016012 --- /dev/null +++ b/backend/app/services/cleanup_service.py @@ -0,0 +1,156 @@ +"""FC-3k: first-class admin destructive operations. + +Projections are pure SELECTs used by both dry-run preview endpoints +and Tier-B count prompts. Mutations (Task 2) are called from sync +HTTP handlers (small ops) and from Celery tasks in +backend.app.tasks.admin (long ops). + +This module is the PERMANENT home of artist-cascade + image-unlink +logic. The legacy copy at backend/app/services/migrators/cleanup.py +stays in place until FC-3j; FC-3j will replace its body with thin +re-exports from this module and then delete the wrapper. +""" +from __future__ import annotations + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from ..models import Artist, ImageRecord, Tag +from ..models.series_page import SeriesPage +from ..models.tag import image_tag + + +def project_artist_cascade(session: Session, *, slug: str) -> dict: + """Read-only projection of what delete_artist_cascade would touch. + + Returns: + { + "artist": {"id": int, "name": str, "slug": str}, + "projected": { + "images": int, + "sources": int, + "thumbs": int, # images with a thumbnail_path set + "import_tasks": int, # ImportTask rows referencing the artist's images + "bytes_on_disk": int, # SUM(image_record.size_bytes) — column is NOT NULL + }, + } + Raises LookupError if slug not found. No mutations. + """ + from ..models.import_task import ImportTask + from ..models.source import Source + + artist = session.execute( + select(Artist).where(Artist.slug == slug) + ).scalar_one_or_none() + if artist is None: + raise LookupError(f"artist slug not found: {slug!r}") + + images_count = session.execute( + select(func.count(ImageRecord.id)) + .where(ImageRecord.artist_id == artist.id) + ).scalar_one() + sources_count = session.execute( + select(func.count(Source.id)) + .where(Source.artist_id == artist.id) + ).scalar_one() + thumbs_count = session.execute( + select(func.count(ImageRecord.id)) + .where(ImageRecord.artist_id == artist.id) + .where(ImageRecord.thumbnail_path.is_not(None)) + ).scalar_one() + import_tasks_count = session.execute( + select(func.count(ImportTask.id)) + .where( + ImportTask.result_image_id.in_( + select(ImageRecord.id).where(ImageRecord.artist_id == artist.id) + ) + ) + ).scalar_one() + bytes_on_disk = session.execute( + select(func.coalesce(func.sum(ImageRecord.size_bytes), 0)) + .where(ImageRecord.artist_id == artist.id) + ).scalar_one() + + return { + "artist": {"id": artist.id, "name": artist.name, "slug": artist.slug}, + "projected": { + "images": images_count, + "sources": sources_count, + "thumbs": thumbs_count, + "import_tasks": import_tasks_count, + "bytes_on_disk": int(bytes_on_disk), + }, + } + + +def project_bulk_image_delete( + session: Session, *, image_ids: list[int], +) -> dict: + """Read-only projection of what delete_images would touch. + + Returns: + { + "images_found": int, + "thumbs_to_unlink": int, + "bytes_on_disk": int, + "missing_ids": list[int], # ids passed in that don't exist + } + No mutations. + """ + if not image_ids: + return { + "images_found": 0, + "thumbs_to_unlink": 0, + "bytes_on_disk": 0, + "missing_ids": [], + } + + rows = session.execute( + select( + ImageRecord.id, + ImageRecord.thumbnail_path, + ImageRecord.size_bytes, + ).where(ImageRecord.id.in_(image_ids)) + ).all() + found_ids = {r.id for r in rows} + missing = sorted(set(image_ids) - found_ids) + return { + "images_found": len(rows), + "thumbs_to_unlink": sum(1 for r in rows if r.thumbnail_path), + "bytes_on_disk": sum(r.size_bytes for r in rows), + "missing_ids": missing, + } + + +def count_tag_associations(session: Session, *, tag_id: int) -> int: + """COUNT(*) FROM image_tag WHERE tag_id=?. For Tier-B prompt.""" + return session.execute( + select(func.count()) + .select_from(image_tag) + .where(image_tag.c.tag_id == tag_id) + ).scalar_one() + + +def find_unused_tags( + session: Session, *, limit: int | None = None, +) -> list[Tag]: + """Tags with no image_tag rows AND no series_page rows. + + Sorted by name. Used by both dry-run preview and the live prune. + A tag is "unused" iff it has zero rows in image_tag AND zero rows + in series_page (so we don't accidentally prune a series tag that + happens to have no images yet). + """ + used_via_image_tag = select(image_tag.c.tag_id).distinct() + used_via_series = select(SeriesPage.series_tag_id).where( + SeriesPage.series_tag_id.is_not(None) + ).distinct() + stmt = ( + select(Tag) + .where(Tag.id.not_in(used_via_image_tag)) + .where(Tag.id.not_in(used_via_series)) + .order_by(Tag.name) + ) + if limit is not None: + stmt = stmt.limit(limit) + return list(session.execute(stmt).scalars().all())