refactor(admin): consolidate Tier-A dry-run/apply handlers onto one helper (#753)
DRY pass on the cleanup/admin destructive-ops surface (task #753, hardened process #594). Five Tier-A endpoints repeated the same get_json -> dry_run -> run_sync(service_fn) -> jsonify block verbatim. Extract _run_dry_run_op(service_fn, **kwargs); the five route handlers now delegate. reconcile keeps its source_id validation and passes it through **kwargs. The cleanup_service predicates were already shared between preview and apply (find_*_conditions / find_duplicate_post_groups) — the post-data-loss fix — so no backend-logic change; this is purely the HTTP-handler boilerplate. Consumers (all routed through the helper, verified no copy left behind): prune_unused_tags, prune_bare_posts, reconcile_duplicate_posts (+source_id), purge_legacy_tags, reset_content_tagging. Added route-level tests for prune-bare (apply) and reconcile (apply + source_id passthrough + invalid-source_id 400) — the two helper consumers that previously had only service-level coverage, so every consumer is exercised at the route. Findings B (queued-response helper) and C (store dry-run POST helper) identified but not applied this pass (operator scoped to A). The card preview->commit state machine is deferred to a frontend pattern-consistency sweep. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+22
-44
@@ -39,6 +39,23 @@ def _bulk_image_confirm_token(image_ids: list[int]) -> str:
|
||||
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)
|
||||
|
||||
|
||||
@admin_bp.route("/artists/<slug>/cascade-delete", methods=["POST"])
|
||||
async def artist_cascade_delete(slug: str):
|
||||
body = await request.get_json(silent=True) or {}
|
||||
@@ -193,16 +210,7 @@ async def tags_prune_unused():
|
||||
re-call with dry_run=false."""
|
||||
from ..services.cleanup_service import prune_unused_tags
|
||||
|
||||
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: prune_unused_tags(
|
||||
sync_sess, dry_run=dry_run,
|
||||
)
|
||||
)
|
||||
return jsonify(result)
|
||||
return await _run_dry_run_op(prune_unused_tags)
|
||||
|
||||
|
||||
@admin_bp.route("/posts/prune-bare", methods=["POST"])
|
||||
@@ -214,16 +222,7 @@ async def posts_prune_bare():
|
||||
prune itself, so the preview can't diverge from the delete."""
|
||||
from ..services.cleanup_service import prune_bare_posts
|
||||
|
||||
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: prune_bare_posts(
|
||||
sync_sess, dry_run=dry_run,
|
||||
)
|
||||
)
|
||||
return jsonify(result)
|
||||
return await _run_dry_run_op(prune_bare_posts)
|
||||
|
||||
|
||||
@admin_bp.route("/posts/reconcile-duplicates", methods=["POST"])
|
||||
@@ -237,20 +236,13 @@ async def posts_reconcile_duplicates():
|
||||
from ..services.cleanup_service import reconcile_duplicate_posts
|
||||
|
||||
body = await request.get_json(silent=True) or {}
|
||||
dry_run = bool(body.get("dry_run", False))
|
||||
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:
|
||||
result = await session.run_sync(
|
||||
lambda sync_sess: reconcile_duplicate_posts(
|
||||
sync_sess, source_id=source_id, dry_run=dry_run,
|
||||
)
|
||||
)
|
||||
return jsonify(result)
|
||||
return await _run_dry_run_op(reconcile_duplicate_posts, source_id=source_id)
|
||||
|
||||
|
||||
@admin_bp.route("/tags/purge-legacy", methods=["POST"])
|
||||
@@ -263,14 +255,7 @@ async def tags_purge_legacy():
|
||||
operator confirms with dry_run=false."""
|
||||
from ..services.cleanup_service import purge_legacy_tags
|
||||
|
||||
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: purge_legacy_tags(sync_sess, dry_run=dry_run)
|
||||
)
|
||||
return jsonify(result)
|
||||
return await _run_dry_run_op(purge_legacy_tags)
|
||||
|
||||
|
||||
@admin_bp.route("/tags/reset-content", methods=["POST"])
|
||||
@@ -284,14 +269,7 @@ async def tags_reset_content():
|
||||
Irreversible except via DB backup restore."""
|
||||
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:
|
||||
result = await session.run_sync(
|
||||
lambda sync_sess: reset_content_tagging(sync_sess, dry_run=dry_run)
|
||||
)
|
||||
return jsonify(result)
|
||||
return await _run_dry_run_op(reset_content_tagging)
|
||||
|
||||
|
||||
@admin_bp.route("/tags/normalize", methods=["POST"])
|
||||
|
||||
+62
-1
@@ -8,7 +8,7 @@ import hashlib
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.models import Artist, ImageRecord, Tag, TagKind
|
||||
from backend.app.models import Artist, ImageRecord, Post, Tag, TagKind
|
||||
from backend.app.models.tag import image_tag
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
@@ -411,6 +411,67 @@ async def test_purge_legacy_commit_deletes_only_legacy(client, db):
|
||||
assert gone == 0
|
||||
|
||||
|
||||
# --- Tier-A: POST /posts/prune-bare + /posts/reconcile-duplicates ---
|
||||
# These two routes share _run_dry_run_op with the tag prunes (DRY pass,
|
||||
# task #753); cover their apply path + reconcile's source_id passthrough so
|
||||
# every helper consumer is exercised at the route level, not just the service.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prune_bare_commit_deletes_bare_posts(client, db):
|
||||
from sqlalchemy import func, select
|
||||
|
||||
a = Artist(name="BP", slug="bp-api")
|
||||
db.add(a)
|
||||
await db.flush()
|
||||
db.add(Post(artist_id=a.id, external_post_id="bare1")) # no images/attachments
|
||||
await db.commit()
|
||||
|
||||
resp = await client.post(
|
||||
"/api/admin/posts/prune-bare", json={"dry_run": False},
|
||||
)
|
||||
body = await resp.get_json()
|
||||
assert body["deleted"] >= 1
|
||||
remaining = (await db.execute(
|
||||
select(func.count()).select_from(Post).where(Post.external_post_id == "bare1")
|
||||
)).scalar_one()
|
||||
assert remaining == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_duplicates_commit_merges_via_endpoint(client, db):
|
||||
from sqlalchemy import select
|
||||
|
||||
a = Artist(name="RC", slug="rc-api")
|
||||
db.add(a)
|
||||
await db.flush()
|
||||
db.add_all([
|
||||
Post(artist_id=a.id, external_post_id="711509",
|
||||
raw_metadata={"post_id": 1923726, "category": "subscribestar"},
|
||||
description="body"),
|
||||
Post(artist_id=a.id, external_post_id="1923726",
|
||||
raw_metadata={"post_id": 1923726, "category": "subscribestar"}),
|
||||
])
|
||||
await db.commit()
|
||||
|
||||
resp = await client.post(
|
||||
"/api/admin/posts/reconcile-duplicates", json={"dry_run": False},
|
||||
)
|
||||
body = await resp.get_json()
|
||||
assert body["merged"] == 1
|
||||
rows = (await db.execute(select(Post.external_post_id))).scalars().all()
|
||||
assert rows == ["1923726"] # one post-id-keyed keeper survives
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_duplicates_rejects_bad_source_id(client):
|
||||
resp = await client.post(
|
||||
"/api/admin/posts/reconcile-duplicates",
|
||||
json={"dry_run": True, "source_id": "abc"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# --- DB maintenance: bloat readout + manual VACUUM trigger ----------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user