shinkan-jinkendo/backend/routers/matrix_stack_bundle.py
Lars cb11e39201
Some checks failed
Deploy Development / deploy (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 5s
Test Suite / playwright-tests (push) Failing after 1m54s
feat: enhance exercise management and media handling
- Introduced new API endpoints for managing exercise media, including upload, update, delete, and reorder functionalities.
- Updated the exercise creation and update logic to ensure goal and execution fields are validated and normalized.
- Refactored frontend components to support the new exercise media features, including a dedicated import section for complete stack files.
- Removed the deprecated ExercisesPage component and replaced it with a more modular structure for exercise management.
- Incremented database schema version to 20260427028 and updated changelog to reflect these changes.
2026-04-27 14:27:25 +02:00

774 lines
29 KiB
Python

"""
Vollständiger Export/Import: Fähigkeitskatalog (Haupt-/Unterkategorien, Skills, Level-Definitionen),
Reifegradmodelle inkl. Kontext-M:N und Kontext-Bindings.
Ziel: Test → Prod; IDs werden über Slugs/Namen aufgelöst, nicht 1:1 übernommen.
"""
from __future__ import annotations
import json
import re
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
from fastapi import APIRouter, Body, Depends, HTTPException
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from auth import require_auth
from db import get_db, get_cursor, r2d
router = APIRouter(prefix="/api/admin/matrix-stack", tags=["admin_matrix_stack"])
KIND_V1 = "shinkan.matrix_stack.v1"
def _require_admin(session: dict) -> None:
role = session.get("role")
if role not in ("admin", "superadmin"):
raise HTTPException(403, "Nur Administratoren")
def _keywords_param(raw: Any) -> Any:
if raw is None:
return None
if isinstance(raw, str):
return raw
return json.dumps(raw)
def _slugify_label(text: str) -> str:
t = (text or "").strip().lower()
t = re.sub(r"[^a-z0-9äöüß]+", "_", t, flags=re.IGNORECASE)
t = re.sub(r"_+", "_", t).strip("_")
return (t[:48] or "gruppe")
def _jsonable(val: Any) -> Any:
if val is None:
return None
if hasattr(val, "isoformat"):
try:
return val.isoformat()
except Exception:
return str(val)
return val
def _sort_categories_topo(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
by_id = {int(r["id"]): r for r in rows}
children: Dict[Optional[int], List[int]] = {}
for r in rows:
pid = r.get("parent_category_id")
pk = int(pid) if pid is not None else None
children.setdefault(pk, []).append(int(r["id"]))
out: List[Dict[str, Any]] = []
seen = set()
def visit(cid: int) -> None:
if cid in seen:
return
seen.add(cid)
out.append(by_id[cid])
for ch in sorted(children.get(cid, [])):
visit(ch)
roots = sorted(children.get(None, []))
for root_id in roots:
visit(root_id)
# Zyklen / verwaiste Knoten
for r in rows:
rid = int(r["id"])
if rid not in seen:
visit(rid)
return out
def _catalog_name_maps(cur) -> Tuple[Dict[str, int], Dict[str, int], Dict[str, int], Dict[str, int]]:
cur.execute("SELECT id, name FROM focus_areas")
fa = {r["name"].strip(): int(r["id"]) for r in cur.fetchall() if r.get("name")}
cur.execute("SELECT id, name FROM style_directions")
sd = {r["name"].strip(): int(r["id"]) for r in cur.fetchall() if r.get("name")}
cur.execute("SELECT id, name FROM training_types")
tt = {r["name"].strip(): int(r["id"]) for r in cur.fetchall() if r.get("name")}
cur.execute("SELECT id, name FROM target_groups")
tg = {r["name"].strip(): int(r["id"]) for r in cur.fetchall() if r.get("name")}
return fa, sd, tt, tg
def export_matrix_stack_v1(session: dict = Depends(require_auth)) -> JSONResponse:
_require_admin(session)
export_uid = str(uuid.uuid4())
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT * FROM skill_main_categories
ORDER BY sort_order NULLS LAST, name
"""
)
main_cats = [r2d(r) for r in cur.fetchall()]
cur.execute("SELECT * FROM skill_categories ORDER BY sort_order NULLS LAST, name")
raw_cats = [r2d(r) for r in cur.fetchall()]
skill_categories = _sort_categories_topo(raw_cats)
cur.execute(
"""
SELECT s.*,
mc.slug AS _export_main_category_slug,
sc.slug AS _export_category_slug,
pfa.name AS _export_primary_focus_area_name
FROM skills s
LEFT JOIN skill_main_categories mc ON s.main_category_id = mc.id
LEFT JOIN skill_categories sc ON s.category_id = sc.id
LEFT JOIN focus_areas pfa ON s.primary_focus_area_id = pfa.id
ORDER BY mc.sort_order NULLS LAST, sc.sort_order NULLS LAST, s.sort_order NULLS LAST, s.name
"""
)
skills = [r2d(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT sld.* FROM skill_level_definitions sld
JOIN skills s ON s.id = sld.skill_id
ORDER BY s.name, sld.level
"""
)
skill_level_definitions = [r2d(r) for r in cur.fetchall()]
cur.execute("SELECT * FROM maturity_models ORDER BY id")
maturity_models_raw = [r2d(r) for r in cur.fetchall()]
maturity_blocks: List[Dict[str, Any]] = []
for m in maturity_models_raw:
mid = int(m["id"])
cur.execute(
"""
SELECT fa.name, 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,),
)
fa_rows = [r2d(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT 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,),
)
sd_rows = [r2d(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT 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,),
)
tg_rows = [r2d(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT * FROM model_levels
WHERE maturity_model_id = %s
ORDER BY sort_order ASC, level_number ASC
""",
(mid,),
)
levels = [r2d(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT ms.skill_id, ms.sort_order, ms.relevance
FROM model_skills ms
WHERE ms.maturity_model_id = %s
ORDER BY ms.sort_order ASC, ms.id ASC
""",
(mid,),
)
model_skills = [r2d(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT msl.skill_id, msl.level_number, msl.description, msl.observable_criteria,
msl.example_exercise_hints, msl.ai_generated
FROM model_skill_levels msl
WHERE msl.maturity_model_id = %s
ORDER BY msl.skill_id, msl.level_number
""",
(mid,),
)
skill_levels = [r2d(r) for r in cur.fetchall()]
row = {k: _jsonable(v) for k, v in m.items()}
for drop in ("created_at", "updated_at"):
row.pop(drop, None)
maturity_blocks.append(
{
"source_id": mid,
"model": row,
"legacy_focus_areas": fa_rows,
"legacy_style_directions": sd_rows,
"legacy_target_groups": tg_rows,
"levels": levels,
"model_skills": model_skills,
"skill_levels": skill_levels,
}
)
cur.execute(
"""
SELECT b.maturity_model_id AS maturity_model_source_id,
fa.name AS focus_area_name,
sd.name AS style_direction_name,
tt.name AS training_type_name
FROM maturity_model_context_bindings b
JOIN focus_areas fa ON fa.id = b.focus_area_id
LEFT JOIN style_directions sd ON sd.id = b.style_direction_id
LEFT JOIN training_types tt ON tt.id = b.training_type_id
ORDER BY fa.sort_order, fa.name, sd.name NULLS LAST, tt.name NULLS LAST
"""
)
context_bindings = [r2d(r) for r in cur.fetchall()]
bundle = {
"kind": KIND_V1,
"export_version": 1,
"bundle_export_id": export_uid,
"exported_at": datetime.now(timezone.utc).isoformat(),
"skill_main_categories": main_cats,
"skill_categories": skill_categories,
"skills": skills,
"skill_level_definitions": skill_level_definitions,
"maturity_models": maturity_blocks,
"context_bindings": context_bindings,
}
return JSONResponse(
content=jsonable_encoder(bundle),
headers={
"Content-Disposition": f'attachment; filename="matrix-stack-{export_uid[:8]}.json"'
},
)
def _upsert_main_category(cur, row: Dict[str, Any]) -> int:
name = (row.get("name") or "").strip()
slug = (row.get("slug") or "").strip() or _slugify_label(name)
if not name:
raise HTTPException(400, "skill_main_categories: name fehlt")
cur.execute(
"""
INSERT INTO skill_main_categories (name, slug, description, sort_order)
VALUES (%s, %s, %s, %s)
ON CONFLICT (slug) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
sort_order = EXCLUDED.sort_order,
updated_at = NOW()
RETURNING id
""",
(name, slug, row.get("description"), row.get("sort_order")),
)
return int(cur.fetchone()["id"])
def _upsert_skill_category(
cur,
row: Dict[str, Any],
main_id_map: Dict[int, int],
cat_id_map: Dict[int, int],
) -> Tuple[int, int]:
"""Returns (old_id, new_id)."""
old_id = int(row["id"])
name = (row.get("name") or "").strip()
slug = (row.get("slug") or "").strip() or _slugify_label(name)
if not name:
raise HTTPException(400, f"skill_categories id={old_id}: name fehlt")
old_main = row.get("main_category_id")
new_main = main_id_map.get(int(old_main)) if old_main is not None else None
old_parent = row.get("parent_category_id")
new_parent = cat_id_map.get(int(old_parent)) if old_parent is not None else None
cur.execute(
"""
INSERT INTO skill_categories (
name, slug, description, parent_category_id, main_category_id, sort_order, status
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (slug) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
parent_category_id = EXCLUDED.parent_category_id,
main_category_id = EXCLUDED.main_category_id,
sort_order = EXCLUDED.sort_order,
status = EXCLUDED.status,
updated_at = NOW()
RETURNING id
""",
(
name,
slug,
row.get("description"),
new_parent,
new_main,
row.get("sort_order"),
row.get("status") or "active",
),
)
new_id = int(cur.fetchone()["id"])
return old_id, new_id
def import_matrix_stack_v1(
data: Dict[str, Any] = Body(...),
session: dict = Depends(require_auth),
) -> Dict[str, Any]:
_require_admin(session)
if data.get("kind") != KIND_V1:
raise HTTPException(400, f"kind muss {KIND_V1} sein")
replace_all = bool(data.get("replace_all_maturity_models"))
confirm = (data.get("confirm_replace_all") or "").strip()
if replace_all and confirm != "DELETE_MATURITY_STACK":
raise HTTPException(
400,
'replace_all_maturity_models erfordert confirm_replace_all: "DELETE_MATURITY_STACK"',
)
if replace_all and session.get("role") != "superadmin":
raise HTTPException(403, "replace_all_maturity_models nur für Superadmins")
profile_id = session.get("profile_id")
warnings: List[str] = []
main_rows = data.get("skill_main_categories") or []
cat_rows = _sort_categories_topo(list(data.get("skill_categories") or []))
skill_rows = data.get("skills") or []
sld_rows = data.get("skill_level_definitions") or []
model_blocks = data.get("maturity_models") or []
bind_rows = data.get("context_bindings") or []
skill_id_map: Dict[int, int] = {}
model_id_map: Dict[int, int] = {}
with get_db() as conn:
cur = get_cursor(conn)
fa_by_name, sd_by_name, tt_by_name, tg_by_name = _catalog_name_maps(cur)
# ── Katalog: Hauptkategorien ──
main_id_map: Dict[int, int] = {}
for mc in main_rows:
old = int(mc["id"])
main_id_map[old] = _upsert_main_category(cur, mc)
# ── Katalog: Unterkategorien (mehrere Durchläufe für Parent-Kette) ──
cat_id_map: Dict[int, int] = {}
remaining = list(cat_rows)
guard = 0
while remaining and guard < len(cat_rows) + 5:
guard += 1
next_pass: List[Dict[str, Any]] = []
for row in remaining:
old_parent = row.get("parent_category_id")
if old_parent is not None and int(old_parent) not in cat_id_map:
next_pass.append(row)
continue
old_id, new_id = _upsert_skill_category(cur, row, main_id_map, cat_id_map)
cat_id_map[old_id] = new_id
remaining = next_pass
if remaining:
raise HTTPException(400, "skill_categories: Parent-Auflösung fehlgeschlagen (Zyklus?)")
# ── Skills ──
slug_to_cat_id = {}
cur.execute("SELECT id, slug FROM skill_categories WHERE slug IS NOT NULL")
for r in cur.fetchall():
slug_to_cat_id[r["slug"]] = int(r["id"])
main_slug_to_id = {}
cur.execute("SELECT id, slug FROM skill_main_categories")
for r in cur.fetchall():
main_slug_to_id[r["slug"]] = int(r["id"])
for s in skill_rows:
old_sid = int(s["id"])
name = (s.get("name") or "").strip()
if not name:
raise HTTPException(400, f"Skill id={old_sid}: name fehlt")
cat_slug = s.get("_export_category_slug")
main_slug = s.get("_export_main_category_slug")
cat_id = None
main_id = None
if cat_slug:
cat_id = slug_to_cat_id.get(cat_slug)
if cat_id is None and s.get("category_id") is not None:
cat_id = cat_id_map.get(int(s["category_id"]))
if main_slug:
main_id = main_slug_to_id.get(main_slug)
if main_id is None and s.get("main_category_id") is not None:
main_id = main_id_map.get(int(s["main_category_id"]))
pfa_name = s.get("_export_primary_focus_area_name")
pfa_id = None
if pfa_name:
pfa_id = fa_by_name.get(str(pfa_name).strip())
if pfa_id is None:
warnings.append(f"primary_focus_area „{pfa_name}“ für Skill „{name}“ nicht gefunden")
focus_json = s.get("focus_areas")
if isinstance(focus_json, (dict, list)):
focus_json = json.dumps(focus_json)
elif focus_json is None:
focus_json = "[]"
cur.execute(
"""
SELECT id FROM skills
WHERE category_id IS NOT DISTINCT FROM %s AND name = %s
LIMIT 1
""",
(cat_id, name),
)
ex = cur.fetchone()
if ex:
new_sid = int(ex["id"])
cur.execute(
"""
UPDATE skills SET
category = %s,
description = %s,
importance = %s,
keywords = %s,
status = COALESCE(%s, status),
main_category_id = COALESCE(%s, main_category_id),
category_id = COALESCE(%s, category_id),
focus_areas = %s::jsonb,
sort_order = COALESCE(%s, sort_order),
primary_focus_area_id = COALESCE(%s, primary_focus_area_id),
is_cross_domain = COALESCE(%s, is_cross_domain),
level = COALESCE(%s, level),
parent_skill_id = COALESCE(%s, parent_skill_id),
updated_at = NOW()
WHERE id = %s
""",
(
s.get("category"),
s.get("description"),
s.get("importance"),
_keywords_param(s.get("keywords")),
s.get("status"),
main_id,
cat_id,
focus_json,
s.get("sort_order"),
pfa_id,
s.get("is_cross_domain"),
s.get("level"),
None,
new_sid,
),
)
else:
cur.execute(
"""
INSERT INTO skills (
name, category, description, importance, keywords, status,
category_id, main_category_id, focus_areas, sort_order,
primary_focus_area_id, is_cross_domain, level
)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s,%s,%s,%s)
RETURNING id
""",
(
name,
s.get("category"),
s.get("description"),
s.get("importance"),
_keywords_param(s.get("keywords")),
s.get("status") or "active",
cat_id,
main_id,
focus_json,
s.get("sort_order"),
pfa_id,
s.get("is_cross_domain"),
s.get("level"),
),
)
new_sid = int(cur.fetchone()["id"])
skill_id_map[old_sid] = new_sid
# parent_skill_id zweite Runde
for s in skill_rows:
old_sid = int(s["id"])
ps = s.get("parent_skill_id")
if ps is None:
continue
new_sid = skill_id_map.get(old_sid)
new_parent = skill_id_map.get(int(ps))
if new_sid and new_parent:
cur.execute(
"UPDATE skills SET parent_skill_id = %s WHERE id = %s",
(new_parent, new_sid),
)
# skill_level_definitions
for sld in sld_rows:
old_sk = int(sld["skill_id"])
new_sk = skill_id_map.get(old_sk)
if not new_sk:
warnings.append(f"skill_level_definitions: Skill {old_sk} nicht gemappt, übersprungen")
continue
lvl = int(sld["level"])
cur.execute(
"""
INSERT INTO skill_level_definitions (skill_id, level, description)
VALUES (%s, %s, %s)
ON CONFLICT (skill_id, level) DO UPDATE SET
description = EXCLUDED.description,
updated_at = NOW()
""",
(new_sk, lvl, sld.get("description") or ""),
)
if replace_all:
cur.execute("DELETE FROM maturity_models")
for block in model_blocks:
src_mid = int(block["source_id"])
mrow = dict(block.get("model") or {})
for drop in ("id", "created_at", "updated_at"):
mrow.pop(drop, None)
lc = int(mrow.get("level_count") or 5)
if lc < 3 or lc > 10:
raise HTTPException(400, "level_count ungültig")
cur.execute(
"""
INSERT INTO maturity_models (
name, description, level_count, status, version,
created_by, import_source, import_id, club_id
)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id
""",
(
(mrow.get("name") or "").strip() or "Importiertes Modell",
mrow.get("description"),
lc,
mrow.get("status") or "draft",
mrow.get("version") or "1.0",
profile_id,
mrow.get("import_source") or "matrix_stack_v1",
mrow.get("import_id") or f"stack:{data.get('bundle_export_id') or 'na'}:{src_mid}",
None,
),
)
new_mid = int(cur.fetchone()["id"])
model_id_map[src_mid] = new_mid
for fa in block.get("legacy_focus_areas") or []:
nm = (fa.get("name") or "").strip()
fid = fa_by_name.get(nm)
if fid is None:
warnings.append(f"Modell {mrow.get('name')}: Fokus „{nm}“ unbekannt")
continue
cur.execute(
"""
INSERT INTO maturity_model_focus_areas (maturity_model_id, focus_area_id, is_primary)
VALUES (%s, %s, %s)
ON CONFLICT (maturity_model_id, focus_area_id) DO NOTHING
""",
(new_mid, fid, bool(fa.get("is_primary"))),
)
for sd in block.get("legacy_style_directions") or []:
nm = (sd.get("name") or "").strip()
sid = sd_by_name.get(nm)
if sid is None:
warnings.append(f"Modell {mrow.get('name')}: Stilrichtung „{nm}“ unbekannt")
continue
cur.execute(
"""
INSERT INTO maturity_model_style_directions (maturity_model_id, style_direction_id, is_primary)
VALUES (%s, %s, %s)
ON CONFLICT (maturity_model_id, style_direction_id) DO NOTHING
""",
(new_mid, sid, bool(sd.get("is_primary"))),
)
for tg in block.get("legacy_target_groups") or []:
nm = (tg.get("name") or "").strip()
tid = tg_by_name.get(nm)
if tid is None:
warnings.append(f"Modell {mrow.get('name')}: Zielgruppe „{nm}“ unbekannt")
continue
cur.execute(
"""
INSERT INTO maturity_model_target_groups (maturity_model_id, target_group_id, is_primary)
VALUES (%s, %s, %s)
ON CONFLICT (maturity_model_id, target_group_id) DO NOTHING
""",
(new_mid, tid, bool(tg.get("is_primary"))),
)
for lev in block.get("levels") or []:
cur.execute(
"""
INSERT INTO model_levels (maturity_model_id, level_number, name, description, sort_order)
VALUES (%s, %s, %s, %s, %s)
""",
(
new_mid,
int(lev["level_number"]),
(lev.get("name") or f"Stufe {lev['level_number']}").strip(),
lev.get("description"),
int(lev.get("sort_order") or lev["level_number"]),
),
)
for ms in block.get("model_skills") or []:
old_sk = int(ms["skill_id"])
new_sk = skill_id_map.get(old_sk)
if not new_sk:
raise HTTPException(400, f"Modell {mrow.get('name')}: unbekannte skill_id {old_sk}")
cur.execute(
"""
INSERT INTO model_skills (maturity_model_id, skill_id, sort_order, relevance)
VALUES (%s, %s, %s, %s)
ON CONFLICT (maturity_model_id, skill_id) DO UPDATE SET
sort_order = EXCLUDED.sort_order,
relevance = EXCLUDED.relevance
""",
(new_mid, new_sk, int(ms.get("sort_order") or 0), ms.get("relevance")),
)
for sl in block.get("skill_levels") or []:
old_sk = int(sl["skill_id"])
new_sk = skill_id_map.get(old_sk)
if not new_sk:
continue
ln = int(sl["level_number"])
desc = (sl.get("description") or "").strip()
if not desc:
continue
cur.execute(
"""
INSERT INTO model_skill_levels (
maturity_model_id, skill_id, level_number,
description, observable_criteria, example_exercise_hints, ai_generated
)
VALUES (%s,%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,
ai_generated = EXCLUDED.ai_generated,
updated_at = NOW()
""",
(
new_mid,
new_sk,
ln,
desc,
sl.get("observable_criteria"),
sl.get("example_exercise_hints"),
sl.get("ai_generated"),
),
)
for b in bind_rows:
src_m = int(b["maturity_model_source_id"])
new_m = model_id_map.get(src_m)
if not new_m:
warnings.append(f"Binding: Modell-Quell-ID {src_m} nicht gefunden, übersprungen")
continue
fa_n = (b.get("focus_area_name") or "").strip()
fa_id = fa_by_name.get(fa_n)
if fa_id is None:
warnings.append(f"Binding: Fokus „{fa_n}“ unbekannt")
continue
sd_n = b.get("style_direction_name")
sd_id = None
if sd_n:
sd_id = sd_by_name.get(str(sd_n).strip())
if sd_id is None:
warnings.append(f"Binding: Stil „{sd_n}“ unbekannt")
continue
tt_n = b.get("training_type_name")
tt_id = None
if tt_n:
tt_id = tt_by_name.get(str(tt_n).strip())
if tt_id is None:
warnings.append(f"Binding: Trainingsstil „{tt_n}“ unbekannt")
continue
if sd_id is None and tt_id is None:
cur.execute(
"""
DELETE FROM maturity_model_context_bindings
WHERE focus_area_id = %s AND style_direction_id IS NULL AND training_type_id IS NULL
""",
(fa_id,),
)
elif sd_id is not None and tt_id is None:
cur.execute(
"""
DELETE FROM maturity_model_context_bindings
WHERE focus_area_id = %s AND style_direction_id = %s AND training_type_id IS NULL
""",
(fa_id, sd_id),
)
elif sd_id is None and tt_id is not None:
cur.execute(
"""
DELETE FROM maturity_model_context_bindings
WHERE focus_area_id = %s AND style_direction_id IS NULL AND training_type_id = %s
""",
(fa_id, tt_id),
)
else:
cur.execute(
"""
DELETE FROM maturity_model_context_bindings
WHERE focus_area_id = %s AND style_direction_id = %s AND training_type_id = %s
""",
(fa_id, sd_id, tt_id),
)
cur.execute(
"""
INSERT INTO maturity_model_context_bindings (
maturity_model_id, focus_area_id, style_direction_id, training_type_id
)
VALUES (%s, %s, %s, %s)
""",
(new_m, fa_id, sd_id, tt_id),
)
return {
"ok": True,
"skill_id_map": {str(k): v for k, v in skill_id_map.items()},
"model_id_map": {str(k): v for k, v in model_id_map.items()},
"warnings": warnings,
}
router.add_api_route("/export", export_matrix_stack_v1, methods=["GET"])
router.add_api_route("/import", import_matrix_stack_v1, methods=["POST"])