- Introduced new maturity models feature with CRUD operations in the API. - Added routes and frontend components for managing maturity models. - Updated version to 0.7.1 with corresponding build date and schema version. - Enhanced admin navigation to include maturity models section. - Documented changes in the changelog for version 0.7.1.
484 lines
17 KiB
Python
484 lines
17 KiB
Python
"""
|
|
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)
|