From f7ee122243e2c2df7c1c09a6ea570f2b59a16aaa Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 25 May 2026 00:40:53 -0400 Subject: [PATCH] =?UTF-8?q?fc3k:=20/api/admin=20blueprint=20=E2=80=94=20Ti?= =?UTF-8?q?er-C=20artist=20cascade=20+=20bulk=20image=20delete=20with=20sh?= =?UTF-8?q?a8-keyed=20confirm=20tokens?= 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/api/admin.py | 114 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 backend/app/api/admin.py diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py new file mode 100644 index 0000000..31ce17d --- /dev/null +++ b/backend/app/api/admin.py @@ -0,0 +1,114 @@ +"""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