feat(fc2a): add /api/tags endpoints (autocomplete, create, image association)

Thin async blueprint delegating to TagService. Returns 400 with the
TagValidationError message on bad kind/fandom combos so the frontend can
surface the reason. List/add/remove endpoints scoped under
/api/images/<id>/tags follow REST conventions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 12:10:10 -04:00
parent fd8cf83003
commit 0e66368010
3 changed files with 186 additions and 2 deletions
+2 -2
View File
@@ -15,5 +15,5 @@ api_bp.add_url_rule("/health", view_func=health.get_health, methods=["GET"])
def all_blueprints() -> list[Blueprint]:
from .gallery import gallery_bp
# FC-2a additions: tags, settings, import_admin land in later tasks
return [api_bp, gallery_bp]
from .tags import tags_bp
return [api_bp, gallery_bp, tags_bp]
+126
View File
@@ -0,0 +1,126 @@
"""Tags API: autocomplete, create, list/add/remove for an image."""
from quart import Blueprint, jsonify, request
from ..extensions import make_engine, make_session_factory
from ..models import TagKind
from ..services.tag_service import TagService, TagValidationError
tags_bp = Blueprint("tags", __name__, url_prefix="/api")
_engine = None
_Session = None
def _session_factory():
global _engine, _Session
if _engine is None:
_engine = make_engine()
_Session = make_session_factory(_engine)
return _Session
def _coerce_kind(raw: str | None) -> TagKind | None:
if raw is None:
return None
try:
return TagKind(raw)
except ValueError:
return None
@tags_bp.route("/tags/autocomplete", methods=["GET"])
async def autocomplete():
q = request.args.get("q", "")
kind = _coerce_kind(request.args.get("kind"))
try:
limit = int(request.args.get("limit", "20"))
except ValueError:
return jsonify({"error": "limit must be an integer"}), 400
Session = _session_factory()
async with Session() as session:
svc = TagService(session)
hits = await svc.autocomplete(q, kind=kind, limit=limit)
return jsonify(
[
{
"id": h.id,
"name": h.name,
"kind": h.kind,
"fandom_id": h.fandom_id,
"fandom_name": h.fandom_name,
"image_count": h.image_count,
}
for h in hits
]
)
@tags_bp.route("/tags", methods=["POST"])
async def create_tag():
body = await request.get_json()
if not body or "name" not in body or "kind" not in body:
return jsonify({"error": "name and kind required"}), 400
name = body["name"]
kind = _coerce_kind(body["kind"])
if kind is None:
return jsonify({"error": f"invalid kind {body['kind']!r}"}), 400
fandom_id = body.get("fandom_id")
Session = _session_factory()
async with Session() as session:
svc = TagService(session)
try:
tag = await svc.find_or_create(name, kind, fandom_id=fandom_id)
except TagValidationError as exc:
return jsonify({"error": str(exc)}), 400
await session.commit()
return jsonify(
{"id": tag.id, "name": tag.name, "kind": tag.kind.value, "fandom_id": tag.fandom_id}
), 201
@tags_bp.route("/images/<int:image_id>/tags", methods=["GET"])
async def list_tags_for_image(image_id: int):
Session = _session_factory()
async with Session() as session:
svc = TagService(session)
tags = await svc.list_for_image(image_id)
return jsonify(
[
{
"id": t.id,
"name": t.name,
"kind": t.kind.value,
"fandom_id": t.fandom_id,
}
for t in tags
]
)
@tags_bp.route("/images/<int:image_id>/tags", methods=["POST"])
async def add_tag_to_image(image_id: int):
body = await request.get_json()
if not body or "tag_id" not in body:
return jsonify({"error": "tag_id required"}), 400
source = body.get("source", "manual")
Session = _session_factory()
async with Session() as session:
svc = TagService(session)
await svc.add_to_image(image_id, body["tag_id"], source=source)
await session.commit()
return "", 204
@tags_bp.route("/images/<int:image_id>/tags/<int:tag_id>", methods=["DELETE"])
async def remove_tag_from_image(image_id: int, tag_id: int):
Session = _session_factory()
async with Session() as session:
svc = TagService(session)
await svc.remove_from_image(image_id, tag_id)
await session.commit()
return "", 204
+58
View File
@@ -0,0 +1,58 @@
import pytest
from backend.app import create_app
@pytest.fixture
async def app():
return create_app()
@pytest.fixture
async def client(app):
async with app.test_client() as c:
yield c
@pytest.mark.asyncio
async def test_autocomplete_empty(client):
resp = await client.get("/api/tags/autocomplete?q=")
assert resp.status_code == 200
assert await resp.get_json() == []
@pytest.mark.asyncio
async def test_create_tag(client):
resp = await client.post("/api/tags", json={"name": "Bob", "kind": "artist"})
assert resp.status_code == 201
body = await resp.get_json()
assert body["name"] == "Bob"
assert body["kind"] == "artist"
@pytest.mark.asyncio
async def test_create_tag_rejects_invalid_kind(client):
resp = await client.post("/api/tags", json={"name": "Bob", "kind": "notakind"})
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_create_character_without_fandom_ok(client):
resp = await client.post("/api/tags", json={"name": "Alice", "kind": "character"})
assert resp.status_code == 201
@pytest.mark.asyncio
async def test_create_character_with_bad_fandom_id(client):
artist_resp = await client.post("/api/tags", json={"name": "X", "kind": "artist"})
artist_id = (await artist_resp.get_json())["id"]
resp = await client.post(
"/api/tags", json={"name": "Y", "kind": "character", "fandom_id": artist_id}
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_create_tag_missing_required(client):
resp = await client.post("/api/tags", json={"name": "Bob"})
assert resp.status_code == 400