diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 31ce17d..a5e0f6c 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -112,3 +112,70 @@ async def images_bulk_delete(): 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") + + 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})