diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 3a96e9d..600fc70 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -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] diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py new file mode 100644 index 0000000..d8d59e2 --- /dev/null +++ b/backend/app/api/tags.py @@ -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//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//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//tags/", 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 diff --git a/tests/test_api_tags.py b/tests/test_api_tags.py new file mode 100644 index 0000000..9699d7f --- /dev/null +++ b/tests/test_api_tags.py @@ -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