feat: update maturity models and version to 0.7.2
- Incremented application version to 0.7.2 and updated database schema version to 20260427025. - Enhanced maturity models functionality to support M:N relationships for focus areas, style directions, and target groups. - Updated frontend to allow multi-selection for focus areas, style directions, and target groups. - Documented changes in the changelog for version 0.7.2, including new migration details and UI improvements.
This commit is contained in:
parent
5277f4f4cf
commit
f1ee1eec7e
160
backend/migrations/025_maturity_model_context_mn.sql
Normal file
160
backend/migrations/025_maturity_model_context_mn.sql
Normal file
|
|
@ -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 $$;
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div>
|
||||
<label className="form-label">{label}</label>
|
||||
<select
|
||||
multiple
|
||||
className="form-input"
|
||||
style={{ minHeight: 100, width: '100%' }}
|
||||
value={(valueIds || []).map(String)}
|
||||
onChange={(e) => {
|
||||
const v = Array.from(e.target.selectedOptions, (o) => parseInt(o.value, 10))
|
||||
onChange(v)
|
||||
}}
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o.id} value={o.id}>{o.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{hint ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)', marginTop: 4 }}>{hint}</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminMaturityModelsPage() {
|
||||
const { user } = useAuth()
|
||||
const isAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
||||
|
|
@ -23,9 +48,9 @@ export default function AdminMaturityModelsPage() {
|
|||
const [newModel, setNewModel] = useState({
|
||||
name: '',
|
||||
level_count: 5,
|
||||
focus_area_id: '',
|
||||
style_direction_id: '',
|
||||
target_group_id: '',
|
||||
focus_area_ids: [],
|
||||
style_direction_ids: [],
|
||||
target_group_ids: [],
|
||||
status: 'draft'
|
||||
})
|
||||
|
||||
|
|
@ -76,9 +101,9 @@ export default function AdminMaturityModelsPage() {
|
|||
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 ?? '',
|
||||
focus_area_ids: (d.focus_areas || []).map((x) => x.id),
|
||||
style_direction_ids: (d.style_directions || []).map((x) => x.id),
|
||||
target_group_ids: (d.target_groups || []).map((x) => x.id),
|
||||
status: d.status,
|
||||
version: d.version || '1.0'
|
||||
})
|
||||
|
|
@ -118,13 +143,9 @@ export default function AdminMaturityModelsPage() {
|
|||
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
|
||||
focus_area_ids: newModel.focus_area_ids,
|
||||
style_direction_ids: newModel.style_direction_ids,
|
||||
target_group_ids: newModel.target_group_ids
|
||||
}
|
||||
const created = await api.createMaturityModel(payload)
|
||||
await refreshModels()
|
||||
|
|
@ -132,9 +153,9 @@ export default function AdminMaturityModelsPage() {
|
|||
setNewModel({
|
||||
name: '',
|
||||
level_count: 5,
|
||||
focus_area_id: '',
|
||||
style_direction_id: '',
|
||||
target_group_id: '',
|
||||
focus_area_ids: [],
|
||||
style_direction_ids: [],
|
||||
target_group_ids: [],
|
||||
status: 'draft'
|
||||
})
|
||||
} catch (e) {
|
||||
|
|
@ -152,11 +173,9 @@ export default function AdminMaturityModelsPage() {
|
|||
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),
|
||||
focus_area_ids: meta.focus_area_ids,
|
||||
style_direction_ids: meta.style_direction_ids,
|
||||
target_group_ids: meta.target_group_ids,
|
||||
status: meta.status,
|
||||
version: meta.version
|
||||
})
|
||||
|
|
@ -343,10 +362,18 @@ export default function AdminMaturityModelsPage() {
|
|||
onClick={() => selectModel(m.id)}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{m.name}</div>
|
||||
<div style={{ fontSize: 12, opacity: 0.85 }}>
|
||||
{[m.focus_area_name, m.style_direction_name, m.target_group_name]
|
||||
.filter(Boolean)
|
||||
.join(' · ') || 'Allgemein'}
|
||||
<div style={{ fontSize: 12, opacity: 0.85, lineHeight: 1.35 }}>
|
||||
{(m.focus_areas || []).length
|
||||
? `Fokus: ${m.focus_areas.map((f) => f.name).join(', ')}`
|
||||
: 'Fokus: alle'}
|
||||
<br />
|
||||
{(m.style_directions || []).length
|
||||
? `Stil: ${m.style_directions.map((s) => s.name).join(', ')}`
|
||||
: 'Stil: alle'}
|
||||
<br />
|
||||
{(m.target_groups || []).length
|
||||
? `Zielgr.: ${m.target_groups.map((t) => t.name).join(', ')}`
|
||||
: 'Zielgr.: alle'}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, opacity: 0.75 }}>{m.status} · {m.level_count} Stufen</div>
|
||||
</button>
|
||||
|
|
@ -384,39 +411,27 @@ export default function AdminMaturityModelsPage() {
|
|||
<option value="active">Aktiv</option>
|
||||
<option value="archived">Archiviert</option>
|
||||
</select>
|
||||
<label className="form-label">Fokusbereich (optional)</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={newModel.focus_area_id}
|
||||
onChange={(e) => setNewModel((s) => ({ ...s, focus_area_id: e.target.value }))}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{focusAreas.map((fa) => (
|
||||
<option key={fa.id} value={fa.id}>{fa.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="form-label">Stilrichtung (optional)</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={newModel.style_direction_id}
|
||||
onChange={(e) => setNewModel((s) => ({ ...s, style_direction_id: e.target.value }))}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{styles.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="form-label">Zielgruppe (optional)</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={newModel.target_group_id}
|
||||
onChange={(e) => setNewModel((s) => ({ ...s, target_group_id: e.target.value }))}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{targetGroups.map((tg) => (
|
||||
<option key={tg.id} value={tg.id}>{tg.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<MultiIdSelect
|
||||
label="Fokusbereiche (leer = alle)"
|
||||
options={focusAreas}
|
||||
valueIds={newModel.focus_area_ids}
|
||||
onChange={(ids) => setNewModel((s) => ({ ...s, focus_area_ids: ids }))}
|
||||
hint="Strg/Cmd gedrückt halten für Mehrfachauswahl."
|
||||
/>
|
||||
<MultiIdSelect
|
||||
label="Stilrichtungen (leer = alle)"
|
||||
options={styles}
|
||||
valueIds={newModel.style_direction_ids}
|
||||
onChange={(ids) => setNewModel((s) => ({ ...s, style_direction_ids: ids }))}
|
||||
hint="Strg/Cmd gedrückt halten für Mehrfachauswahl."
|
||||
/>
|
||||
<MultiIdSelect
|
||||
label="Zielgruppen (leer = alle)"
|
||||
options={targetGroups}
|
||||
valueIds={newModel.target_group_ids}
|
||||
onChange={(ids) => setNewModel((s) => ({ ...s, target_group_ids: ids }))}
|
||||
hint="Strg/Cmd gedrückt halten für Mehrfachauswahl."
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary btn-full" disabled={saving}>
|
||||
Anlegen
|
||||
</button>
|
||||
|
|
@ -454,55 +469,27 @@ export default function AdminMaturityModelsPage() {
|
|||
onChange={(e) => setMeta((m) => ({ ...m, description: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<label className="form-label">Fokusbereich</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={meta.focus_area_id === null || meta.focus_area_id === '' ? '' : meta.focus_area_id}
|
||||
onChange={(e) => setMeta((m) => ({ ...m, focus_area_id: e.target.value }))}
|
||||
>
|
||||
<option value="">— (alle)</option>
|
||||
{focusAreas.map((fa) => (
|
||||
<option key={fa.id} value={fa.id}>{fa.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Stilrichtung</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={
|
||||
meta.style_direction_id === null || meta.style_direction_id === ''
|
||||
? ''
|
||||
: meta.style_direction_id
|
||||
}
|
||||
onChange={(e) => setMeta((m) => ({ ...m, style_direction_id: e.target.value }))}
|
||||
>
|
||||
<option value="">— (alle)</option>
|
||||
{styles.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Zielgruppe</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={
|
||||
meta.target_group_id === null || meta.target_group_id === ''
|
||||
? ''
|
||||
: meta.target_group_id
|
||||
}
|
||||
onChange={(e) => setMeta((m) => ({ ...m, target_group_id: e.target.value }))}
|
||||
>
|
||||
<option value="">— (alle)</option>
|
||||
{targetGroups.map((tg) => (
|
||||
<option key={tg.id} value={tg.id}>{tg.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<MultiIdSelect
|
||||
label="Fokusbereiche (keine Auswahl = alle)"
|
||||
options={focusAreas}
|
||||
valueIds={meta.focus_area_ids}
|
||||
onChange={(ids) => setMeta((m) => ({ ...m, focus_area_ids: ids }))}
|
||||
hint="Strg/Cmd + Klick für mehrere. Alle abwählen = gilt in jedem Fokusbereich."
|
||||
/>
|
||||
<MultiIdSelect
|
||||
label="Stilrichtungen (keine Auswahl = alle)"
|
||||
options={styles}
|
||||
valueIds={meta.style_direction_ids}
|
||||
onChange={(ids) => setMeta((m) => ({ ...m, style_direction_ids: ids }))}
|
||||
hint="Strg/Cmd + Klick für mehrere."
|
||||
/>
|
||||
<MultiIdSelect
|
||||
label="Zielgruppen (keine Auswahl = alle)"
|
||||
options={targetGroups}
|
||||
valueIds={meta.target_group_ids}
|
||||
onChange={(ids) => setMeta((m) => ({ ...m, target_group_ids: ids }))}
|
||||
hint="Strg/Cmd + Klick für mehrere."
|
||||
/>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<label className="form-label">Status</label>
|
||||
|
|
@ -640,17 +627,25 @@ export default function AdminMaturityModelsPage() {
|
|||
</div>
|
||||
<ul style={{ marginTop: 12, paddingLeft: 18 }}>
|
||||
{(detail.model_skills || []).map((ms) => (
|
||||
<li key={ms.skill_id} style={{ marginBottom: 6 }}>
|
||||
<strong>{ms.skill_name}</strong>
|
||||
{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: '2px 8px', fontSize: 12 }}
|
||||
onClick={() => handleRemoveSkill(ms.skill_id)}
|
||||
>
|
||||
entfernen
|
||||
</button>
|
||||
<li key={ms.skill_id} style={{ marginBottom: 10 }}>
|
||||
<div>
|
||||
<strong>{ms.skill_name}</strong>
|
||||
{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: '2px 8px', fontSize: 12 }}
|
||||
onClick={() => handleRemoveSkill(ms.skill_id)}
|
||||
>
|
||||
entfernen
|
||||
</button>
|
||||
</div>
|
||||
{(ms.skill_main_category_name || ms.skill_subcategory_name) ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', marginTop: 2 }}>
|
||||
{ms.skill_main_category_name}
|
||||
{ms.skill_subcategory_name ? ` › ${ms.skill_subcategory_name}` : ''}
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
@ -690,10 +685,17 @@ export default function AdminMaturityModelsPage() {
|
|||
left: 0,
|
||||
background: 'var(--surface)',
|
||||
fontWeight: 600,
|
||||
whiteSpace: 'nowrap'
|
||||
maxWidth: 220,
|
||||
verticalAlign: 'top'
|
||||
}}
|
||||
>
|
||||
{ms.skill_name}
|
||||
<div>{ms.skill_name}</div>
|
||||
{(ms.skill_main_category_name || ms.skill_subcategory_name) ? (
|
||||
<div style={{ fontSize: 11, fontWeight: 400, color: 'var(--text2)', marginTop: 4, lineHeight: 1.3 }}>
|
||||
{ms.skill_main_category_name || '—'}
|
||||
{ms.skill_subcategory_name ? ` › ${ms.skill_subcategory_name}` : ''}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
{(detail.levels || []).map((l) => {
|
||||
const key = `${ms.skill_id}-${l.level_number}`
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user