From 46338a1f2e5da8dca750210a2dd6c56a463019ce Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 15 May 2026 07:46:38 -0400 Subject: [PATCH] feat(fc2b): add /api/allowlist and /api/aliases endpoints Allowlist: list-all, get-one (404 if not listed), PATCH threshold (range-validated), DELETE. Aliases: list-all (with canonical name), create (idempotent, 201), DELETE by (string, category). Tests integration-marked. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/api/__init__.py | 4 ++ backend/app/api/aliases.py | 65 +++++++++++++++++++++++++++++++ backend/app/api/allowlist.py | 74 ++++++++++++++++++++++++++++++++++++ tests/test_api_aliases.py | 51 +++++++++++++++++++++++++ tests/test_api_allowlist.py | 53 ++++++++++++++++++++++++++ 5 files changed, 247 insertions(+) create mode 100644 backend/app/api/aliases.py create mode 100644 backend/app/api/allowlist.py create mode 100644 tests/test_api_aliases.py create mode 100644 tests/test_api_allowlist.py diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 91f99ab..83b95ea 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -14,6 +14,8 @@ api_bp.add_url_rule("/health", view_func=health.get_health, methods=["GET"]) def all_blueprints() -> list[Blueprint]: + from .aliases import aliases_bp + from .allowlist import allowlist_bp from .gallery import gallery_bp from .import_admin import import_admin_bp from .settings import settings_bp @@ -26,4 +28,6 @@ def all_blueprints() -> list[Blueprint]: settings_bp, import_admin_bp, suggestions_bp, + allowlist_bp, + aliases_bp, ] diff --git a/backend/app/api/aliases.py b/backend/app/api/aliases.py new file mode 100644 index 0000000..3b1d2f6 --- /dev/null +++ b/backend/app/api/aliases.py @@ -0,0 +1,65 @@ +"""Aliases API: list, create, remove.""" + +from quart import Blueprint, jsonify, request + +from ..extensions import make_engine, make_session_factory +from ..services.ml.aliases import AliasService + +aliases_bp = Blueprint("aliases", __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 + + +@aliases_bp.route("/aliases", methods=["GET"]) +async def list_aliases(): + Session = _session_factory() + async with Session() as session: + rows = await AliasService(session).list_all() + return jsonify( + [ + { + "alias_string": r.alias_string, + "alias_category": r.alias_category, + "canonical_tag_id": r.canonical_tag_id, + "canonical_tag_name": r.canonical_tag_name, + } + for r in rows + ] + ) + + +@aliases_bp.route("/aliases", methods=["POST"]) +async def create_alias(): + body = await request.get_json() + required = {"alias_string", "alias_category", "canonical_tag_id"} + if not body or not required.issubset(body): + return jsonify({"error": f"required: {sorted(required)}"}), 400 + Session = _session_factory() + async with Session() as session: + await AliasService(session).create( + body["alias_string"], + body["alias_category"], + body["canonical_tag_id"], + ) + await session.commit() + return "", 201 + + +@aliases_bp.route( + "/aliases//", methods=["DELETE"] +) +async def remove_alias(alias_string: str, alias_category: str): + Session = _session_factory() + async with Session() as session: + await AliasService(session).remove(alias_string, alias_category) + await session.commit() + return "", 204 diff --git a/backend/app/api/allowlist.py b/backend/app/api/allowlist.py new file mode 100644 index 0000000..69889c8 --- /dev/null +++ b/backend/app/api/allowlist.py @@ -0,0 +1,74 @@ +"""Allowlist API: list, adjust threshold, remove.""" + +from quart import Blueprint, jsonify, request + +from ..extensions import make_engine, make_session_factory +from ..models import TagAllowlist +from ..services.ml.allowlist import AllowlistService + +allowlist_bp = Blueprint("allowlist", __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 + + +@allowlist_bp.route("/allowlist", methods=["GET"]) +async def list_allowlist(): + Session = _session_factory() + async with Session() as session: + rows = await AllowlistService(session).list_all() + return jsonify( + [ + { + "tag_id": r.tag_id, + "tag_name": r.tag_name, + "tag_kind": r.tag_kind, + "min_confidence": r.min_confidence, + } + for r in rows + ] + ) + + +@allowlist_bp.route("/tags//allowlist", methods=["GET"]) +async def get_one(tag_id: int): + Session = _session_factory() + async with Session() as session: + row = await session.get(TagAllowlist, tag_id) + if row is None: + return jsonify({"error": "not on allowlist"}), 404 + return jsonify( + {"min_confidence": row.min_confidence, "added_at": row.added_at.isoformat()} + ) + + +@allowlist_bp.route("/tags//allowlist", methods=["PATCH"]) +async def patch_threshold(tag_id: int): + body = await request.get_json() + if not body or "min_confidence" not in body: + return jsonify({"error": "min_confidence required"}), 400 + mc = float(body["min_confidence"]) + if not (0 < mc <= 1): + return jsonify({"error": "min_confidence must be in (0, 1]"}), 400 + Session = _session_factory() + async with Session() as session: + await AllowlistService(session).update_threshold(tag_id, mc) + await session.commit() + return "", 204 + + +@allowlist_bp.route("/tags//allowlist", methods=["DELETE"]) +async def remove(tag_id: int): + Session = _session_factory() + async with Session() as session: + await AllowlistService(session).remove(tag_id) + await session.commit() + return "", 204 diff --git a/tests/test_api_aliases.py b/tests/test_api_aliases.py new file mode 100644 index 0000000..123f8ef --- /dev/null +++ b/tests/test_api_aliases.py @@ -0,0 +1,51 @@ +import pytest + +from backend.app import create_app +from backend.app.models import TagKind +from backend.app.services.tag_service import TagService + +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 + + +@pytest.mark.asyncio +async def test_create_list_delete(client, db): + tag = await TagService(db).find_or_create("Canon", TagKind.character) + await db.commit() + + resp = await client.post( + "/api/aliases", + json={ + "alias_string": "model_name", + "alias_category": "character", + "canonical_tag_id": tag.id, + }, + ) + assert resp.status_code == 201 + + resp = await client.get("/api/aliases") + body = await resp.get_json() + assert any( + a["alias_string"] == "model_name" + and a["canonical_tag_name"] == "Canon" + for a in body + ) + + resp = await client.delete("/api/aliases/model_name/character") + assert resp.status_code == 204 + + +@pytest.mark.asyncio +async def test_create_requires_fields(client): + resp = await client.post("/api/aliases", json={"alias_string": "x"}) + assert resp.status_code == 400 diff --git a/tests/test_api_allowlist.py b/tests/test_api_allowlist.py new file mode 100644 index 0000000..4505b7d --- /dev/null +++ b/tests/test_api_allowlist.py @@ -0,0 +1,53 @@ +import pytest + +from backend.app import create_app +from backend.app.models import TagAllowlist, TagKind +from backend.app.services.tag_service import TagService + +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 + + +@pytest.mark.asyncio +async def test_list_and_patch_and_delete(client, db): + tag = await TagService(db).find_or_create("AL", TagKind.character) + db.add(TagAllowlist(tag_id=tag.id, min_confidence=0.95)) + await db.commit() + + resp = await client.get("/api/allowlist") + assert resp.status_code == 200 + assert any(r["tag_id"] == tag.id for r in await resp.get_json()) + + resp = await client.patch( + f"/api/tags/{tag.id}/allowlist", json={"min_confidence": 0.80} + ) + assert resp.status_code == 204 + + resp = await client.get(f"/api/tags/{tag.id}/allowlist") + assert (await resp.get_json())["min_confidence"] == pytest.approx(0.80) + + resp = await client.delete(f"/api/tags/{tag.id}/allowlist") + assert resp.status_code == 204 + resp = await client.get(f"/api/tags/{tag.id}/allowlist") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_patch_rejects_out_of_range(client, db): + tag = await TagService(db).find_or_create("AL2", TagKind.character) + db.add(TagAllowlist(tag_id=tag.id)) + await db.commit() + resp = await client.patch( + f"/api/tags/{tag.id}/allowlist", json={"min_confidence": 1.5} + ) + assert resp.status_code == 400