""" Reifegradmodelle / Fähigkeitsmatrix (kontextbezogen) Lesen: alle authentifizierten Nutzer. Schreiben: admin, superadmin. """ from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, Query from auth import require_auth from db import get_db, get_cursor, r2d router = APIRouter(prefix="/api", tags=["maturity_models"]) def _require_admin(session: dict) -> None: role = session.get("role") if role not in ("admin", "superadmin"): raise HTTPException(403, "Nur Administratoren dürfen Reifegradmodelle verwalten") def _row_maturity_model(cur, model_id: int) -> Optional[Dict[str, Any]]: cur.execute( """ SELECT mm.*, fa.name AS focus_area_name, sd.name AS style_direction_name, tg.name AS target_group_name FROM maturity_models mm LEFT JOIN focus_areas fa ON mm.focus_area_id = fa.id LEFT JOIN style_directions sd ON mm.style_direction_id = sd.id LEFT JOIN target_groups tg ON mm.target_group_id = tg.id WHERE mm.id = %s """, (model_id,), ) row = cur.fetchone() return r2d(row) if row else None def _load_full_model(cur, model_id: int) -> Dict[str, Any]: base = _row_maturity_model(cur, model_id) if not base: raise HTTPException(404, "Reifegradmodell nicht gefunden") cur.execute( """ SELECT * FROM model_levels WHERE maturity_model_id = %s ORDER BY sort_order ASC, level_number ASC """, (model_id,), ) levels = [r2d(r) for r in cur.fetchall()] cur.execute( """ SELECT ms.*, s.name AS skill_name, s.status AS skill_status FROM model_skills ms JOIN skills s ON s.id = ms.skill_id WHERE ms.maturity_model_id = %s ORDER BY ms.sort_order ASC, s.name ASC """, (model_id,), ) model_skills = [r2d(r) for r in cur.fetchall()] cur.execute( """ SELECT msl.*, s.name AS skill_name FROM model_skill_levels msl JOIN skills s ON s.id = msl.skill_id WHERE msl.maturity_model_id = %s ORDER BY s.name ASC, msl.level_number ASC """, (model_id,), ) skill_levels = [r2d(r) for r in cur.fetchall()] return { **base, "levels": levels, "model_skills": model_skills, "skill_levels": skill_levels, } def _insert_default_levels(cur, model_id: int, level_count: int) -> None: for i in range(1, level_count + 1): cur.execute( """ INSERT INTO model_levels (maturity_model_id, level_number, name, description, sort_order) VALUES (%s, %s, %s, %s, %s) """, (model_id, i, f"Stufe {i}", None, i), ) def _replace_levels(cur, model_id: int, level_count: int, levels: List[Dict[str, Any]]) -> None: if len(levels) != level_count: raise HTTPException( 400, f"Anzahl der Stufen-Definitionen ({len(levels)}) muss level_count ({level_count}) entsprechen", ) seen = set() for lev in levels: num = int(lev.get("level_number") or 0) if num < 1 or num > level_count: raise HTTPException(400, f"Ungültige level_number: {num}") if num in seen: raise HTTPException(400, f"Doppelte level_number: {num}") seen.add(num) if seen != set(range(1, level_count + 1)): raise HTTPException(400, "level_number muss lückenlos 1..level_count abdecken") cur.execute("DELETE FROM model_levels WHERE maturity_model_id = %s", (model_id,)) for lev in sorted(levels, key=lambda x: int(x.get("level_number"))): cur.execute( """ INSERT INTO model_levels (maturity_model_id, level_number, name, description, sort_order) VALUES (%s, %s, %s, %s, %s) """, ( model_id, int(lev["level_number"]), (lev.get("name") or "").strip() or f"Stufe {lev['level_number']}", lev.get("description"), int(lev.get("sort_order") or lev["level_number"]), ), ) @router.get("/maturity-models/resolve") def resolve_maturity_model( focus_area_id: Optional[int] = Query(default=None), style_direction_id: Optional[int] = Query(default=None), target_group_id: Optional[int] = Query(default=None), session: dict = Depends(require_auth), ): """ Wählt das spezifischste aktive Modell, das zum Kontext passt. Dimensionen mit NULL im Modell gelten als Wildcard. """ with get_db() as conn: cur = get_cursor(conn) cur.execute( """ SELECT mm.*, fa.name AS focus_area_name, sd.name AS style_direction_name, tg.name AS target_group_name FROM maturity_models mm LEFT JOIN focus_areas fa ON mm.focus_area_id = fa.id LEFT JOIN style_directions sd ON mm.style_direction_id = sd.id LEFT JOIN target_groups tg ON mm.target_group_id = tg.id WHERE mm.status = 'active' """ ) rows = [r2d(r) for r in cur.fetchall()] def matches(m: Dict[str, Any]) -> bool: if focus_area_id is not None: if m.get("focus_area_id") is not None and m["focus_area_id"] != focus_area_id: return False if style_direction_id is not None: if m.get("style_direction_id") is not None and m["style_direction_id"] != style_direction_id: return False if target_group_id is not None: if m.get("target_group_id") is not None and m["target_group_id"] != target_group_id: return False return True def score(m: Dict[str, Any]) -> int: s = 0 if focus_area_id is not None and m.get("focus_area_id") == focus_area_id: s += 1 if style_direction_id is not None and m.get("style_direction_id") == style_direction_id: s += 1 if target_group_id is not None and m.get("target_group_id") == target_group_id: s += 1 return s candidates = [m for m in rows if matches(m)] if not candidates: return None best = max(candidates, key=lambda m: (score(m), m.get("id") or 0)) model_id = best["id"] with get_db() as conn: cur = get_cursor(conn) return _load_full_model(cur, model_id) @router.get("/maturity-models") def list_maturity_models( status: Optional[str] = Query(default=None), focus_area_id: Optional[int] = Query(default=None), session: dict = Depends(require_auth), ): with get_db() as conn: cur = get_cursor(conn) q = """ SELECT mm.*, fa.name AS focus_area_name, sd.name AS style_direction_name, tg.name AS target_group_name FROM maturity_models mm LEFT JOIN focus_areas fa ON mm.focus_area_id = fa.id LEFT JOIN style_directions sd ON mm.style_direction_id = sd.id LEFT JOIN target_groups tg ON mm.target_group_id = tg.id WHERE 1=1 """ params: List[Any] = [] if status: q += " AND mm.status = %s" params.append(status) if focus_area_id is not None: q += " AND mm.focus_area_id = %s" params.append(focus_area_id) q += " ORDER BY mm.name ASC" cur.execute(q, params) return [r2d(r) for r in cur.fetchall()] @router.get("/maturity-models/{model_id}") def get_maturity_model(model_id: int, session: dict = Depends(require_auth)): with get_db() as conn: cur = get_cursor(conn) return _load_full_model(cur, model_id) @router.post("/maturity-models") def create_maturity_model(data: Dict[str, Any], session: dict = Depends(require_auth)): _require_admin(session) name = (data.get("name") or "").strip() if not name: raise HTTPException(400, "Name ist Pflichtfeld") level_count = int(data.get("level_count") or 5) if level_count < 3 or level_count > 10: raise HTTPException(400, "level_count muss zwischen 3 und 10 liegen") profile_id = session.get("profile_id") with get_db() as conn: cur = get_cursor(conn) try: cur.execute( """ INSERT INTO maturity_models ( name, description, focus_area_id, style_direction_id, target_group_id, level_count, status, version, created_by, club_id, import_source, import_id ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING id """, ( name, data.get("description"), data.get("focus_area_id"), data.get("style_direction_id"), data.get("target_group_id"), level_count, data.get("status") or "draft", data.get("version") or "1.0", profile_id, data.get("club_id"), data.get("import_source"), data.get("import_id"), ), ) mid = cur.fetchone()["id"] except Exception as e: if "unique" in str(e).lower() or "duplicate" in str(e).lower(): raise HTTPException(409, "Ein Modell mit diesem Namen und Kontext existiert bereits") from e raise levels = data.get("levels") if levels: _replace_levels(cur, mid, level_count, levels) else: _insert_default_levels(cur, mid, level_count) with get_db() as conn: cur = get_cursor(conn) return _load_full_model(cur, mid) @router.put("/maturity-models/{model_id}") def update_maturity_model(model_id: int, data: Dict[str, Any], session: dict = Depends(require_auth)): _require_admin(session) with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT * FROM maturity_models WHERE id = %s", (model_id,)) row = cur.fetchone() if not row: raise HTTPException(404, "Reifegradmodell nicht gefunden") current = r2d(row) level_count = int(current["level_count"]) if "level_count" in data and data["level_count"] is not None: level_count = int(data["level_count"]) if level_count < 3 or level_count > 10: raise HTTPException(400, "level_count muss zwischen 3 und 10 liegen") if level_count != int(current["level_count"]): if "levels" not in data or data["levels"] is None: raise HTTPException( 400, "Stufenanzahl ändern nur zusammen mit vollständiger levels-Liste (Stufen-Editor).", ) if level_count < int(current["level_count"]): cur.execute( "DELETE FROM model_skill_levels WHERE maturity_model_id = %s AND level_number > %s", (model_id, level_count), ) sets: List[str] = [] vals: List[Any] = [] for key in ( "name", "description", "focus_area_id", "style_direction_id", "target_group_id", "status", "version", ): if key in data: sets.append(f"{key} = %s") vals.append(data[key]) sets.append("level_count = %s") vals.append(level_count) sets.append("updated_at = NOW()") vals.append(model_id) cur.execute( f"UPDATE maturity_models SET {', '.join(sets)} WHERE id = %s", tuple(vals), ) if "levels" in data and data["levels"] is not None: _replace_levels(cur, model_id, level_count, data["levels"]) with get_db() as conn: cur = get_cursor(conn) return _load_full_model(cur, model_id) @router.delete("/maturity-models/{model_id}") def delete_maturity_model(model_id: int, session: dict = Depends(require_auth)): role = session.get("role") if role != "superadmin": raise HTTPException(403, "Nur Superadmins dürfen Reifegradmodelle löschen") with get_db() as conn: cur = get_cursor(conn) cur.execute("DELETE FROM maturity_models WHERE id = %s RETURNING id", (model_id,)) if not cur.fetchone(): raise HTTPException(404, "Reifegradmodell nicht gefunden") return {"ok": True} @router.post("/maturity-models/{model_id}/skills") def add_model_skill(model_id: int, data: Dict[str, Any], session: dict = Depends(require_auth)): _require_admin(session) skill_id = data.get("skill_id") if not skill_id: raise HTTPException(400, "skill_id ist Pflicht") with get_db() as conn: cur = get_cursor(conn) if not _row_maturity_model(cur, model_id): raise HTTPException(404, "Reifegradmodell nicht gefunden") cur.execute("SELECT id FROM skills WHERE id = %s", (skill_id,)) if not cur.fetchone(): raise HTTPException(404, "Fähigkeit nicht gefunden") try: cur.execute( """ INSERT INTO model_skills (maturity_model_id, skill_id, sort_order, relevance) VALUES (%s, %s, %s, %s) RETURNING id """, ( model_id, skill_id, int(data.get("sort_order") or 0), data.get("relevance"), ), ) except Exception as e: if "unique" in str(e).lower(): raise HTTPException(409, "Fähigkeit ist bereits im Modell") from e raise with get_db() as conn: cur = get_cursor(conn) return _load_full_model(cur, model_id) @router.delete("/maturity-models/{model_id}/skills/{skill_id}") def remove_model_skill(model_id: int, skill_id: int, session: dict = Depends(require_auth)): _require_admin(session) with get_db() as conn: cur = get_cursor(conn) cur.execute( "DELETE FROM model_skills WHERE maturity_model_id = %s AND skill_id = %s RETURNING id", (model_id, skill_id), ) if not cur.fetchone(): raise HTTPException(404, "Zuordnung nicht gefunden") cur.execute( "DELETE FROM model_skill_levels WHERE maturity_model_id = %s AND skill_id = %s", (model_id, skill_id), ) with get_db() as conn: cur = get_cursor(conn) return _load_full_model(cur, model_id) @router.put("/maturity-models/{model_id}/skill-levels") def upsert_model_skill_levels(model_id: int, data: Dict[str, Any], session: dict = Depends(require_auth)): _require_admin(session) entries: List[Dict[str, Any]] = data.get("entries") or [] if not entries: raise HTTPException(400, "entries darf nicht leer sein") with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT level_count FROM maturity_models WHERE id = %s", (model_id,)) row = cur.fetchone() if not row: raise HTTPException(404, "Reifegradmodell nicht gefunden") level_count = int(row["level_count"]) for e in entries: sid = e.get("skill_id") ln = int(e.get("level_number") or 0) desc = (e.get("description") or "").strip() if not sid or ln < 1 or ln > level_count: raise HTTPException(400, "Ungültige skill_id oder level_number") if not desc: cur.execute( """ DELETE FROM model_skill_levels WHERE maturity_model_id = %s AND skill_id = %s AND level_number = %s """, (model_id, sid, ln), ) continue cur.execute( """ INSERT INTO model_skill_levels ( maturity_model_id, skill_id, level_number, description, observable_criteria, example_exercise_hints ) VALUES (%s,%s,%s,%s,%s,%s) ON CONFLICT (maturity_model_id, skill_id, level_number) DO UPDATE SET description = EXCLUDED.description, observable_criteria = EXCLUDED.observable_criteria, example_exercise_hints = EXCLUDED.example_exercise_hints, updated_at = NOW() """, ( model_id, sid, ln, desc, e.get("observable_criteria"), e.get("example_exercise_hints"), ), ) with get_db() as conn: cur = get_cursor(conn) return _load_full_model(cur, model_id)