shinkan-jinkendo/backend/routers/ai_skill_retrieval_admin.py
Lars 286c36e9d7
Some checks failed
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Has been cancelled
Document Superadmin API for AI Skill Retrieval Profiles and Update Access Layer
- Added documentation for the new Superadmin CRUD endpoints for managing AI Skill Retrieval Profiles (`/api/admin/ai-skill-retrieval-profiles*`).
- Updated the ACCESS_LAYER_ENDPOINT_AUDIT.md to include the new Superadmin API and its exempt status.
- Registered the ai_skill_retrieval_admin router in the backend and updated versioning to reflect the changes.
- Enhanced the frontend with a new Admin page for AI Skill Retrieval, including navigation and API integration for profile management.
2026-05-22 09:57:39 +02:00

371 lines
13 KiB
Python

"""
Superadmin API: Pflege von ai_skill_retrieval_profiles (KI Skill-Katalog / exercise_ai).
Kein Vereinsbezug — require_auth + is_superadmin; kein TenantContext.
Siehe ACCESS_LAYER_ENDPOINT_AUDIT.md.
"""
from __future__ import annotations
import json
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field, model_validator
from psycopg2.extras import Json
from auth import require_auth
from club_tenancy import is_superadmin
from db import get_cursor, get_db, r2d
router = APIRouter(tags=["admin_ai_skill_retrieval"])
def _require_superadmin(session: dict) -> dict:
role = (session.get("role") or "").strip().lower()
if not is_superadmin(role):
raise HTTPException(status_code=403, detail="Nur Superadmins")
return session
def _table_ready(cur) -> bool:
cur.execute("SELECT to_regclass(%s)::text AS t", ("public.ai_skill_retrieval_profiles",))
row = cur.fetchone()
if not row:
return False
val = row.get("t") if isinstance(row, dict) else row[0]
return val is not None and str(val).strip() != ""
def _assert_table(cur) -> None:
if not _table_ready(cur):
raise HTTPException(
status_code=503,
detail="Tabelle ai_skill_retrieval_profiles fehlt — Migration 068 ausführen.",
)
def _normalize_config(raw: Any) -> Dict[str, Any]:
if raw is None:
return {}
if isinstance(raw, dict):
return raw
if isinstance(raw, str):
try:
parsed = json.loads(raw)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"config: ungültiges JSON — {e}") from e
if not isinstance(parsed, dict):
raise HTTPException(status_code=400, detail="config muss ein JSON-Objekt sein.")
return parsed
raise HTTPException(status_code=400, detail="config muss ein Objekt sein.")
class AiRetrievalProfileCreate(BaseModel):
name: str = Field(..., min_length=2, max_length=200)
description: Optional[str] = Field("", max_length=4000)
active: bool = True
is_default: bool = False
focus_area_id: Optional[int] = Field(None, ge=1)
config: Dict[str, Any] = Field(default_factory=dict)
@model_validator(mode="after")
def default_vs_focus(self):
if self.is_default and self.focus_area_id is not None:
raise ValueError("Standardprofil darf keinen Fokusbereich (focus_area_id) haben.")
if not self.is_default and self.focus_area_id is None:
raise ValueError("Profil ohne Standard-Flag benötigt eine focus_area_id.")
return self
class AiRetrievalProfileUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=2, max_length=200)
description: Optional[str] = Field(None, max_length=4000)
active: Optional[bool] = None
is_default: Optional[bool] = None
focus_area_id: Optional[int] = Field(None, ge=1)
config: Optional[Dict[str, Any]] = None
def _row_to_out(row: dict) -> dict:
cfg = row.get("config")
if isinstance(cfg, str):
try:
cfg = json.loads(cfg)
except json.JSONDecodeError:
cfg = {}
if not isinstance(cfg, dict):
cfg = {}
out = {
"id": row["id"],
"focus_area_id": row.get("focus_area_id"),
"focus_area_name": row.get("focus_area_name"),
"is_default": bool(row.get("is_default")),
"name": row.get("name") or "",
"description": row.get("description") or "",
"active": bool(row.get("active", True)),
"config": cfg,
"updated_at": row.get("updated_at"),
}
return out
def _active_focus_conflict(cur, focus_area_id: int, exclude_id: Optional[int] = None) -> bool:
if exclude_id is not None:
cur.execute(
"""
SELECT 1 FROM ai_skill_retrieval_profiles
WHERE active = true AND focus_area_id = %s AND id != %s
LIMIT 1
""",
(focus_area_id, exclude_id),
)
else:
cur.execute(
"""
SELECT 1 FROM ai_skill_retrieval_profiles
WHERE active = true AND focus_area_id = %s
LIMIT 1
""",
(focus_area_id,),
)
return cur.fetchone() is not None
def _focus_area_exists(cur, focus_area_id: int) -> bool:
cur.execute(
"SELECT 1 FROM focus_areas WHERE id = %s AND (status IS NULL OR status = 'active') LIMIT 1",
(focus_area_id,),
)
return cur.fetchone() is not None
@router.get("/api/admin/ai-skill-retrieval-profiles")
def list_ai_skill_retrieval_profiles(session: dict = Depends(require_auth)):
_require_superadmin(session)
with get_db() as conn:
cur = get_cursor(conn)
_assert_table(cur)
cur.execute(
"""
SELECT p.id, p.focus_area_id, p.is_default, p.name, p.description, p.active, p.config, p.updated_at,
fa.name AS focus_area_name
FROM ai_skill_retrieval_profiles p
LEFT JOIN focus_areas fa ON fa.id = p.focus_area_id
ORDER BY p.is_default DESC NULLS LAST, fa.name NULLS LAST, p.name
"""
)
rows = [r2d(r) for r in cur.fetchall()]
return [_row_to_out(r) for r in rows]
@router.get("/api/admin/ai-skill-retrieval-profiles/{profile_id}")
def get_ai_skill_retrieval_profile(profile_id: int, session: dict = Depends(require_auth)):
_require_superadmin(session)
with get_db() as conn:
cur = get_cursor(conn)
_assert_table(cur)
cur.execute(
"""
SELECT p.id, p.focus_area_id, p.is_default, p.name, p.description, p.active, p.config, p.updated_at,
fa.name AS focus_area_name
FROM ai_skill_retrieval_profiles p
LEFT JOIN focus_areas fa ON fa.id = p.focus_area_id
WHERE p.id = %s
""",
(profile_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
return _row_to_out(r2d(row))
@router.post("/api/admin/ai-skill-retrieval-profiles", status_code=201)
def create_ai_skill_retrieval_profile(
body: AiRetrievalProfileCreate,
session: dict = Depends(require_auth),
):
_require_superadmin(session)
cfg = _normalize_config(body.config)
with get_db() as conn:
cur = get_cursor(conn)
_assert_table(cur)
if body.is_default:
cur.execute(
"UPDATE ai_skill_retrieval_profiles SET is_default = false, updated_at = NOW() WHERE is_default = true"
)
else:
if not _focus_area_exists(cur, int(body.focus_area_id)):
raise HTTPException(status_code=400, detail="Unbekannter oder inaktiver Fokusbereich.")
if body.active and _active_focus_conflict(cur, int(body.focus_area_id)):
raise HTTPException(
status_code=409,
detail="Für diesen Fokusbereich existiert bereits ein aktives Profil.",
)
cur.execute(
"""
INSERT INTO ai_skill_retrieval_profiles
(focus_area_id, is_default, name, description, active, config, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, NOW())
RETURNING id
""",
(
None if body.is_default else body.focus_area_id,
body.is_default,
body.name.strip(),
(body.description or "").strip(),
body.active,
Json(cfg),
),
)
new_id_row = cur.fetchone()
new_id = int(new_id_row["id"] if isinstance(new_id_row, dict) else new_id_row[0])
cur.execute(
"""
SELECT p.id, p.focus_area_id, p.is_default, p.name, p.description, p.active, p.config, p.updated_at,
fa.name AS focus_area_name
FROM ai_skill_retrieval_profiles p
LEFT JOIN focus_areas fa ON fa.id = p.focus_area_id
WHERE p.id = %s
""",
(new_id,),
)
row = r2d(cur.fetchone())
return _row_to_out(row)
@router.put("/api/admin/ai-skill-retrieval-profiles/{profile_id}")
def update_ai_skill_retrieval_profile(
profile_id: int,
body: AiRetrievalProfileUpdate,
session: dict = Depends(require_auth),
):
_require_superadmin(session)
with get_db() as conn:
cur = get_cursor(conn)
_assert_table(cur)
cur.execute(
"""
SELECT id, focus_area_id, is_default, name, description, active, config, updated_at
FROM ai_skill_retrieval_profiles WHERE id = %s
""",
(profile_id,),
)
old = cur.fetchone()
if not old:
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
old_d = dict(old)
if body.is_default is True and body.focus_area_id is not None:
raise HTTPException(
status_code=400,
detail="Standardprofil und Fokusbereich schließen sich aus.",
)
nm = body.name.strip() if body.name is not None else (old_d["name"] or "")
nm = nm.strip()
desc = (
body.description.strip() if body.description is not None else (old_d.get("description") or "")
).strip()
active = body.active if body.active is not None else bool(old_d.get("active", True))
next_is_default = body.is_default if body.is_default is not None else bool(old_d.get("is_default"))
next_focus_raw = (
body.focus_area_id if body.focus_area_id is not None else old_d.get("focus_area_id")
)
cfg = (
_normalize_config(body.config) if body.config is not None else _normalize_config(old_d.get("config"))
)
if next_is_default:
focus_id_sql: Optional[int] = None
else:
if next_focus_raw is None:
raise HTTPException(status_code=400, detail="Nicht-Standard-Profil benötigt focus_area_id.")
focus_id_sql = int(next_focus_raw)
if not _focus_area_exists(cur, focus_id_sql):
raise HTTPException(status_code=400, detail="Unbekannter oder inaktiver Fokusbereich.")
if active and _active_focus_conflict(cur, focus_id_sql, exclude_id=profile_id):
raise HTTPException(
status_code=409,
detail="Für diesen Fokusbereich existiert bereits ein anderes aktives Profil.",
)
old_default = bool(old_d.get("is_default"))
if old_default and not next_is_default:
raise HTTPException(
status_code=400,
detail="Das Standardprofil kann hier nicht zum Fokusbereichsprofil geändert werden — zuerst ein anderes Profil als Standard aktivieren.",
)
if old_default and active is False:
raise HTTPException(status_code=400, detail="Standardprofil kann nicht deaktiviert werden.")
if next_is_default:
cur.execute(
"""
UPDATE ai_skill_retrieval_profiles
SET is_default = false, updated_at = NOW()
WHERE is_default = true AND id != %s
""",
(profile_id,),
)
cur.execute(
"""
UPDATE ai_skill_retrieval_profiles
SET focus_area_id = %s,
is_default = %s,
name = %s,
description = %s,
active = %s,
config = %s,
updated_at = NOW()
WHERE id = %s
""",
(
focus_id_sql,
next_is_default,
nm,
desc,
active,
Json(cfg),
profile_id,
),
)
cur.execute(
"""
SELECT p.id, p.focus_area_id, p.is_default, p.name, p.description, p.active, p.config, p.updated_at,
fa.name AS focus_area_name
FROM ai_skill_retrieval_profiles p
LEFT JOIN focus_areas fa ON fa.id = p.focus_area_id
WHERE p.id = %s
""",
(profile_id,),
)
row = r2d(cur.fetchone())
return _row_to_out(row)
@router.delete("/api/admin/ai-skill-retrieval-profiles/{profile_id}")
def delete_ai_skill_retrieval_profile(profile_id: int, session: dict = Depends(require_auth)):
_require_superadmin(session)
with get_db() as conn:
cur = get_cursor(conn)
_assert_table(cur)
cur.execute(
"SELECT id, is_default FROM ai_skill_retrieval_profiles WHERE id = %s",
(profile_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
if bool(row.get("is_default") if isinstance(row, dict) else row[1]):
raise HTTPException(status_code=400, detail="Standardprofil kann nicht gelöscht werden.")
cur.execute("DELETE FROM ai_skill_retrieval_profiles WHERE id = %s", (profile_id,))
return {"deleted": True, "id": profile_id}