"""FC-3k: /api/admin — destructive admin actions. Five action surfaces: POST /api/admin/artists//cascade-delete (Tier C) POST /api/admin/images/bulk-delete (Tier C) DELETE /api/admin/tags/ (Tier B) POST /api/admin/tags//merge (Tier B) POST /api/admin/tags/prune-unused (Tier A) GET /api/admin/tags//usage-count (helper) Tier-C ops take a dry_run body flag (returns projection inline, no dispatch) and a confirm body field (server-recomputed token). Long-running ops dispatch a maintenance-queue Celery task; the UI tails FC-3i's /api/system/activity/runs to surface progress. """ from __future__ import annotations import hashlib from quart import Blueprint, jsonify, request from sqlalchemy import select from ..extensions import get_session from ..models import Artist from ..services.cleanup_service import project_artist_cascade, project_bulk_image_delete admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin") def _bad(error: str, *, status: int = 400, **extra): body = {"error": error} body.update(extra) return jsonify(body), status def _bulk_image_confirm_token(image_ids: list[int]) -> str: """Stable 8-hex token derived from the sorted id list. Mutates when the selection changes; stays the same across modal opens of the same selection so the operator can paste without confusion.""" canon = ",".join(str(i) for i in sorted(image_ids)) digest = hashlib.sha256(canon.encode("utf-8")).hexdigest() return digest[:8] @admin_bp.route("/artists//cascade-delete", methods=["POST"]) async def artist_cascade_delete(slug: str): body = await request.get_json(silent=True) or {} dry_run = bool(body.get("dry_run", False)) supplied_confirm = body.get("confirm", "") async with get_session() as session: artist = (await session.execute( select(Artist).where(Artist.slug == slug) )).scalar_one_or_none() if artist is None: return _bad("not_found", status=404) artist_id = artist.id projected = await session.run_sync( lambda sync_sess: project_artist_cascade(sync_sess, slug=slug) ) if dry_run: return jsonify(projected) expected = f"delete-artist-{artist_id}" if supplied_confirm != expected: return _bad( "confirm_mismatch", detail=f"confirm must equal {expected!r}", expected=expected, ) from ..tasks.admin import delete_artist_cascade_task async_result = delete_artist_cascade_task.delay(artist_id=artist_id) return jsonify({"task_id": async_result.id}), 202 @admin_bp.route("/images/bulk-delete", methods=["POST"]) async def images_bulk_delete(): body = await request.get_json(silent=True) or {} image_ids = body.get("image_ids") if not isinstance(image_ids, list) or not image_ids: return _bad("invalid_image_ids", detail="image_ids must be non-empty list of int") try: image_ids = [int(i) for i in image_ids] except (TypeError, ValueError): return _bad("invalid_image_ids", detail="image_ids must contain only ints") dry_run = bool(body.get("dry_run", False)) supplied_confirm = body.get("confirm", "") async with get_session() as session: projected = await session.run_sync( lambda sync_sess: project_bulk_image_delete( sync_sess, image_ids=image_ids, ) ) if dry_run: return jsonify(projected) sha8 = _bulk_image_confirm_token(image_ids) expected = f"delete-images-{sha8}" if supplied_confirm != expected: return _bad( "confirm_mismatch", detail=f"confirm must equal {expected!r}", expected=expected, ) from ..tasks.admin import bulk_delete_images_task async_result = bulk_delete_images_task.delay(image_ids=image_ids) return jsonify({"task_id": async_result.id}), 202