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:
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user