feat(api): rich 409 merge hint and POST tags merge endpoint with backfill enqueue

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 13:40:37 -04:00
parent 8fbeabe9ba
commit 305687dc33
2 changed files with 168 additions and 5 deletions
+56 -5
View File
@@ -1,11 +1,17 @@
"""Tags API: autocomplete, create, list/add/remove for an image."""
from quart import Blueprint, jsonify, request
from sqlalchemy import exists, select
from ..extensions import get_session
from ..models import TagKind
from ..models.tag_allowlist import TagAllowlist
from ..services.tag_directory_service import TagDirectoryService
from ..services.tag_service import TagService, TagValidationError
from ..services.tag_service import (
TagMergeConflict,
TagService,
TagValidationError,
)
tags_bp = Blueprint("tags", __name__, url_prefix="/api")
@@ -138,12 +144,57 @@ async def rename_tag(tag_id: int):
svc = TagService(session)
try:
tag = await svc.rename(tag_id, body["name"])
except TagMergeConflict as exc:
return jsonify(
{
"error": str(exc),
"target": {
"id": exc.target_id,
"name": exc.target_name,
},
"source_image_count": exc.source_image_count,
"will_alias": exc.will_alias,
}
), 409
except TagValidationError as exc:
# Collision → 409 so the frontend can show the FC-2c merge hint.
msg = str(exc)
status = 409 if "already exists" in msg else 400
return jsonify({"error": msg}), status
return jsonify({"error": str(exc)}), 400
await session.commit()
return jsonify(
{"id": tag.id, "name": tag.name, "kind": tag.kind.value}
)
@tags_bp.route("/tags/<int:source_id>/merge", methods=["POST"])
async def merge_tag(source_id: int):
body = await request.get_json()
if not body or "target_id" not in body:
return jsonify({"error": "target_id required"}), 400
target_id = body["target_id"]
async with get_session() as session:
svc = TagService(session)
try:
result = await svc.merge(source_id, target_id)
except TagValidationError as exc:
msg = str(exc)
status = 404 if "not found" in msg else 400
return jsonify({"error": msg}), status
await session.commit()
target_allowlisted = await session.scalar(
select(exists().where(TagAllowlist.tag_id == result.target_id))
)
if target_allowlisted:
from ..tasks.ml import apply_allowlist_tags
apply_allowlist_tags.delay(tag_id=result.target_id)
return jsonify(
{
"target": {
"id": result.target_id,
"name": result.target_name,
"kind": result.target_kind,
},
"merged_count": result.merged_count,
"alias_created": result.alias_created,
"source_deleted": result.source_deleted,
}
)