feat(fc-cleanup): api/cleanup.py blueprint (9 endpoints) + register + delete-audit-<id> token (matches modal convention) — Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ api_bp.add_url_rule("/health", view_func=health.get_health, methods=["GET"])
|
||||
def all_blueprints() -> list[Blueprint]:
|
||||
from .admin import admin_bp
|
||||
from .aliases import aliases_bp
|
||||
from .cleanup import cleanup_bp
|
||||
from .allowlist import allowlist_bp
|
||||
from .artist import artist_bp
|
||||
from .artists import artists_bp
|
||||
@@ -50,6 +51,7 @@ def all_blueprints() -> list[Blueprint]:
|
||||
system_activity_bp,
|
||||
system_backup_bp,
|
||||
admin_bp,
|
||||
cleanup_bp,
|
||||
import_admin_bp,
|
||||
migrate_bp,
|
||||
suggestions_bp,
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""FC-Cleanup: /api/cleanup/* — retroactive enforcement of import filters.
|
||||
|
||||
Endpoints:
|
||||
POST /min-dimension/preview synchronous SQL audit
|
||||
POST /min-dimension/delete synchronous SQL delete (Tier-C token)
|
||||
POST /audit async transparency / single_color start
|
||||
GET /audit list recent audit_run rows
|
||||
GET /audit/<id> single audit_run row
|
||||
POST /audit/<id>/apply apply matched_ids deletes (Tier-C token)
|
||||
POST /audit/<id>/cancel flip running audit to cancelled
|
||||
|
||||
Unused-tags retroactive prune intentionally NOT in this namespace —
|
||||
TagMaintenanceCard (Maintenance tab → moved to Cleanup tab in v26.05.25.7)
|
||||
uses the existing /api/admin/tags/prune-unused endpoint via the admin
|
||||
store. No duplicate route here.
|
||||
|
||||
Confirm-token format matches modal/DestructiveConfirmModal.vue convention:
|
||||
`delete-min-dim-<sha8(w,h)>` for min-dim delete
|
||||
`delete-audit-<id>` for audit apply
|
||||
(Modal hardcodes action ∈ {'restore', 'delete'}; "apply audit" is semantically a delete of the matched images, so we use `delete-audit-<id>`.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..extensions import get_session
|
||||
from ..models import LibraryAuditRun
|
||||
from ..services import cleanup_service
|
||||
|
||||
cleanup_bp = Blueprint("cleanup", __name__, url_prefix="/api/cleanup")
|
||||
|
||||
IMAGES_ROOT = Path("/images")
|
||||
|
||||
|
||||
def _bad(error: str, *, status: int = 400, **extra):
|
||||
body = {"error": error}
|
||||
body.update(extra)
|
||||
return jsonify(body), status
|
||||
|
||||
|
||||
def _min_dim_token(min_w: int, min_h: int) -> str:
|
||||
# SHA-256 (not MD5) — Web Crypto's subtle.digest rejects MD5; both
|
||||
# sides use SHA-256 truncated to 8 hex chars.
|
||||
canon = f"{min_w}x{min_h}"
|
||||
return f"delete-min-dim-{hashlib.sha256(canon.encode()).hexdigest()[:8]}"
|
||||
|
||||
|
||||
def _serialize_audit_run(audit: LibraryAuditRun) -> dict:
|
||||
return {
|
||||
"id": audit.id,
|
||||
"rule": audit.rule,
|
||||
"params": audit.params,
|
||||
"status": audit.status,
|
||||
"started_at": audit.started_at.isoformat() if audit.started_at else None,
|
||||
"finished_at": audit.finished_at.isoformat() if audit.finished_at else None,
|
||||
"scanned_count": audit.scanned_count,
|
||||
"matched_count": audit.matched_count,
|
||||
"matched_ids": audit.matched_ids,
|
||||
"error": audit.error,
|
||||
}
|
||||
|
||||
|
||||
@cleanup_bp.route("/min-dimension/preview", methods=["POST"])
|
||||
async def min_dim_preview():
|
||||
body = await request.get_json(silent=True) or {}
|
||||
try:
|
||||
min_w = int(body.get("min_width", 0))
|
||||
min_h = int(body.get("min_height", 0))
|
||||
except (TypeError, ValueError):
|
||||
return _bad("invalid_dimensions")
|
||||
if min_w < 0 or min_h < 0:
|
||||
return _bad("invalid_dimensions")
|
||||
async with get_session() as session:
|
||||
projection = await session.run_sync(
|
||||
lambda s: cleanup_service.project_min_dimension_violations(
|
||||
s, min_width=min_w, min_height=min_h,
|
||||
)
|
||||
)
|
||||
return jsonify(projection)
|
||||
|
||||
|
||||
@cleanup_bp.route("/min-dimension/delete", methods=["POST"])
|
||||
async def min_dim_delete():
|
||||
body = await request.get_json(silent=True) or {}
|
||||
try:
|
||||
min_w = int(body.get("min_width", 0))
|
||||
min_h = int(body.get("min_height", 0))
|
||||
except (TypeError, ValueError):
|
||||
return _bad("invalid_dimensions")
|
||||
if min_w < 0 or min_h < 0:
|
||||
return _bad("invalid_dimensions")
|
||||
supplied = body.get("confirm", "")
|
||||
expected = _min_dim_token(min_w, min_h)
|
||||
if supplied != expected:
|
||||
return _bad("confirm_mismatch", expected=expected)
|
||||
async with get_session() as session:
|
||||
deleted = await session.run_sync(
|
||||
lambda s: cleanup_service.delete_min_dimension_violations(
|
||||
s, min_width=min_w, min_height=min_h, images_root=IMAGES_ROOT,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return jsonify({"deleted": deleted})
|
||||
|
||||
|
||||
@cleanup_bp.route("/audit", methods=["POST"])
|
||||
async def audit_create():
|
||||
body = await request.get_json(silent=True) or {}
|
||||
rule = body.get("rule")
|
||||
params = body.get("params") or {}
|
||||
if rule not in ("transparency", "single_color"):
|
||||
return _bad("invalid_rule")
|
||||
if not isinstance(params, dict):
|
||||
return _bad("invalid_params")
|
||||
async with get_session() as session:
|
||||
try:
|
||||
audit_id = await session.run_sync(
|
||||
lambda s: cleanup_service.start_audit_run(
|
||||
s, rule=rule, params=params,
|
||||
)
|
||||
)
|
||||
except cleanup_service.AuditAlreadyRunning as running_id:
|
||||
return _bad(
|
||||
"audit_already_running", status=409,
|
||||
running_id=int(str(running_id)),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return _bad(str(exc))
|
||||
await session.commit()
|
||||
return jsonify({"audit_id": audit_id, "status": "running"}), 202
|
||||
|
||||
|
||||
@cleanup_bp.route("/audit/<int:audit_id>", methods=["GET"])
|
||||
async def audit_get(audit_id: int):
|
||||
async with get_session() as session:
|
||||
audit = (await session.execute(
|
||||
select(LibraryAuditRun).where(LibraryAuditRun.id == audit_id)
|
||||
)).scalar_one_or_none()
|
||||
if audit is None:
|
||||
return _bad("not_found", status=404)
|
||||
return jsonify(_serialize_audit_run(audit))
|
||||
|
||||
|
||||
@cleanup_bp.route("/audit", methods=["GET"])
|
||||
async def audit_history():
|
||||
try:
|
||||
limit = min(int(request.args.get("limit", "20")), 100)
|
||||
except ValueError:
|
||||
return _bad("invalid_limit")
|
||||
async with get_session() as session:
|
||||
rows = (await session.execute(
|
||||
select(LibraryAuditRun)
|
||||
.order_by(LibraryAuditRun.id.desc())
|
||||
.limit(limit)
|
||||
)).scalars().all()
|
||||
return jsonify({"runs": [_serialize_audit_run(r) for r in rows]})
|
||||
|
||||
|
||||
@cleanup_bp.route("/audit/<int:audit_id>/apply", methods=["POST"])
|
||||
async def audit_apply(audit_id: int):
|
||||
body = await request.get_json(silent=True) or {}
|
||||
confirm = body.get("confirm", "")
|
||||
async with get_session() as session:
|
||||
try:
|
||||
deleted = await session.run_sync(
|
||||
lambda s: cleanup_service.apply_audit_run(
|
||||
s, audit_id=audit_id, confirm_token=confirm,
|
||||
images_root=IMAGES_ROOT,
|
||||
)
|
||||
)
|
||||
except cleanup_service.AuditNotReady as exc:
|
||||
return _bad("audit_not_ready", current_status=str(exc))
|
||||
except cleanup_service.ConfirmTokenMismatch as exc:
|
||||
return _bad("confirm_mismatch", expected=str(exc))
|
||||
except ValueError as exc:
|
||||
return _bad("not_found", status=404, detail=str(exc))
|
||||
await session.commit()
|
||||
return jsonify({"deleted": deleted})
|
||||
|
||||
|
||||
@cleanup_bp.route("/audit/<int:audit_id>/cancel", methods=["POST"])
|
||||
async def audit_cancel(audit_id: int):
|
||||
async with get_session() as session:
|
||||
await session.run_sync(
|
||||
lambda s: cleanup_service.cancel_audit_run(s, audit_id=audit_id)
|
||||
)
|
||||
await session.commit()
|
||||
return jsonify({"cancelled": True})
|
||||
Reference in New Issue
Block a user