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,
}
)
+112
View File
@@ -0,0 +1,112 @@
import pytest
from backend.app import create_app
pytestmark = pytest.mark.integration
@pytest.fixture
async def app():
return create_app()
@pytest.fixture
async def client(app):
async with app.test_client() as c:
yield c
async def _mk(client, name, kind):
r = await client.post("/api/tags", json={"name": name, "kind": kind})
return (await r.get_json())["id"]
@pytest.mark.asyncio
async def test_rename_collision_returns_rich_409(client):
tgt = await _mk(client, "Canonical", "general")
src = await _mk(client, "Duplicate", "general")
resp = await client.patch(f"/api/tags/{src}", json={"name": "Canonical"})
assert resp.status_code == 409
body = await resp.get_json()
assert body["target"]["id"] == tgt
assert body["target"]["name"] == "Canonical"
assert body["source_image_count"] == 0
assert body["will_alias"] is False
@pytest.mark.asyncio
async def test_merge_endpoint_moves_and_deletes(client, monkeypatch):
calls = []
from backend.app.tasks import ml as ml_tasks
monkeypatch.setattr(
ml_tasks.apply_allowlist_tags,
"delay",
lambda **kw: calls.append(kw),
)
tgt = await _mk(client, "Keep", "general")
src = await _mk(client, "Gone", "general")
resp = await client.post(
f"/api/tags/{src}/merge", json={"target_id": tgt}
)
assert resp.status_code == 200
body = await resp.get_json()
assert body["target"]["id"] == tgt
assert body["source_deleted"] is True
# source is gone: renaming something else onto its old name no longer collides
other = await _mk(client, "Other", "general")
r2 = await client.patch(f"/api/tags/{other}", json={"name": "Gone"})
assert r2.status_code == 200
@pytest.mark.asyncio
async def test_merge_enqueues_backfill_when_target_allowlisted(
client, monkeypatch
):
calls = []
from backend.app.tasks import ml as ml_tasks
monkeypatch.setattr(
ml_tasks.apply_allowlist_tags,
"delay",
lambda **kw: calls.append(kw),
)
tgt = await _mk(client, "AllowTgt", "general")
src = await _mk(client, "AllowSrc", "general")
# No public route adds a tag to the allowlist (it happens via
# accept-suggestion); set the row directly through the app session.
from backend.app.extensions import get_session
from backend.app.models.tag_allowlist import TagAllowlist
async with get_session() as s:
s.add(TagAllowlist(tag_id=tgt))
await s.commit()
resp = await client.post(
f"/api/tags/{src}/merge", json={"target_id": tgt}
)
assert resp.status_code == 200
assert calls == [{"tag_id": tgt}]
@pytest.mark.asyncio
async def test_merge_self_is_400(client):
t = await _mk(client, "Selfie", "general")
resp = await client.post(f"/api/tags/{t}/merge", json={"target_id": t})
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_merge_missing_target_is_404(client):
t = await _mk(client, "Lonely", "general")
resp = await client.post(
f"/api/tags/{t}/merge", json={"target_id": 999999}
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_merge_requires_target_id(client):
t = await _mk(client, "NoBody", "general")
resp = await client.post(f"/api/tags/{t}/merge", json={})
assert resp.status_code == 400