fc3k: /api/admin blueprint — Tier-C artist cascade + bulk image delete with sha8-keyed confirm tokens
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
"""FC-3k: /api/admin — destructive admin actions.
|
||||
|
||||
Five action surfaces:
|
||||
POST /api/admin/artists/<slug>/cascade-delete (Tier C)
|
||||
POST /api/admin/images/bulk-delete (Tier C)
|
||||
DELETE /api/admin/tags/<int:tag_id> (Tier B)
|
||||
POST /api/admin/tags/<int:dest_id>/merge (Tier B)
|
||||
POST /api/admin/tags/prune-unused (Tier A)
|
||||
GET /api/admin/tags/<int:tag_id>/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/<slug>/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
|
||||
Reference in New Issue
Block a user