diff --git a/backend/migrations/025_maturity_model_context_mn.sql b/backend/migrations/025_maturity_model_context_mn.sql new file mode 100644 index 0000000..89086d3 --- /dev/null +++ b/backend/migrations/025_maturity_model_context_mn.sql @@ -0,0 +1,160 @@ +-- Migration 025: Reifegradmodell-Kontext als M:N (Fokusbereich, Stilrichtung, Zielgruppe) +-- + Bootstrap: ein Modell aus allen importierten Skills (Wiki / Migration 023) +-- Datum: 2026-04-27 + +-- ============================================================================ +-- JUNCTION TABLES +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS maturity_model_focus_areas ( + id SERIAL PRIMARY KEY, + maturity_model_id INT NOT NULL REFERENCES maturity_models(id) ON DELETE CASCADE, + focus_area_id INT NOT NULL REFERENCES focus_areas(id) ON DELETE CASCADE, + is_primary BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE (maturity_model_id, focus_area_id) +); + +CREATE INDEX IF NOT EXISTS idx_mmfa_model ON maturity_model_focus_areas(maturity_model_id); +CREATE INDEX IF NOT EXISTS idx_mmfa_focus ON maturity_model_focus_areas(focus_area_id); + +CREATE TABLE IF NOT EXISTS maturity_model_style_directions ( + id SERIAL PRIMARY KEY, + maturity_model_id INT NOT NULL REFERENCES maturity_models(id) ON DELETE CASCADE, + style_direction_id INT NOT NULL REFERENCES style_directions(id) ON DELETE CASCADE, + is_primary BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE (maturity_model_id, style_direction_id) +); + +CREATE INDEX IF NOT EXISTS idx_mmsd_model ON maturity_model_style_directions(maturity_model_id); +CREATE INDEX IF NOT EXISTS idx_mmsd_style ON maturity_model_style_directions(style_direction_id); + +CREATE TABLE IF NOT EXISTS maturity_model_target_groups ( + id SERIAL PRIMARY KEY, + maturity_model_id INT NOT NULL REFERENCES maturity_models(id) ON DELETE CASCADE, + target_group_id INT NOT NULL REFERENCES target_groups(id) ON DELETE CASCADE, + is_primary BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE (maturity_model_id, target_group_id) +); + +CREATE INDEX IF NOT EXISTS idx_mmtg_model ON maturity_model_target_groups(maturity_model_id); +CREATE INDEX IF NOT EXISTS idx_mmtg_tg ON maturity_model_target_groups(target_group_id); + +-- ============================================================================ +-- Daten aus 1:1-FKs übernehmen (falls Spalten noch existieren), dann FK-Spalten entfernen +-- ============================================================================ + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'maturity_models' AND column_name = 'focus_area_id' + ) THEN + INSERT INTO maturity_model_focus_areas (maturity_model_id, focus_area_id, is_primary) + SELECT id, focus_area_id, true + FROM maturity_models + WHERE focus_area_id IS NOT NULL + ON CONFLICT (maturity_model_id, focus_area_id) DO NOTHING; + END IF; + + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'maturity_models' AND column_name = 'style_direction_id' + ) THEN + INSERT INTO maturity_model_style_directions (maturity_model_id, style_direction_id, is_primary) + SELECT id, style_direction_id, true + FROM maturity_models + WHERE style_direction_id IS NOT NULL + ON CONFLICT (maturity_model_id, style_direction_id) DO NOTHING; + END IF; + + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'maturity_models' AND column_name = 'target_group_id' + ) THEN + INSERT INTO maturity_model_target_groups (maturity_model_id, target_group_id, is_primary) + SELECT id, target_group_id, true + FROM maturity_models + WHERE target_group_id IS NOT NULL + ON CONFLICT (maturity_model_id, target_group_id) DO NOTHING; + END IF; +END $$; + +ALTER TABLE maturity_models DROP CONSTRAINT IF EXISTS maturity_models_context_name_unique; + +ALTER TABLE maturity_models DROP COLUMN IF EXISTS focus_area_id; +ALTER TABLE maturity_models DROP COLUMN IF EXISTS style_direction_id; +ALTER TABLE maturity_models DROP COLUMN IF EXISTS target_group_id; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_maturity_models_wiki_import + ON maturity_models(import_source, import_id) + WHERE import_id IS NOT NULL; + +-- ============================================================================ +-- Bootstrap: Standard-Modell für alle Skills aus der Wiki-Matrix (Migration 023) +-- ============================================================================ + +DO $$ +DECLARE + mid INT; + n_skills INT; +BEGIN + SELECT COUNT(*) INTO n_skills FROM skills; + IF n_skills = 0 THEN + RAISE NOTICE 'Bootstrap: keine Skills – übersprungen'; + RETURN; + END IF; + + SELECT id INTO mid FROM maturity_models WHERE import_id = 'faehigkeitsmatrix_karatetrainer' LIMIT 1; + IF mid IS NOT NULL THEN + RAISE NOTICE 'Bootstrap: Modell bereits vorhanden (id=%)', mid; + RETURN; + END IF; + + INSERT INTO maturity_models (name, description, level_count, status, import_source, import_id, version) + VALUES ( + 'Fähigkeitsmatrix (Wiki Import)', + 'Alle Fähigkeiten aus der Wiki-Matrix (Karatetrainer). Haupt- und Untergruppen kommen aus skill_main_categories / skill_categories.', + 5, + 'active', + 'wiki_matrix', + 'faehigkeitsmatrix_karatetrainer', + '1.0' + ) + RETURNING id INTO mid; + + INSERT INTO maturity_model_focus_areas (maturity_model_id, focus_area_id, is_primary) + SELECT mid, fa.id, (fa.name = 'Karate') + FROM focus_areas fa + WHERE fa.name IN ('Karate', 'Selbstverteidigung', 'Gewaltschutz') + ON CONFLICT (maturity_model_id, focus_area_id) DO NOTHING; + + INSERT INTO model_levels (maturity_model_id, level_number, name, description, sort_order) + VALUES + (mid, 1, 'Einsteiger', NULL, 1), + (mid, 2, 'Grundlagen', NULL, 2), + (mid, 3, 'Aufbau', NULL, 3), + (mid, 4, 'Fortgeschritten', NULL, 4), + (mid, 5, 'Experte', NULL, 5); + + INSERT INTO model_skills (maturity_model_id, skill_id, sort_order, relevance) + SELECT + mid, + s.id, + ROW_NUMBER() OVER ( + ORDER BY mc.sort_order NULLS LAST, sc.sort_order NULLS LAST, s.name + ), + NULL + FROM skills s + LEFT JOIN skill_categories sc ON s.category_id = sc.id + LEFT JOIN skill_main_categories mc ON s.main_category_id = mc.id; + + INSERT INTO model_skill_levels (maturity_model_id, skill_id, level_number, description, observable_criteria) + SELECT mid, sld.skill_id, sld.level, sld.description, NULL + FROM skill_level_definitions sld + ON CONFLICT (maturity_model_id, skill_id, level_number) DO NOTHING; + + RAISE NOTICE 'Bootstrap: Reifegradmodell id=% angelegt (% Skills)', mid, n_skills; +END $$; diff --git a/backend/routers/maturity_models.py b/backend/routers/maturity_models.py index c07cd07..63d8466 100644 --- a/backend/routers/maturity_models.py +++ b/backend/routers/maturity_models.py @@ -1,10 +1,12 @@ """ Reifegradmodelle / Fähigkeitsmatrix (kontextbezogen) +Kontext zu Fokusbereich, Stilrichtung, Zielgruppe: jeweils M:N (leer = gilt überall). + Lesen: alle authentifizierten Nutzer. Schreiben: admin, superadmin. """ -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Sequence from fastapi import APIRouter, Depends, HTTPException, Query @@ -20,29 +22,123 @@ def _require_admin(session: dict) -> None: 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,), - ) +def _base_maturity_model(cur, model_id: int) -> Optional[Dict[str, Any]]: + cur.execute("SELECT * FROM maturity_models WHERE id = %s", (model_id,)) row = cur.fetchone() return r2d(row) if row else None +def _attach_context(cur, m: Dict[str, Any]) -> Dict[str, Any]: + mid = m["id"] + cur.execute( + """ + SELECT fa.id, fa.name, fa.abbreviation, mfa.is_primary + FROM maturity_model_focus_areas mfa + JOIN focus_areas fa ON fa.id = mfa.focus_area_id + WHERE mfa.maturity_model_id = %s + ORDER BY mfa.is_primary DESC NULLS LAST, fa.sort_order, fa.name + """, + (mid,), + ) + m["focus_areas"] = [r2d(r) for r in cur.fetchall()] + + cur.execute( + """ + SELECT sd.id, sd.name, msd.is_primary + FROM maturity_model_style_directions msd + JOIN style_directions sd ON sd.id = msd.style_direction_id + WHERE msd.maturity_model_id = %s + ORDER BY msd.is_primary DESC NULLS LAST, sd.name + """, + (mid,), + ) + m["style_directions"] = [r2d(r) for r in cur.fetchall()] + + cur.execute( + """ + SELECT tg.id, tg.name, mtg.is_primary + FROM maturity_model_target_groups mtg + JOIN target_groups tg ON tg.id = mtg.target_group_id + WHERE mtg.maturity_model_id = %s + ORDER BY mtg.is_primary DESC NULLS LAST, tg.name + """, + (mid,), + ) + m["target_groups"] = [r2d(r) for r in cur.fetchall()] + + m["focus_area_name"] = m["focus_areas"][0]["name"] if m["focus_areas"] else None + m["style_direction_name"] = m["style_directions"][0]["name"] if m["style_directions"] else None + m["target_group_name"] = m["target_groups"][0]["name"] if m["target_groups"] else None + return m + + +def _normalize_id_list(raw: Any) -> Optional[List[int]]: + if raw is None: + return None + if isinstance(raw, (str, bytes)): + raise HTTPException(400, "Erwarte Liste von IDs") + if not isinstance(raw, Sequence): + raise HTTPException(400, "Erwarte Liste von IDs") + out: List[int] = [] + for x in raw: + if x is None: + continue + out.append(int(x)) + return out + + +def _write_context_junctions(cur, model_id: int, data: Dict[str, Any]) -> None: + fa = data.get("focus_area_ids") + if fa is None and data.get("focus_area_id") is not None: + fa = [data.get("focus_area_id")] + if fa is not None: + fa = _normalize_id_list(fa) + cur.execute("DELETE FROM maturity_model_focus_areas WHERE maturity_model_id = %s", (model_id,)) + for i, fid in enumerate(fa or []): + cur.execute( + """ + INSERT INTO maturity_model_focus_areas (maturity_model_id, focus_area_id, is_primary) + VALUES (%s, %s, %s) + """, + (model_id, fid, i == 0), + ) + + sd = data.get("style_direction_ids") + if sd is None and data.get("style_direction_id") is not None: + sd = [data.get("style_direction_id")] + if sd is not None: + sd = _normalize_id_list(sd) + cur.execute("DELETE FROM maturity_model_style_directions WHERE maturity_model_id = %s", (model_id,)) + for i, sid in enumerate(sd or []): + cur.execute( + """ + INSERT INTO maturity_model_style_directions (maturity_model_id, style_direction_id, is_primary) + VALUES (%s, %s, %s) + """, + (model_id, sid, i == 0), + ) + + tg = data.get("target_group_ids") + if tg is None and data.get("target_group_id") is not None: + tg = [data.get("target_group_id")] + if tg is not None: + tg = _normalize_id_list(tg) + cur.execute("DELETE FROM maturity_model_target_groups WHERE maturity_model_id = %s", (model_id,)) + for i, tid in enumerate(tg or []): + cur.execute( + """ + INSERT INTO maturity_model_target_groups (maturity_model_id, target_group_id, is_primary) + VALUES (%s, %s, %s) + """, + (model_id, tid, i == 0), + ) + + def _load_full_model(cur, model_id: int) -> Dict[str, Any]: - base = _row_maturity_model(cur, model_id) + base = _base_maturity_model(cur, model_id) if not base: raise HTTPException(404, "Reifegradmodell nicht gefunden") + _attach_context(cur, base) cur.execute( """ @@ -56,11 +152,15 @@ def _load_full_model(cur, model_id: int) -> Dict[str, Any]: cur.execute( """ - SELECT ms.*, s.name AS skill_name, s.status AS skill_status + SELECT ms.*, s.name AS skill_name, s.status AS skill_status, + sc.name AS skill_subcategory_name, sc.slug AS skill_subcategory_slug, + mc.name AS skill_main_category_name, mc.slug AS skill_main_category_slug FROM model_skills ms JOIN skills s ON s.id = ms.skill_id + LEFT JOIN skill_categories sc ON s.category_id = sc.id + LEFT JOIN skill_main_categories mc ON s.main_category_id = mc.id WHERE ms.maturity_model_id = %s - ORDER BY ms.sort_order ASC, s.name ASC + ORDER BY ms.sort_order ASC, mc.sort_order NULLS LAST, sc.sort_order NULLS LAST, s.name ASC """, (model_id,), ) @@ -131,6 +231,22 @@ def _replace_levels(cur, model_id: int, level_count: int, levels: List[Dict[str, ) +def _dim_matches(items: List[Dict[str, Any]], query_id: Optional[int]) -> bool: + if not items: + return True + if query_id is None: + return True + return any(int(x["id"]) == int(query_id) for x in items) + + +def _dim_score(items: List[Dict[str, Any]], query_id: Optional[int]) -> int: + if not items: + return 0 + if query_id is not None and any(int(x["id"]) == int(query_id) for x in items): + return 1 + return 0 + + @router.get("/maturity-models/resolve") def resolve_maturity_model( focus_area_id: Optional[int] = Query(default=None), @@ -140,48 +256,37 @@ def resolve_maturity_model( ): """ Wählt das spezifischste aktive Modell, das zum Kontext passt. - Dimensionen mit NULL im Modell gelten als Wildcard. + Leere M:N-Zuordnung = Wildcard für diese Dimension. """ 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' - """ - ) + cur.execute("SELECT * FROM maturity_models WHERE status = 'active' ORDER BY id") 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 + enriched: List[Dict[str, Any]] = [] + with get_db() as conn: + cur = get_cursor(conn) + for m in rows: + _attach_context(cur, m) + enriched.append(m) + + def ok(m: Dict[str, Any]) -> bool: + if not _dim_matches(m.get("focus_areas") or [], focus_area_id): + return False + if not _dim_matches(m.get("style_directions") or [], style_direction_id): + return False + if not _dim_matches(m.get("target_groups") or [], 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 + return ( + _dim_score(m.get("focus_areas") or [], focus_area_id) + + _dim_score(m.get("style_directions") or [], style_direction_id) + + _dim_score(m.get("target_groups") or [], target_group_id) + ) - candidates = [m for m in rows if matches(m)] + candidates = [m for m in enriched if ok(m)] if not candidates: return None best = max(candidates, key=lambda m: (score(m), m.get("id") or 0)) @@ -199,27 +304,30 @@ def list_maturity_models( ): 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 - """ + q = "SELECT * FROM maturity_models mm 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" + q += """ AND ( + EXISTS ( + SELECT 1 FROM maturity_model_focus_areas mfa + WHERE mfa.maturity_model_id = mm.id + AND mfa.focus_area_id = %s + ) + OR NOT EXISTS ( + SELECT 1 FROM maturity_model_focus_areas mfa2 + WHERE mfa2.maturity_model_id = mm.id + ) + )""" params.append(focus_area_id) q += " ORDER BY mm.name ASC" cur.execute(q, params) - return [r2d(r) for r in cur.fetchall()] + rows = [r2d(r) for r in cur.fetchall()] + for m in rows: + _attach_context(cur, m) + return rows @router.get("/maturity-models/{model_id}") @@ -249,19 +357,15 @@ def create_maturity_model(data: Dict[str, Any], session: dict = Depends(require_ """ 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) + ) VALUES (%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", @@ -274,9 +378,22 @@ def create_maturity_model(data: Dict[str, Any], session: dict = Depends(require_ 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 HTTPException(409, "Konflikt: import_id oder Name bereits vergeben") from e raise + if any( + k in data + for k in ( + "focus_area_ids", + "style_direction_ids", + "target_group_ids", + "focus_area_id", + "style_direction_id", + "target_group_id", + ) + ): + _write_context_junctions(cur, mid, data) + levels = data.get("levels") if levels: _replace_levels(cur, mid, level_count, levels) @@ -320,15 +437,7 @@ def update_maturity_model(model_id: int, data: Dict[str, Any], session: dict = D sets: List[str] = [] vals: List[Any] = [] - for key in ( - "name", - "description", - "focus_area_id", - "style_direction_id", - "target_group_id", - "status", - "version", - ): + for key in ("name", "description", "status", "version"): if key in data: sets.append(f"{key} = %s") vals.append(data[key]) @@ -343,6 +452,19 @@ def update_maturity_model(model_id: int, data: Dict[str, Any], session: dict = D tuple(vals), ) + if any( + k in data + for k in ( + "focus_area_ids", + "style_direction_ids", + "target_group_ids", + "focus_area_id", + "style_direction_id", + "target_group_id", + ) + ): + _write_context_junctions(cur, model_id, data) + if "levels" in data and data["levels"] is not None: _replace_levels(cur, model_id, level_count, data["levels"]) @@ -373,7 +495,7 @@ def add_model_skill(model_id: int, data: Dict[str, Any], session: dict = Depends with get_db() as conn: cur = get_cursor(conn) - if not _row_maturity_model(cur, model_id): + if not _base_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(): diff --git a/backend/version.py b/backend/version.py index 3e49959..47b9fff 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.7.1" +APP_VERSION = "0.7.2" BUILD_DATE = "2026-04-27" -DB_SCHEMA_VERSION = "20260427024" +DB_SCHEMA_VERSION = "20260427025" MODULE_VERSIONS = { "auth": "1.0.0", @@ -19,10 +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 + "maturity_models": "1.1.0", # 025: Kontext M:N + Wiki-Bootstrap } CHANGELOG = [ + { + "version": "0.7.2", + "date": "2026-04-27", + "changes": [ + "DB: Migration 025 – maturity_model_focus_areas / _style_directions / _target_groups (M:N)", + "Bootstrap-Reifegradmodell für alle importierten Skills (Wiki), Stufen 1–5", + "API+UI: Kontext als Mehrfachauswahl; Matrix zeigt Haupt-/Untergruppe pro Fähigkeit", + ], + }, { "version": "0.7.1", "date": "2026-04-27", diff --git a/frontend/src/pages/AdminMaturityModelsPage.jsx b/frontend/src/pages/AdminMaturityModelsPage.jsx index af81d8c..5856f40 100644 --- a/frontend/src/pages/AdminMaturityModelsPage.jsx +++ b/frontend/src/pages/AdminMaturityModelsPage.jsx @@ -4,6 +4,31 @@ import { useAuth } from '../context/AuthContext' import api from '../utils/api' import AdminPageNav from '../components/AdminPageNav' +function MultiIdSelect({ label, options, valueIds, onChange, hint }) { + return ( +