From 5277f4f4cfddd32a65d44f1c1c03c8e5030cbed1 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 27 Apr 2026 11:32:30 +0200 Subject: [PATCH] feat: add maturity models functionality and update version - 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. --- backend/main.py | 3 +- backend/migrations/024_maturity_models.sql | 132 ++++ backend/routers/maturity_models.py | 483 ++++++++++++ backend/scripts/generate_migration_023.py | 141 ++++ backend/version.py | 16 +- frontend/src/App.jsx | 9 + frontend/src/components/AdminPageNav.jsx | 3 +- .../src/pages/AdminMaturityModelsPage.jsx | 743 ++++++++++++++++++ frontend/src/utils/api.js | 67 ++ skills_mapping_clean.csv | 70 ++ skills_mapping_clean_utf8.csv | 0 11 files changed, 1662 insertions(+), 5 deletions(-) create mode 100644 backend/migrations/024_maturity_models.sql create mode 100644 backend/routers/maturity_models.py create mode 100644 backend/scripts/generate_migration_023.py create mode 100644 frontend/src/pages/AdminMaturityModelsPage.jsx create mode 100644 skills_mapping_clean.csv create mode 100644 skills_mapping_clean_utf8.csv diff --git a/backend/main.py b/backend/main.py index c45b218..0c07a3a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -70,7 +70,7 @@ def read_root(): } # Register routers -from routers import auth, profiles, exercises, clubs, skills, training_planning, catalogs, import_wiki, import_wiki_admin +from routers import auth, profiles, exercises, clubs, skills, training_planning, catalogs, maturity_models, import_wiki, import_wiki_admin app.include_router(auth.router) app.include_router(profiles.router) @@ -79,6 +79,7 @@ app.include_router(clubs.router) app.include_router(skills.router) app.include_router(training_planning.router) app.include_router(catalogs.router) +app.include_router(maturity_models.router) app.include_router(import_wiki.router) app.include_router(import_wiki_admin.router) diff --git a/backend/migrations/024_maturity_models.sql b/backend/migrations/024_maturity_models.sql new file mode 100644 index 0000000..4036228 --- /dev/null +++ b/backend/migrations/024_maturity_models.sql @@ -0,0 +1,132 @@ +-- Migration 024: Reifegradmodelle / Fähigkeitsmatrix (kontextbezogen) +-- Datum: 2026-04-27 + +-- ============================================================================ +-- MATURITY MODELS +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS maturity_models ( + id SERIAL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + description TEXT, + + focus_area_id INT REFERENCES focus_areas(id) ON DELETE RESTRICT, + style_direction_id INT REFERENCES style_directions(id) ON DELETE RESTRICT, + target_group_id INT REFERENCES target_groups(id) ON DELETE RESTRICT, + + level_count INT NOT NULL DEFAULT 5 CHECK (level_count BETWEEN 3 AND 10), + + status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'archived')), + + version VARCHAR(20) DEFAULT '1.0', + + created_by INT REFERENCES profiles(id) ON DELETE SET NULL, + club_id INT REFERENCES clubs(id) ON DELETE SET NULL, + + import_source VARCHAR(50), + import_id VARCHAR(200), + + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + + CONSTRAINT maturity_models_context_name_unique + UNIQUE (name, focus_area_id, style_direction_id, target_group_id) +); + +-- ============================================================================ +-- MODEL LEVELS (Stufen-Labels pro Modell) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS model_levels ( + id SERIAL PRIMARY KEY, + maturity_model_id INT NOT NULL REFERENCES maturity_models(id) ON DELETE CASCADE, + level_number INT NOT NULL CHECK (level_number >= 1), + name VARCHAR(100) NOT NULL, + description TEXT, + sort_order INT NOT NULL, + + created_at TIMESTAMP DEFAULT NOW(), + + UNIQUE(maturity_model_id, level_number) +); + +-- ============================================================================ +-- MODEL SKILLS: Welche Fähigkeiten sind im Modell geführt (Matrix-Zeilen) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS model_skills ( + id SERIAL PRIMARY KEY, + maturity_model_id INT NOT NULL REFERENCES maturity_models(id) ON DELETE CASCADE, + skill_id INT NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + sort_order INT NOT NULL DEFAULT 0, + relevance VARCHAR(50), + + created_at TIMESTAMP DEFAULT NOW(), + + UNIQUE(maturity_model_id, skill_id) +); + +CREATE INDEX IF NOT EXISTS idx_model_skills_model ON model_skills(maturity_model_id); +CREATE INDEX IF NOT EXISTS idx_model_skills_skill ON model_skills(skill_id); + +-- ============================================================================ +-- MODEL SKILL LEVELS (Zelltexte der Matrix) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS model_skill_levels ( + id SERIAL PRIMARY KEY, + maturity_model_id INT NOT NULL REFERENCES maturity_models(id) ON DELETE CASCADE, + skill_id INT NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + level_number INT NOT NULL CHECK (level_number >= 1), + + description TEXT NOT NULL, + observable_criteria TEXT, + example_exercise_hints JSONB, + + ai_generated BOOLEAN DEFAULT false, + + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + + UNIQUE(maturity_model_id, skill_id, level_number) +); + +-- ============================================================================ +-- SKILLS: optionale Matrix-Felder (Spec) +-- ============================================================================ + +ALTER TABLE skills + ADD COLUMN IF NOT EXISTS primary_focus_area_id INT REFERENCES focus_areas(id) ON DELETE SET NULL; + +ALTER TABLE skills + ADD COLUMN IF NOT EXISTS is_cross_domain BOOLEAN DEFAULT false; + +-- ============================================================================ +-- INDEXES +-- ============================================================================ + +CREATE INDEX IF NOT EXISTS idx_maturity_models_focus ON maturity_models(focus_area_id); +CREATE INDEX IF NOT EXISTS idx_maturity_models_style ON maturity_models(style_direction_id); +CREATE INDEX IF NOT EXISTS idx_maturity_models_target ON maturity_models(target_group_id); +CREATE INDEX IF NOT EXISTS idx_maturity_models_status ON maturity_models(status); + +CREATE INDEX IF NOT EXISTS idx_model_levels_model ON model_levels(maturity_model_id); + +CREATE INDEX IF NOT EXISTS idx_model_skill_levels_model ON model_skill_levels(maturity_model_id); +CREATE INDEX IF NOT EXISTS idx_model_skill_levels_skill ON model_skill_levels(skill_id); + +CREATE INDEX IF NOT EXISTS idx_skills_primary_focus ON skills(primary_focus_area_id); + +-- ============================================================================ +-- TRIGGERS updated_at +-- ============================================================================ + +DROP TRIGGER IF EXISTS maturity_models_update ON maturity_models; +CREATE TRIGGER maturity_models_update + BEFORE UPDATE ON maturity_models + FOR EACH ROW EXECUTE FUNCTION update_timestamp(); + +DROP TRIGGER IF EXISTS model_skill_levels_update ON model_skill_levels; +CREATE TRIGGER model_skill_levels_update + BEFORE UPDATE ON model_skill_levels + FOR EACH ROW EXECUTE FUNCTION update_timestamp(); diff --git a/backend/routers/maturity_models.py b/backend/routers/maturity_models.py new file mode 100644 index 0000000..c07cd07 --- /dev/null +++ b/backend/routers/maturity_models.py @@ -0,0 +1,483 @@ +""" +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) diff --git a/backend/scripts/generate_migration_023.py b/backend/scripts/generate_migration_023.py new file mode 100644 index 0000000..eb733bc --- /dev/null +++ b/backend/scripts/generate_migration_023.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +""" +Generiert Migration 023: Vollständiger Skills-Import mit Kategorisierung. +""" +import csv +from collections import defaultdict + +# Lese CSV +import os +script_dir = os.path.dirname(os.path.abspath(__file__)) +csv_path = os.path.join(script_dir, '..', '..', 'skills_mapping_clean.csv') + +try: + with open(csv_path, 'r', encoding='utf-8') as f: + reader = csv.DictReader(f) + skills = list(reader) +except UnicodeDecodeError: + # Fallback zu latin-1 bei Encoding-Problemen + with open(csv_path, 'r', encoding='latin-1') as f: + reader = csv.DictReader(f) + skills = list(reader) + +# Slug-Generierung +def to_slug(name): + """Wandelt Namen in URL-friendly Slugs um.""" + return name.lower().replace(' ', '_').replace('ä', 'ae').replace('ö', 'oe').replace('ü', 'ue').replace('ß', 'ss') + +# Gruppiere nach Kategorien +by_main_cat = defaultdict(lambda: defaultdict(list)) +sub_categories = {} # {sub_cat_name: (slug, main_cat)} + +for s in skills: + main = s['main_category'] + sub = s['sub_category'] + by_main_cat[main][sub].append(s) + + if sub not in sub_categories: + sub_categories[sub] = (to_slug(sub), main) + +print("""-- Migration 023: Vollständiger Skills-Import +-- Purpose: Produktionsreifer Import aller 69 Skills mit vollständiger Kategorisierung +-- Source: Fähigkeitsmatrix https://karatetrainer.net/index.php?title=Fähigkeitsmatrix +-- Date: 2026-04-27 + +-- ====================================================================== +-- CLEANUP: Alte Daten löschen +-- ====================================================================== + +-- Erst M:N-Beziehungen löschen +DELETE FROM exercise_skills; + +-- Skills löschen (Cascades zu skill_level_definitions) +DELETE FROM skills; + +-- Kategorien löschen +DELETE FROM skill_categories; +DELETE FROM skill_main_categories WHERE id > 0; -- Falls Tabelle existiert + +-- ====================================================================== +-- 1. HAUPT-KATEGORIEN +-- ====================================================================== + +INSERT INTO skill_main_categories (name, slug, description, sort_order) VALUES +('KARATE Fähigkeiten', 'karate', 'Karate-spezifische Techniken und Fähigkeiten', 1), +('ALLGEMEINE sportliche Fähigkeiten', 'allgemeine', 'Universelle sportliche und mentale Fähigkeiten', 2); + +-- ====================================================================== +-- 2. UNTERKATEGORIEN +-- ====================================================================== +""") + +# Sortiere Unterkategorien: Karate zuerst, dann Allgemeine +karate_subs = sorted([(name, slug, main) for name, (slug, main) in sub_categories.items() if main == 'karate']) +allgemeine_subs = sorted([(name, slug, main) for name, (slug, main) in sub_categories.items() if main == 'allgemeine']) + +sort_order = 1 +print("INSERT INTO skill_categories (name, slug, main_category_id, description, sort_order) VALUES") + +for idx, (name, slug, main_cat) in enumerate(karate_subs + allgemeine_subs): + comma = "," if idx < len(karate_subs) + len(allgemeine_subs) - 1 else ";" + print(f"('{name}', '{slug}', (SELECT id FROM skill_main_categories WHERE slug='{main_cat}'), '', {sort_order}){comma}") + sort_order += 1 + +print(""" +-- ====================================================================== +-- 3. SKILLS +-- ====================================================================== + +INSERT INTO skills (name, description, category_id, main_category_id, focus_areas) VALUES""") + +# Sortiere Skills: Karate zuerst, dann Allgemeine +karate_skills = [] +for sub in [name for name, slug, main in karate_subs]: + karate_skills.extend(by_main_cat['karate'][sub]) + +allgemeine_skills = [] +for sub in [name for name, slug, main in allgemeine_subs]: + allgemeine_skills.extend(by_main_cat['allgemeine'][sub]) + +all_skills = karate_skills + allgemeine_skills + +for idx, s in enumerate(all_skills): + name = s['skill_name'].replace("'", "''") # SQL-Escape + sub_cat_slug = to_slug(s['sub_category']) + main_cat_slug = s['main_category'] + focus = s['focus_areas'] + + comma = "," if idx < len(all_skills) - 1 else ";" + + print(f"('{name}', '', (SELECT id FROM skill_categories WHERE slug='{sub_cat_slug}'), (SELECT id FROM skill_main_categories WHERE slug='{main_cat_slug}'), '[\"{ focus}\"]'::jsonb){comma}") + +print(""" +-- ====================================================================== +-- 4. VERIFIKATION +-- ====================================================================== + +-- Sollte 69 Skills ergeben +DO $$ +DECLARE + skill_count INT; +BEGIN + SELECT COUNT(*) INTO skill_count FROM skills; + + IF skill_count != 69 THEN + RAISE WARNING 'FEHLER: % Skills gefunden, erwartet 69', skill_count; + ELSE + RAISE NOTICE 'OK: 69 Skills importiert'; + END IF; +END $$; + +-- Zeige Verteilung +SELECT + mc.name AS hauptkategorie, + sc.name AS unterkategorie, + COUNT(s.id) AS anzahl_skills +FROM skills s +JOIN skill_categories sc ON s.category_id = sc.id +JOIN skill_main_categories mc ON s.main_category_id = mc.id +GROUP BY mc.name, sc.name, mc.sort_order, sc.sort_order +ORDER BY mc.sort_order, sc.sort_order; +""") diff --git a/backend/version.py b/backend/version.py index dbe5534..3e49959 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.7.0" -BUILD_DATE = "2026-04-24" -DB_SCHEMA_VERSION = "20260424002" +APP_VERSION = "0.7.1" +BUILD_DATE = "2026-04-27" +DB_SCHEMA_VERSION = "20260427024" MODULE_VERSIONS = { "auth": "1.0.0", @@ -19,9 +19,19 @@ MODULE_VERSIONS = { "admin": "1.0.0", "membership": "1.0.0", "catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012) + "maturity_models": "1.0.0", # Migration 024: Reifegradmodelle / Fähigkeitsmatrix } CHANGELOG = [ + { + "version": "0.7.1", + "date": "2026-04-27", + "changes": [ + "DB: Migration 024 – maturity_models, model_levels, model_skills, model_skill_levels", + "API: CRUD Reifegradmodelle, Matrix-Pflege, resolve-Kontext", + "Frontend: Admin Fähigkeitsmatrix (/admin/maturity-models)", + ], + }, { "version": "0.7.0", "date": "2026-04-24", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9cea7cc..11eb6a8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -12,6 +12,7 @@ import SkillsPage from './pages/SkillsPage' import TrainingPlanningPage from './pages/TrainingPlanningPage' import AdminCatalogsPage from './pages/AdminCatalogsPage' import AdminHierarchyPage from './pages/AdminHierarchyPage' +import AdminMaturityModelsPage from './pages/AdminMaturityModelsPage' import TrainerContextsPage from './pages/TrainerContextsPage' import MediaWikiImportPage from './pages/MediaWikiImportPage' import './app.css' @@ -192,6 +193,14 @@ function AppRoutes() { } /> + + + + } + /> { + if (!isAdmin) return + let cancelled = false + ;(async () => { + try { + const [m, fa, sd, tg, sk] = await Promise.all([ + api.listMaturityModels({}), + api.listFocusAreas({}), + api.listStyleDirections({}), + api.listTargetGroups({}), + api.listSkills({ status: 'active' }) + ]) + if (!cancelled) { + setModels(m) + setFocusAreas(fa) + setStyles(sd) + setTargetGroups(tg) + setAllSkills(sk) + } + } catch (e) { + if (!cancelled) setError(e.message) + } + })() + return () => { cancelled = true } + }, [isAdmin]) + + async function refreshModels() { + const m = await api.listMaturityModels({}) + setModels(m) + } + + async function selectModel(id) { + setSelectedId(id) + setLoading(true) + setError('') + try { + const d = await api.getMaturityModel(id) + setDetail(d) + setMeta({ + name: d.name, + description: d.description || '', + focus_area_id: d.focus_area_id ?? '', + style_direction_id: d.style_direction_id ?? '', + target_group_id: d.target_group_id ?? '', + status: d.status, + version: d.version || '1.0' + }) + const lc = parseInt(String(d.level_count), 10) + setLevelCount(lc) + setLevelsForm( + (d.levels || []).map((l) => ({ + level_number: l.level_number, + name: l.name, + description: l.description || '', + sort_order: l.sort_order + })) + ) + const draft = {} + for (const r of d.skill_levels || []) { + draft[`${r.skill_id}-${r.level_number}`] = { + description: r.description || '', + observable_criteria: r.observable_criteria || '' + } + } + setCellDraft(draft) + } catch (e) { + setError(e.message) + setDetail(null) + setMeta(null) + } finally { + setLoading(false) + } + } + + async function handleCreate(e) { + e.preventDefault() + setError('') + setSaving(true) + try { + const payload = { + name: newModel.name.trim(), + level_count: parseInt(String(newModel.level_count), 10), + status: newModel.status, + focus_area_id: newModel.focus_area_id ? parseInt(String(newModel.focus_area_id), 10) : null, + style_direction_id: newModel.style_direction_id + ? parseInt(String(newModel.style_direction_id), 10) + : null, + target_group_id: newModel.target_group_id + ? parseInt(String(newModel.target_group_id), 10) + : null + } + const created = await api.createMaturityModel(payload) + await refreshModels() + await selectModel(created.id) + setNewModel({ + name: '', + level_count: 5, + focus_area_id: '', + style_direction_id: '', + target_group_id: '', + status: 'draft' + }) + } catch (e) { + setError(e.message) + } finally { + setSaving(false) + } + } + + async function handleSaveMeta() { + if (!selectedId || !meta) return + setError('') + setSaving(true) + try { + await api.updateMaturityModel(selectedId, { + name: meta.name, + description: meta.description || null, + focus_area_id: meta.focus_area_id === '' ? null : parseInt(String(meta.focus_area_id), 10), + style_direction_id: + meta.style_direction_id === '' ? null : parseInt(String(meta.style_direction_id), 10), + target_group_id: + meta.target_group_id === '' ? null : parseInt(String(meta.target_group_id), 10), + status: meta.status, + version: meta.version + }) + await refreshModels() + await selectModel(selectedId) + } catch (e) { + setError(e.message) + } finally { + setSaving(false) + } + } + + async function handleSaveLevels() { + if (!selectedId || !meta) return + setError('') + setSaving(true) + try { + await api.updateMaturityModel(selectedId, { + level_count: parseInt(String(levelCount), 10), + levels: levelsForm.map((l) => ({ + level_number: parseInt(String(l.level_number), 10), + name: l.name, + description: l.description || null, + sort_order: parseInt(String(l.sort_order), 10) + })) + }) + await refreshModels() + await selectModel(selectedId) + } catch (e) { + setError(e.message) + } finally { + setSaving(false) + } + } + + async function handleSaveMatrix() { + if (!selectedId || !detail) return + if (!detail.model_skills?.length) { + setError('Matrix: Zuerst Fähigkeiten hinzufügen.') + return + } + setError('') + setSaving(true) + try { + const entries = [] + for (const ms of detail.model_skills) { + for (const lev of detail.levels || []) { + const key = `${ms.skill_id}-${lev.level_number}` + const d = cellDraft[key] || { description: '', observable_criteria: '' } + entries.push({ + skill_id: ms.skill_id, + level_number: lev.level_number, + description: (d.description || '').trim(), + observable_criteria: (d.observable_criteria || '').trim() || null + }) + } + } + await api.upsertMaturityModelSkillLevels(selectedId, { entries }) + await selectModel(selectedId) + } catch (e) { + setError(e.message) + } finally { + setSaving(false) + } + } + + async function handleAddSkill() { + if (!selectedId || !skillToAdd) return + setError('') + setSaving(true) + try { + await api.addMaturityModelSkill(selectedId, { skill_id: parseInt(String(skillToAdd), 10) }) + setSkillToAdd('') + await selectModel(selectedId) + } catch (e) { + setError(e.message) + } finally { + setSaving(false) + } + } + + async function handleRemoveSkill(skillId) { + if (!selectedId) return + if (!confirm('Fähigkeit aus dem Modell entfernen? Zelltexte werden gelöscht.')) return + setError('') + setSaving(true) + try { + await api.removeMaturityModelSkill(selectedId, skillId) + await selectModel(selectedId) + } catch (e) { + setError(e.message) + } finally { + setSaving(false) + } + } + + async function handleDeleteModel() { + if (!selectedId || !isSuperadmin) return + if (!confirm('Reifegradmodell dauerhaft löschen?')) return + setError('') + setSaving(true) + try { + await api.deleteMaturityModel(selectedId) + setSelectedId(null) + setDetail(null) + setMeta(null) + await refreshModels() + } catch (e) { + setError(e.message) + } finally { + setSaving(false) + } + } + + function onLevelCountChange(raw) { + const next = Math.max(3, Math.min(10, parseInt(String(raw), 10) || 3)) + setLevelCount(next) + setLevelsForm((prev) => { + const byNum = Object.fromEntries(prev.map((r) => [r.level_number, { ...r }])) + const rows = [] + for (let i = 1; i <= next; i++) { + rows.push( + byNum[i] || { + level_number: i, + name: `Stufe ${i}`, + description: '', + sort_order: i + } + ) + } + return rows + }) + } + + function setCell(skillId, levelNumber, field, value) { + const key = `${skillId}-${levelNumber}` + setCellDraft((prev) => ({ + ...prev, + [key]: { + description: field === 'description' ? value : (prev[key]?.description ?? ''), + observable_criteria: + field === 'observable_criteria' ? value : (prev[key]?.observable_criteria ?? '') + } + })) + } + + if (!isAdmin) { + return + } + + return ( +
+ + +

Admin: Fähigkeitsmatrix

+

+ Reifegradmodelle mit Fokusbereich, Stilrichtung und Zielgruppe. Pro Modell: Stufen definieren, + Fähigkeiten zuordnen, Zelltexte pflegen. +

+ + {error ? ( +
+ {error} +
+ ) : null} + +
+
+

Modelle

+
    + {models.map((m) => ( +
  • + +
  • + ))} +
+ +
+ +

Neues Modell

+
+ + setNewModel((s) => ({ ...s, name: e.target.value }))} + required + /> + + setNewModel((s) => ({ ...s, level_count: e.target.value }))} + /> + + + + + + + + + +
+
+ +
+ {loading ?
: null} + + {!loading && !detail && ( +
+ Modell links wählen oder neu anlegen. +
+ )} + + {!loading && detail && meta && ( + <> +
+

Kontext & Metadaten

+
+
+ + setMeta((m) => ({ ...m, name: e.target.value }))} + /> +
+
+ +