"""FC-3k: /api/admin — destructive admin actions. 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) POST /api/admin/posts/prune-bare (Tier A) POST /api/admin/posts/refetch-external (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, text from ..extensions import get_session from ..models import Artist, Post from ..services.cleanup_service import project_artist_cascade, project_bulk_image_delete from ._responses import error_response as _bad admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin") 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] async def _run_dry_run_op(service_fn, **service_kwargs): """Shared body for the Tier-A dry-run/apply endpoints: read the `dry_run` flag, run the cleanup_service predicate under `run_sync`, and return its result dict. The SAME `service_fn` drives both preview and apply (the flag just toggles), so a handler physically can't let its preview diverge from its delete (rule 93). Default False preserves the existing contract — the UI always passes `dry_run` explicitly (true to preview, false to apply). Extra service kwargs (e.g. `source_id`) pass straight through.""" body = await request.get_json(silent=True) or {} dry_run = bool(body.get("dry_run", False)) async with get_session() as session: result = await session.run_sync( lambda sync_sess: service_fn(sync_sess, dry_run=dry_run, **service_kwargs) ) return jsonify(result) def _queued(async_result): """Standard 202 for an operator-triggered maintenance task: hand the UI the Celery task id so it can tail /maintenance/task-result (or the activity dashboard) for the summary. (trigger_vacuum stays bespoke — the UI doesn't poll it, so it returns no task id.)""" return jsonify({"task_id": async_result.id, "status": "queued"}), 202 @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, ) ) sha8 = _bulk_image_confirm_token(image_ids) expected = f"delete-images-{sha8}" if dry_run: # Hand the canonical Tier-C confirm token back with the # projection so the frontend doesn't have to recompute SHA-256 # client-side via crypto.subtle (Secure-Context-gated, # undefined on plain-HTTP origins per the homelab posture). # Operator-flagged 2026-05-27. projected["confirm_token"] = expected return jsonify(projected) 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 @admin_bp.route("/tags/", methods=["DELETE"]) async def tag_delete(tag_id: int): """Tier-B sync delete. UI yes/no modal is the only confirmation.""" from ..services.cleanup_service import delete_tag async with get_session() as session: try: result = await session.run_sync( lambda sync_sess: delete_tag(sync_sess, tag_id=tag_id) ) except LookupError: return _bad("not_found", status=404) return jsonify(result) @admin_bp.route("/tags//merge", methods=["POST"]) async def tag_merge(dest_id: int): """Wraps TagService.merge. Source repoints to dest, dest survives, source row deleted, protective alias auto-created if source was ML-applied or allowlisted.""" from ..services.tag_service import TagMergeConflict, TagService, TagValidationError body = await request.get_json(silent=True) or {} source_id = body.get("source_id") if not isinstance(source_id, int) or source_id == dest_id: return _bad("invalid_source_id", detail="source_id must be int and differ from dest") # dry_run: non-mutating preview (counts + sample) so the operator can # confirm the target before the irreversible merge (#8, rule 93 parity). if body.get("dry_run"): async with get_session() as session: try: p = await TagService(session).merge_preview( source_id=source_id, target_id=dest_id, ) except TagValidationError as exc: return _bad("tag_not_found", status=404, detail=str(exc)) return jsonify({ "preview": { "source_id": p.source_id, "source_name": p.source_name, "target_id": p.target_id, "target_name": p.target_name, "compatible": p.compatible, "images_moving": p.images_moving, "images_already_on_target": p.images_already_on_target, "source_total": p.source_total, "series_pages": p.series_pages, "will_alias": p.will_alias, "sample_thumbnails": p.sample_thumbnails, }, }) async with get_session() as session: try: result = await TagService(session).merge( source_id=source_id, target_id=dest_id, ) except TagMergeConflict as exc: return _bad("merge_conflict", status=409, detail=str(exc)) except TagValidationError as exc: return _bad("tag_kind_mismatch", detail=str(exc)) except LookupError: return _bad("not_found", status=404) # MergeResult is a frozen dataclass — flatten to dict. return jsonify({ "result": { "target_id": result.target_id, "target_name": result.target_name, "target_kind": result.target_kind, "merged_count": result.merged_count, "alias_created": result.alias_created, "source_deleted": result.source_deleted, }, }) @admin_bp.route("/tags//usage-count", methods=["GET"]) async def tag_usage_count(tag_id: int): """Helper for the Tier-B yes/no prompt; surfaces "N associations" in the dialog so the operator knows what they're nuking.""" from ..services.cleanup_service import count_tag_associations async with get_session() as session: count = await session.run_sync( lambda sync_sess: count_tag_associations( sync_sess, tag_id=tag_id, ) ) return jsonify({"count": count}) @admin_bp.route("/tags/prune-unused", methods=["POST"]) async def tags_prune_unused(): """Tier-A: dry-run preview list IS the prompt. UI calls with dry_run=true first, shows the list, operator clicks button to re-call with dry_run=false.""" from ..services.cleanup_service import prune_unused_tags return await _run_dry_run_op(prune_unused_tags) @admin_bp.route("/posts/prune-bare", methods=["POST"]) async def posts_prune_bare(): """Tier-A: delete bare posts — Post rows with no linked images (primary OR provenance) and no attachments. Dry-run preview list IS the prompt: UI calls with dry_run=true first, shows the count + sample, operator confirms by re-calling with dry_run=false. Same preview/apply-parity predicate as the prune itself, so the preview can't diverge from the delete.""" from ..services.cleanup_service import prune_bare_posts return await _run_dry_run_op(prune_bare_posts) @admin_bp.route("/posts/reconcile-duplicates", methods=["POST"]) async def posts_reconcile_duplicates(): """Tier-A: unify duplicate post rows for the same real post — the gallery-dl (attachment-id) + native (post-id) duplicates — onto ONE post-id-keyed keeper, moving image/provenance/attachment/link rows over. Images are untouched. dry_run=true returns {groups, posts_to_merge, sample}; dry_run=false applies and returns {groups, merged, sample}. Optional source_id scopes to one source. Same find_duplicate_post_groups predicate drives preview + apply (rule 93).""" from ..services.cleanup_service import reconcile_duplicate_posts body = await request.get_json(silent=True) or {} raw_source = body.get("source_id") try: source_id = int(raw_source) if raw_source is not None else None except (TypeError, ValueError): return _bad("invalid_source_id", detail="source_id must be an integer") return await _run_dry_run_op(reconcile_duplicate_posts, source_id=source_id) @admin_bp.route("/posts/refetch-external", methods=["POST"]) async def posts_refetch_external(): """Surgical re-fetch of a post's external file-host links (operator 2026-07-03): the normal cadence never re-walks deep back-catalogue posts, so a deleted external file only comes back by resetting its ExternalLink row(s) — this endpoint does that per post and dispatches the fetches. Sha-dedupe discards payload files that still exist, so only what's missing lands. Body: {external_post_id: str, source_id?: int (to disambiguate the same external id across sources)}.""" from ..services.external_links import refetch_links_for_post body = await request.get_json(silent=True) or {} ext_id = str(body.get("external_post_id") or "").strip() if not ext_id: return _bad("missing_external_post_id", detail="external_post_id is required") raw_source = body.get("source_id") try: source_id = int(raw_source) if raw_source is not None else None except (TypeError, ValueError): return _bad("invalid_source_id", detail="source_id must be an integer") async with get_session() as session: stmt = select(Post.id).where(Post.external_post_id == ext_id) if source_id is not None: stmt = stmt.where(Post.source_id == source_id) post_ids = (await session.execute(stmt)).scalars().all() if not post_ids: return _bad("post_not_found", status=404, detail=f"no post with external_post_id {ext_id!r}") results = {} for pid in post_ids: results[str(pid)] = await session.run_sync( lambda s, p=pid: refetch_links_for_post(s, p) ) return jsonify({"posts": results}) def _reset_content_confirm_token(projection: dict) -> str: """Stable 8-hex token derived from the live counts (mirrors the Tier-C bulk-delete token): it changes whenever the data changes, so the apply can only ever run against numbers the operator just previewed.""" canon = f"reset-content:{projection.get('count')}:{projection.get('applications')}" return hashlib.sha256(canon.encode("utf-8")).hexdigest()[:8] @admin_bp.route("/tags/reset-content", methods=["POST"]) async def tags_reset_content(): """Full-instance reset of the CONTENT vocabulary: deletes ALL general + character tags and their image applications — INCLUDING the examples the tagging heads learned from. Suggestions do NOT repopulate on their own (the Camie predictions that once did are long retired): the operator re-tags from scratch and the heads retrain from the new signal. fandom + series tags + series_page ordering are preserved. Deliberately Tier-C-gated despite the Tier-A shape (operator 2026-07-02: the full reset stays, but behind extra steps): dry_run returns the projection + a `confirm` token derived from the live counts; the apply must echo that token back or it is rejected.""" from ..services.cleanup_service import reset_content_tagging body = await request.get_json(silent=True) or {} dry_run = bool(body.get("dry_run", False)) async with get_session() as session: projection = await session.run_sync( lambda s: reset_content_tagging(s, dry_run=True) ) token = _reset_content_confirm_token(projection) if dry_run: projection["confirm"] = token return jsonify(projection) if str(body.get("confirm", "")) != token: return _bad( "confirm_mismatch", detail="run a fresh preview and echo its confirm token", ) result = await session.run_sync( lambda s: reset_content_tagging(s, dry_run=False) ) return jsonify(result) @admin_bp.route("/tags/normalize", methods=["POST"]) async def tags_normalize(): """#714: retro-normalize existing tags to the #701 canonical form (Title Case + collapsed whitespace) and merge case/whitespace-variant duplicates. dry_run=true (default) returns a projection inline — group/collision/rename counts + a sample of the changes — so the UI shows exactly what'll happen. dry_run=false dispatches the long-running maintenance task (the merge FK repoints can touch many tags); the UI tails the activity dashboard for the summary. Idempotent; back up first (the merges are irreversible).""" from ..services.tag_service import normalize_existing_tags body = await request.get_json(silent=True) or {} dry_run = bool(body.get("dry_run", True)) if dry_run: async with get_session() as session: result = await normalize_existing_tags(session, dry_run=True) return jsonify(result) from ..tasks.admin import normalize_tags_task async_result = normalize_tags_task.delay() return _queued(async_result) @admin_bp.route("/maintenance/db-stats", methods=["GET"]) async def db_stats(): """Per-table bloat readout (pg_stat_user_tables) for the high-churn tables so the operator can see when a VACUUM is worth running.""" from ..tasks.maintenance import VACUUM_TABLES wanted = set(VACUUM_TABLES) async with get_session() as session: rows = (await session.execute(text( "SELECT relname, n_live_tup, n_dead_tup, last_vacuum, " "last_autovacuum, last_analyze FROM pg_stat_user_tables" ))).all() def _iso(v): return v.isoformat() if v is not None else None out = [] for r in rows: if r.relname not in wanted: continue live = r.n_live_tup or 0 dead = r.n_dead_tup or 0 total = live + dead out.append({ "table": r.relname, "live": live, "dead": dead, "dead_pct": round(100 * dead / total, 1) if total else 0.0, "last_vacuum": _iso(r.last_vacuum), "last_autovacuum": _iso(r.last_autovacuum), "last_analyze": _iso(r.last_analyze), }) out.sort(key=lambda t: t["dead"], reverse=True) return jsonify({"tables": out}) @admin_bp.route("/maintenance/vacuum", methods=["POST"]) async def trigger_vacuum(): """Operator-triggered VACUUM (ANALYZE) over the high-churn tables — the same maintenance-queue task the weekly Beat schedule runs.""" from ..tasks.maintenance import vacuum_analyze vacuum_analyze.delay() return jsonify({"status": "queued"}), 202 @admin_bp.route("/maintenance/reextract-archives", methods=["POST"]) async def trigger_reextract_archives(): """Operator-triggered re-extract (#713): PostAttachments that are actually archives but were filed opaquely (pre magic-byte gate) get extracted and their members linked to the post. Idempotent; runs on the maintenance queue.""" from ..tasks.admin import reextract_archive_attachments_task async_result = reextract_archive_attachments_task.delay() return _queued(async_result) @admin_bp.route("/maintenance/prune-missing-files", methods=["POST"]) async def trigger_prune_missing_files(): """Operator-triggered orphan repair (#859): delete ImageRecords whose backing file is gone from disk (e.g. left by the external-attach unlink bug), so they stop 404-ing on playback. The task aborts WITHOUT deleting if a large fraction of files look missing (a filesystem/NFS stall). Maintenance queue; operator-triggered only — never an unattended sweep.""" from ..tasks.admin import prune_missing_file_records_task async_result = prune_missing_file_records_task.delay() return _queued(async_result) @admin_bp.route("/maintenance/dedup-videos", methods=["POST"]) async def trigger_dedup_videos(): """Tier-1 video dedup (#871). Body {"dry_run": bool}: dry_run=true previews what would be removed (groups / redundant count / reclaimable bytes) WITHOUT deleting; dry_run=false applies it (re-link posts to the keeper, then delete the redundant copies). Either way it first re-probes NULL-duration videos so the existing library participates. Returns the Celery task id — poll /maintenance/task-result/ for the summary.""" from ..tasks.admin import dedup_videos_task body = await request.get_json(silent=True) or {} dry_run = bool(body.get("dry_run", True)) # default to the SAFE preview async_result = dedup_videos_task.delay(dry_run=dry_run) return _queued(async_result) @admin_bp.route("/maintenance/purge-gated-previews", methods=["POST"]) async def trigger_purge_gated_previews(): """Cleanup (#874 follow-up). Body {"dry_run": bool}: dry_run=true previews how many blurred locked-preview images (grabbed from tier-gated Patreon posts before the fix) would be removed WITHOUT deleting; dry_run=false applies it. Re-walks every enabled Patreon source read-only and matches by content hash, so real content downloaded when access existed is provably spared. Returns the Celery task id — poll /maintenance/task-result/ for the summary.""" from ..tasks.admin import purge_gated_previews_task body = await request.get_json(silent=True) or {} dry_run = bool(body.get("dry_run", True)) # default to the SAFE preview async_result = purge_gated_previews_task.delay(dry_run=dry_run) return _queued(async_result) @admin_bp.route("/maintenance/task-result/", methods=["GET"]) async def maintenance_task_result(task_id: str): """Poll a maintenance Celery task's result (the summary dict it returns). Used by the video-dedup card to show the dry-run projection before apply.""" from ..celery_app import celery res = celery.AsyncResult(task_id) ready = res.ready() return jsonify({ "ready": ready, "successful": res.successful() if ready else None, "result": res.result if (ready and res.successful()) else None, })