Enhance Maturity Matrix Tools and API Integration
Some checks failed
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Failing after 2s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m19s
Some checks failed
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Failing after 2s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m19s
- Added new functionality for exporting and importing matrix editor data in JSON and CSV formats within the MaturityMatrixToolsAdmin component. - Updated the API utility functions to support matrix editor exports and imports, enhancing the backend communication for Superadmin tasks. - Refactored the client API to streamline request handling and improve code clarity. - Included new UI elements for file upload and download actions, improving user experience in managing matrix data.
This commit is contained in:
parent
cdeddc7cec
commit
d58db3d5dd
|
|
@ -193,7 +193,7 @@ def read_root():
|
|||
return out
|
||||
|
||||
# Register routers
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(profiles.router)
|
||||
|
|
@ -216,6 +216,7 @@ app.include_router(training_framework_programs.router)
|
|||
app.include_router(catalogs.router)
|
||||
app.include_router(maturity_models.router)
|
||||
app.include_router(matrix_stack_bundle.router)
|
||||
app.include_router(matrix_editor.router)
|
||||
app.include_router(import_wiki.router)
|
||||
app.include_router(import_wiki_admin.router)
|
||||
app.include_router(legal_documents.router)
|
||||
|
|
|
|||
726
backend/routers/matrix_editor.py
Normal file
726
backend/routers/matrix_editor.py
Normal file
|
|
@ -0,0 +1,726 @@
|
|||
"""
|
||||
Superadmin Export/Import für zentrale Pflege der Fähigkeitsmatrix.
|
||||
|
||||
Fokus: Beschreibungen und Gewichtungen (skills.importance, model_skills.relevance,
|
||||
skill_level_definitions, model_skill_levels) — flaches, bearbeitbares Format.
|
||||
|
||||
Kein Vereinsbezug — require_auth + is_superadmin; kein TenantContext.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import JSONResponse, PlainTextResponse
|
||||
|
||||
from auth import require_auth
|
||||
from club_tenancy import is_superadmin
|
||||
from db import get_db, get_cursor, r2d
|
||||
|
||||
router = APIRouter(prefix="/api/admin/matrix-editor", tags=["admin_matrix_editor"])
|
||||
|
||||
KIND_V1 = "shinkan.matrix_editor.v1"
|
||||
|
||||
SKILLS_CSV_FIELDS = [
|
||||
"skill_key",
|
||||
"main_category",
|
||||
"subcategory",
|
||||
"skill_name",
|
||||
"description",
|
||||
"importance",
|
||||
"karate_relevance",
|
||||
"relevance_level",
|
||||
"level_1",
|
||||
"level_2",
|
||||
"level_3",
|
||||
"level_4",
|
||||
"level_5",
|
||||
]
|
||||
|
||||
MATRIX_CSV_FIELDS = [
|
||||
"model_key",
|
||||
"model_name",
|
||||
"skill_key",
|
||||
"skill_name",
|
||||
"relevance",
|
||||
"sort_order",
|
||||
"level_number",
|
||||
"level_label",
|
||||
"description",
|
||||
"observable_criteria",
|
||||
]
|
||||
|
||||
|
||||
def _require_superadmin(session: dict) -> dict:
|
||||
role = (session.get("role") or "").strip().lower()
|
||||
if not is_superadmin(role):
|
||||
raise HTTPException(status_code=403, detail="Nur Superadmins")
|
||||
return session
|
||||
|
||||
|
||||
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 _skill_key(main_slug: Optional[str], cat_slug: Optional[str], name: str) -> str:
|
||||
return f"{main_slug or ''}|{cat_slug or ''}|{(name or '').strip()}"
|
||||
|
||||
|
||||
def _model_key(import_id: Optional[str], name: str) -> str:
|
||||
iid = (import_id or "").strip()
|
||||
if iid:
|
||||
return iid
|
||||
return _slugify_label(name)
|
||||
|
||||
|
||||
def _parse_skill_key(raw: str) -> Tuple[str, str, str]:
|
||||
parts = (raw or "").split("|", 2)
|
||||
while len(parts) < 3:
|
||||
parts.append("")
|
||||
return parts[0], parts[1], parts[2]
|
||||
|
||||
|
||||
def _csv_response(text: str, filename: str) -> PlainTextResponse:
|
||||
return PlainTextResponse(
|
||||
content=text,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
def _rows_to_csv(fieldnames: List[str], rows: List[Dict[str, Any]]) -> str:
|
||||
buf = io.StringIO()
|
||||
writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction="ignore", lineterminator="\n")
|
||||
writer.writeheader()
|
||||
for row in rows:
|
||||
writer.writerow({k: row.get(k, "") for k in fieldnames})
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _load_editor_payload(cur) -> Dict[str, Any]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT s.id, s.name, s.description, s.importance, s.karate_relevance, s.relevance_level,
|
||||
mc.slug AS main_category_slug, mc.name AS main_category_name,
|
||||
sc.slug AS category_slug, sc.name AS category_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
|
||||
ORDER BY mc.sort_order NULLS LAST, sc.sort_order NULLS LAST, s.sort_order NULLS LAST, s.name
|
||||
"""
|
||||
)
|
||||
skill_rows = [r2d(r) for r in cur.fetchall()]
|
||||
skill_ids = [int(r["id"]) for r in skill_rows]
|
||||
|
||||
level_defs: Dict[int, Dict[int, str]] = {}
|
||||
if skill_ids:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT skill_id, level, description
|
||||
FROM skill_level_definitions
|
||||
WHERE skill_id = ANY(%s)
|
||||
ORDER BY skill_id, level
|
||||
""",
|
||||
(skill_ids,),
|
||||
)
|
||||
for r in cur.fetchall():
|
||||
sid = int(r["skill_id"])
|
||||
level_defs.setdefault(sid, {})[int(r["level"])] = r.get("description") or ""
|
||||
|
||||
skills_out: List[Dict[str, Any]] = []
|
||||
skills_csv: List[Dict[str, Any]] = []
|
||||
for s in skill_rows:
|
||||
sid = int(s["id"])
|
||||
main_slug = s.get("main_category_slug")
|
||||
cat_slug = s.get("category_slug")
|
||||
name = (s.get("name") or "").strip()
|
||||
key = _skill_key(main_slug, cat_slug, name)
|
||||
defs = level_defs.get(sid, {})
|
||||
level_map = {str(i): defs.get(i, "") for i in range(1, 6)}
|
||||
entry = {
|
||||
"skill_key": key,
|
||||
"main_category": s.get("main_category_name") or "",
|
||||
"subcategory": s.get("category_name") or "",
|
||||
"skill_name": name,
|
||||
"description": s.get("description") or "",
|
||||
"importance": s.get("importance"),
|
||||
"karate_relevance": s.get("karate_relevance"),
|
||||
"relevance_level": s.get("relevance_level"),
|
||||
"level_definitions": level_map,
|
||||
}
|
||||
skills_out.append(entry)
|
||||
csv_row = {
|
||||
"skill_key": key,
|
||||
"main_category": entry["main_category"],
|
||||
"subcategory": entry["subcategory"],
|
||||
"skill_name": name,
|
||||
"description": entry["description"],
|
||||
"importance": entry["importance"] if entry["importance"] is not None else "",
|
||||
"karate_relevance": entry["karate_relevance"] or "",
|
||||
"relevance_level": entry["relevance_level"] if entry["relevance_level"] is not None else "",
|
||||
}
|
||||
for i in range(1, 6):
|
||||
csv_row[f"level_{i}"] = level_map.get(str(i), "")
|
||||
skills_csv.append(csv_row)
|
||||
|
||||
cur.execute("SELECT id, name, import_id, level_count, status FROM maturity_models ORDER BY name")
|
||||
models_raw = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
models_out: List[Dict[str, Any]] = []
|
||||
matrix_csv: List[Dict[str, Any]] = []
|
||||
for m in models_raw:
|
||||
mid = int(m["id"])
|
||||
mkey = _model_key(m.get("import_id"), m.get("name") or "")
|
||||
mname = (m.get("name") or "").strip()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT level_number, name, description
|
||||
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()]
|
||||
level_label = {int(lv["level_number"]): lv.get("name") or "" for lv in levels}
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT ms.skill_id, ms.sort_order, ms.relevance, s.name AS skill_name,
|
||||
mc.slug AS main_category_slug, sc.slug AS category_slug
|
||||
FROM model_skills ms
|
||||
JOIN skills s ON s.id = ms.skill_id
|
||||
LEFT JOIN skill_main_categories mc ON s.main_category_id = mc.id
|
||||
LEFT JOIN skill_categories sc ON s.category_id = sc.id
|
||||
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,
|
||||
s.name AS skill_name, mc.slug AS main_category_slug, sc.slug AS category_slug
|
||||
FROM model_skill_levels msl
|
||||
JOIN skills s ON s.id = msl.skill_id
|
||||
LEFT JOIN skill_main_categories mc ON s.main_category_id = mc.id
|
||||
LEFT JOIN skill_categories sc ON s.category_id = sc.id
|
||||
WHERE msl.maturity_model_id = %s
|
||||
ORDER BY msl.skill_id, msl.level_number
|
||||
""",
|
||||
(mid,),
|
||||
)
|
||||
skill_levels = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
ms_by_skill: Dict[int, Dict[str, Any]] = {int(ms["skill_id"]): ms for ms in model_skills}
|
||||
cells_by_skill: Dict[int, List[Dict[str, Any]]] = {}
|
||||
for sl in skill_levels:
|
||||
sid = int(sl["skill_id"])
|
||||
cells_by_skill.setdefault(sid, []).append(
|
||||
{
|
||||
"level_number": int(sl["level_number"]),
|
||||
"description": sl.get("description") or "",
|
||||
"observable_criteria": sl.get("observable_criteria"),
|
||||
}
|
||||
)
|
||||
|
||||
matrix_rows: List[Dict[str, Any]] = []
|
||||
seen_skills = set()
|
||||
for ms in model_skills:
|
||||
sid = int(ms["skill_id"])
|
||||
seen_skills.add(sid)
|
||||
skey = _skill_key(ms.get("main_category_slug"), ms.get("category_slug"), ms.get("skill_name") or "")
|
||||
matrix_rows.append(
|
||||
{
|
||||
"skill_key": skey,
|
||||
"skill_name": ms.get("skill_name") or "",
|
||||
"relevance": ms.get("relevance"),
|
||||
"sort_order": int(ms.get("sort_order") or 0),
|
||||
"cells": cells_by_skill.get(sid, []),
|
||||
}
|
||||
)
|
||||
for cell in cells_by_skill.get(sid, []):
|
||||
ln = int(cell["level_number"])
|
||||
matrix_csv.append(
|
||||
{
|
||||
"model_key": mkey,
|
||||
"model_name": mname,
|
||||
"skill_key": skey,
|
||||
"skill_name": ms.get("skill_name") or "",
|
||||
"relevance": ms.get("relevance") or "",
|
||||
"sort_order": int(ms.get("sort_order") or 0),
|
||||
"level_number": ln,
|
||||
"level_label": level_label.get(ln, ""),
|
||||
"description": cell.get("description") or "",
|
||||
"observable_criteria": cell.get("observable_criteria") or "",
|
||||
}
|
||||
)
|
||||
|
||||
for sid, cells in cells_by_skill.items():
|
||||
if sid in seen_skills:
|
||||
continue
|
||||
sample = cells[0] if cells else {}
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT s.name, mc.slug AS main_category_slug, sc.slug AS category_slug
|
||||
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
|
||||
WHERE s.id = %s
|
||||
""",
|
||||
(sid,),
|
||||
)
|
||||
sk = r2d(cur.fetchone()) or {}
|
||||
skey = _skill_key(sk.get("main_category_slug"), sk.get("category_slug"), sk.get("name") or "")
|
||||
matrix_rows.append(
|
||||
{
|
||||
"skill_key": skey,
|
||||
"skill_name": sk.get("name") or "",
|
||||
"relevance": None,
|
||||
"sort_order": 0,
|
||||
"cells": cells,
|
||||
}
|
||||
)
|
||||
for cell in cells:
|
||||
ln = int(cell["level_number"])
|
||||
matrix_csv.append(
|
||||
{
|
||||
"model_key": mkey,
|
||||
"model_name": mname,
|
||||
"skill_key": skey,
|
||||
"skill_name": sk.get("name") or "",
|
||||
"relevance": "",
|
||||
"sort_order": 0,
|
||||
"level_number": ln,
|
||||
"level_label": level_label.get(ln, ""),
|
||||
"description": cell.get("description") or "",
|
||||
"observable_criteria": cell.get("observable_criteria") or "",
|
||||
}
|
||||
)
|
||||
|
||||
models_out.append(
|
||||
{
|
||||
"model_key": mkey,
|
||||
"model_name": mname,
|
||||
"level_count": int(m.get("level_count") or 5),
|
||||
"status": m.get("status"),
|
||||
"level_labels": [
|
||||
{
|
||||
"level_number": int(lv["level_number"]),
|
||||
"name": lv.get("name") or "",
|
||||
"description": lv.get("description"),
|
||||
}
|
||||
for lv in levels
|
||||
],
|
||||
"matrix_rows": matrix_rows,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"skills": skills_out,
|
||||
"skills_csv_rows": skills_csv,
|
||||
"maturity_models": models_out,
|
||||
"matrix_csv_rows": matrix_csv,
|
||||
}
|
||||
|
||||
|
||||
def _build_skill_lookup(cur) -> Dict[str, int]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT s.id, s.name, mc.slug AS main_category_slug, sc.slug AS category_slug
|
||||
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
|
||||
"""
|
||||
)
|
||||
out: Dict[str, int] = {}
|
||||
for r in cur.fetchall():
|
||||
row = r2d(r)
|
||||
key = _skill_key(row.get("main_category_slug"), row.get("category_slug"), row.get("name") or "")
|
||||
out[key] = int(row["id"])
|
||||
return out
|
||||
|
||||
|
||||
def _build_model_lookup(cur) -> Dict[str, int]:
|
||||
cur.execute("SELECT id, name, import_id FROM maturity_models")
|
||||
out: Dict[str, int] = {}
|
||||
for r in cur.fetchall():
|
||||
row = r2d(r)
|
||||
key = _model_key(row.get("import_id"), row.get("name") or "")
|
||||
out[key] = int(row["id"])
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_skill_id(
|
||||
cur,
|
||||
skill_lookup: Dict[str, int],
|
||||
*,
|
||||
skill_key: str,
|
||||
main_category: str = "",
|
||||
subcategory: str = "",
|
||||
skill_name: str = "",
|
||||
) -> Optional[int]:
|
||||
key = (skill_key or "").strip()
|
||||
if key and key in skill_lookup:
|
||||
return skill_lookup[key]
|
||||
if key:
|
||||
main_slug, cat_slug, name = _parse_skill_key(key)
|
||||
if name and _skill_key(main_slug, cat_slug, name) in skill_lookup:
|
||||
return skill_lookup[_skill_key(main_slug, cat_slug, name)]
|
||||
name = (skill_name or "").strip()
|
||||
if not name:
|
||||
return None
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT s.id
|
||||
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
|
||||
WHERE s.name = %s
|
||||
AND COALESCE(mc.name, '') = COALESCE(%s, '')
|
||||
AND COALESCE(sc.name, '') = COALESCE(%s, '')
|
||||
LIMIT 1
|
||||
""",
|
||||
(name, (main_category or "").strip() or None, (subcategory or "").strip() or None),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
return int(row["id"])
|
||||
cur.execute("SELECT id FROM skills WHERE name = %s LIMIT 1", (name,))
|
||||
row = cur.fetchone()
|
||||
return int(row["id"]) if row else None
|
||||
|
||||
|
||||
def _optional_int(val: Any) -> Optional[int]:
|
||||
if val is None or val == "":
|
||||
return None
|
||||
try:
|
||||
return int(val)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _apply_skills_import(cur, skills: List[Dict[str, Any]], skill_lookup: Dict[str, int], warnings: List[str]) -> int:
|
||||
updated = 0
|
||||
for row in skills:
|
||||
sid = _resolve_skill_id(
|
||||
cur,
|
||||
skill_lookup,
|
||||
skill_key=str(row.get("skill_key") or ""),
|
||||
main_category=str(row.get("main_category") or ""),
|
||||
subcategory=str(row.get("subcategory") or ""),
|
||||
skill_name=str(row.get("skill_name") or ""),
|
||||
)
|
||||
if not sid:
|
||||
warnings.append(f"Skill nicht gefunden: {row.get('skill_key') or row.get('skill_name')}")
|
||||
continue
|
||||
|
||||
importance = row.get("importance")
|
||||
if importance == "":
|
||||
importance = None
|
||||
relevance_level = row.get("relevance_level")
|
||||
if relevance_level == "":
|
||||
relevance_level = None
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE skills SET
|
||||
description = COALESCE(%s, description),
|
||||
importance = COALESCE(%s, importance),
|
||||
karate_relevance = COALESCE(%s, karate_relevance),
|
||||
relevance_level = COALESCE(%s, relevance_level),
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(
|
||||
row.get("description"),
|
||||
importance,
|
||||
row.get("karate_relevance") if row.get("karate_relevance") != "" else None,
|
||||
relevance_level,
|
||||
sid,
|
||||
),
|
||||
)
|
||||
updated += 1
|
||||
|
||||
defs = row.get("level_definitions")
|
||||
if isinstance(defs, dict):
|
||||
for lvl_str, desc in defs.items():
|
||||
try:
|
||||
lvl = int(lvl_str)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if lvl < 1 or lvl > 10:
|
||||
continue
|
||||
text = (desc or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
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()
|
||||
""",
|
||||
(sid, lvl, text),
|
||||
)
|
||||
else:
|
||||
for i in range(1, 6):
|
||||
col = row.get(f"level_{i}")
|
||||
if col is None or str(col).strip() == "":
|
||||
continue
|
||||
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()
|
||||
""",
|
||||
(sid, i, str(col).strip()),
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
def _apply_matrix_import(
|
||||
cur,
|
||||
models: List[Dict[str, Any]],
|
||||
model_lookup: Dict[str, int],
|
||||
skill_lookup: Dict[str, int],
|
||||
warnings: List[str],
|
||||
) -> int:
|
||||
cells_updated = 0
|
||||
for model in models:
|
||||
mkey = str(model.get("model_key") or "").strip()
|
||||
mid = model_lookup.get(mkey)
|
||||
if not mid and model.get("model_name"):
|
||||
mid = model_lookup.get(_model_key(None, str(model.get("model_name"))))
|
||||
if not mid:
|
||||
warnings.append(f"Reifegradmodell nicht gefunden: {mkey or model.get('model_name')}")
|
||||
continue
|
||||
|
||||
rows = model.get("matrix_rows") or []
|
||||
if not rows and model.get("cells"):
|
||||
rows = [model]
|
||||
|
||||
for mrow in rows:
|
||||
sid = _resolve_skill_id(
|
||||
cur,
|
||||
skill_lookup,
|
||||
skill_key=str(mrow.get("skill_key") or ""),
|
||||
skill_name=str(mrow.get("skill_name") or ""),
|
||||
)
|
||||
if not sid:
|
||||
warnings.append(f"Matrix-Zeile: Skill nicht gefunden ({mrow.get('skill_key')})")
|
||||
continue
|
||||
|
||||
relevance = mrow.get("relevance")
|
||||
if relevance is not None and str(relevance).strip() != "":
|
||||
sort_order = _optional_int(mrow.get("sort_order")) or 0
|
||||
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
|
||||
""",
|
||||
(mid, sid, sort_order, str(relevance).strip()),
|
||||
)
|
||||
|
||||
cells = mrow.get("cells") or []
|
||||
for cell in cells:
|
||||
ln = _optional_int(cell.get("level_number"))
|
||||
if ln is None:
|
||||
continue
|
||||
desc = (cell.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
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON CONFLICT (maturity_model_id, skill_id, level_number)
|
||||
DO UPDATE SET
|
||||
description = EXCLUDED.description,
|
||||
observable_criteria = COALESCE(EXCLUDED.observable_criteria, model_skill_levels.observable_criteria),
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(
|
||||
mid,
|
||||
sid,
|
||||
ln,
|
||||
desc,
|
||||
cell.get("observable_criteria") if cell.get("observable_criteria") else None,
|
||||
),
|
||||
)
|
||||
cells_updated += 1
|
||||
return cells_updated
|
||||
|
||||
|
||||
def _parse_csv_text(text: str) -> List[Dict[str, str]]:
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
return [dict(row) for row in reader]
|
||||
|
||||
|
||||
def _skills_from_csv_rows(rows: List[Dict[str, str]]) -> List[Dict[str, Any]]:
|
||||
out: List[Dict[str, Any]] = []
|
||||
for row in rows:
|
||||
if not any(str(v).strip() for v in row.values()):
|
||||
continue
|
||||
entry: Dict[str, Any] = {
|
||||
"skill_key": row.get("skill_key", ""),
|
||||
"main_category": row.get("main_category", ""),
|
||||
"subcategory": row.get("subcategory", ""),
|
||||
"skill_name": row.get("skill_name", ""),
|
||||
"description": row.get("description", ""),
|
||||
"importance": _optional_int(row.get("importance")),
|
||||
"karate_relevance": row.get("karate_relevance", ""),
|
||||
"relevance_level": _optional_int(row.get("relevance_level")),
|
||||
}
|
||||
for i in range(1, 6):
|
||||
entry[f"level_{i}"] = row.get(f"level_{i}", "")
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def _matrix_from_csv_rows(rows: List[Dict[str, str]]) -> List[Dict[str, Any]]:
|
||||
by_model: Dict[str, Dict[str, Any]] = {}
|
||||
row_index: Dict[Tuple[str, str], Dict[str, Any]] = {}
|
||||
|
||||
for row in rows:
|
||||
if not any(str(v).strip() for v in row.values()):
|
||||
continue
|
||||
mkey = (row.get("model_key") or "").strip()
|
||||
if not mkey:
|
||||
continue
|
||||
model = by_model.setdefault(
|
||||
mkey,
|
||||
{"model_key": mkey, "model_name": row.get("model_name") or "", "matrix_rows": []},
|
||||
)
|
||||
skey = (row.get("skill_key") or "").strip()
|
||||
row_key = (mkey, skey or (row.get("skill_name") or "").strip())
|
||||
mrow = row_index.get(row_key)
|
||||
if not mrow:
|
||||
mrow = {
|
||||
"skill_key": skey,
|
||||
"skill_name": row.get("skill_name") or "",
|
||||
"relevance": row.get("relevance") or "",
|
||||
"sort_order": _optional_int(row.get("sort_order")) or 0,
|
||||
"cells": [],
|
||||
}
|
||||
row_index[row_key] = mrow
|
||||
model["matrix_rows"].append(mrow)
|
||||
|
||||
ln = _optional_int(row.get("level_number"))
|
||||
if ln is None:
|
||||
continue
|
||||
desc = (row.get("description") or "").strip()
|
||||
if not desc:
|
||||
continue
|
||||
mrow["cells"].append(
|
||||
{
|
||||
"level_number": ln,
|
||||
"description": desc,
|
||||
"observable_criteria": row.get("observable_criteria") or "",
|
||||
}
|
||||
)
|
||||
|
||||
return list(by_model.values())
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
def export_matrix_editor(
|
||||
format: str = Query("json", pattern="^(json|csv_skills|csv_matrix)$"),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
_require_superadmin(session)
|
||||
export_uid = str(uuid.uuid4())
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
payload = _load_editor_payload(cur)
|
||||
|
||||
if format == "csv_skills":
|
||||
csv_text = _rows_to_csv(SKILLS_CSV_FIELDS, payload["skills_csv_rows"])
|
||||
return _csv_response(csv_text, f"faehigkeiten-katalog-{export_uid[:8]}.csv")
|
||||
|
||||
if format == "csv_matrix":
|
||||
csv_text = _rows_to_csv(MATRIX_CSV_FIELDS, payload["matrix_csv_rows"])
|
||||
return _csv_response(csv_text, f"faehigkeitsmatrix-zellen-{export_uid[:8]}.csv")
|
||||
|
||||
bundle = {
|
||||
"kind": KIND_V1,
|
||||
"export_version": 1,
|
||||
"bundle_export_id": export_uid,
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"skills": payload["skills"],
|
||||
"maturity_models": payload["maturity_models"],
|
||||
}
|
||||
return JSONResponse(
|
||||
content=jsonable_encoder(bundle),
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="matrix-editor-{export_uid[:8]}.json"'
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
def import_matrix_editor(
|
||||
data: Dict[str, Any] = Body(...),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
_require_superadmin(session)
|
||||
warnings: List[str] = []
|
||||
skills: List[Dict[str, Any]] = []
|
||||
models: List[Dict[str, Any]] = []
|
||||
|
||||
kind = data.get("kind")
|
||||
if kind == KIND_V1:
|
||||
skills = list(data.get("skills") or [])
|
||||
models = list(data.get("maturity_models") or [])
|
||||
elif kind == "shinkan.matrix_editor.csv":
|
||||
part = (data.get("part") or "").strip().lower()
|
||||
csv_text = data.get("csv_text") or ""
|
||||
if not csv_text.strip():
|
||||
raise HTTPException(400, "csv_text fehlt")
|
||||
rows = _parse_csv_text(csv_text)
|
||||
if part == "skills":
|
||||
skills = _skills_from_csv_rows(rows)
|
||||
elif part == "matrix":
|
||||
models = _matrix_from_csv_rows(rows)
|
||||
else:
|
||||
raise HTTPException(400, "part muss skills oder matrix sein")
|
||||
else:
|
||||
raise HTTPException(400, f"Unbekanntes Format (kind={kind!r})")
|
||||
|
||||
if not skills and not models:
|
||||
raise HTTPException(400, "Keine importierbaren Daten")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
skill_lookup = _build_skill_lookup(cur)
|
||||
model_lookup = _build_model_lookup(cur)
|
||||
skills_updated = _apply_skills_import(cur, skills, skill_lookup, warnings) if skills else 0
|
||||
cells_updated = _apply_matrix_import(cur, models, model_lookup, skill_lookup, warnings) if models else 0
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"skills_updated": skills_updated,
|
||||
"matrix_cells_updated": cells_updated,
|
||||
"warnings": warnings,
|
||||
}
|
||||
146
backend/tests/test_matrix_editor.py
Normal file
146
backend/tests/test_matrix_editor.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"""GET/POST /api/admin/matrix-editor — Superadmin Export/Import."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
|
||||
|
||||
from auth import require_auth
|
||||
from main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_overrides():
|
||||
yield
|
||||
app.dependency_overrides.pop(require_auth, None)
|
||||
|
||||
|
||||
def test_matrix_editor_export_requires_superadmin(client: TestClient) -> None:
|
||||
def _admin():
|
||||
return {"profile_id": 1, "role": "admin"}
|
||||
|
||||
app.dependency_overrides[require_auth] = _admin
|
||||
r = client.get("/api/admin/matrix-editor/export", headers={"X-Auth-Token": "t"})
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
@patch("routers.matrix_editor._load_editor_payload")
|
||||
def test_matrix_editor_export_json_ok(mock_load, client: TestClient) -> None:
|
||||
def _superadmin():
|
||||
return {"profile_id": 1, "role": "superadmin"}
|
||||
|
||||
app.dependency_overrides[require_auth] = _superadmin
|
||||
mock_load.return_value = {
|
||||
"skills": [{"skill_key": "a|b|Test", "skill_name": "Test", "description": "x", "level_definitions": {}}],
|
||||
"skills_csv_rows": [],
|
||||
"maturity_models": [],
|
||||
"matrix_csv_rows": [],
|
||||
}
|
||||
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.__enter__.return_value = MagicMock()
|
||||
mock_cm.__exit__.return_value = False
|
||||
|
||||
with patch("routers.matrix_editor.get_db", return_value=mock_cm), patch(
|
||||
"routers.matrix_editor.get_cursor", return_value=MagicMock()
|
||||
):
|
||||
r = client.get("/api/admin/matrix-editor/export", headers={"X-Auth-Token": "t"})
|
||||
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["kind"] == "shinkan.matrix_editor.v1"
|
||||
assert len(body["skills"]) == 1
|
||||
|
||||
|
||||
@patch("routers.matrix_editor._load_editor_payload")
|
||||
def test_matrix_editor_export_csv_skills(mock_load, client: TestClient) -> None:
|
||||
def _superadmin():
|
||||
return {"profile_id": 1, "role": "superadmin"}
|
||||
|
||||
app.dependency_overrides[require_auth] = _superadmin
|
||||
mock_load.return_value = {
|
||||
"skills": [],
|
||||
"skills_csv_rows": [
|
||||
{
|
||||
"skill_key": "main|sub|Skill A",
|
||||
"main_category": "Main",
|
||||
"subcategory": "Sub",
|
||||
"skill_name": "Skill A",
|
||||
"description": "Desc",
|
||||
"importance": 3,
|
||||
"karate_relevance": "",
|
||||
"relevance_level": "",
|
||||
"level_1": "L1",
|
||||
"level_2": "",
|
||||
"level_3": "",
|
||||
"level_4": "",
|
||||
"level_5": "",
|
||||
}
|
||||
],
|
||||
"maturity_models": [],
|
||||
"matrix_csv_rows": [],
|
||||
}
|
||||
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.__enter__.return_value = MagicMock()
|
||||
mock_cm.__exit__.return_value = False
|
||||
|
||||
with patch("routers.matrix_editor.get_db", return_value=mock_cm), patch(
|
||||
"routers.matrix_editor.get_cursor", return_value=MagicMock()
|
||||
):
|
||||
r = client.get(
|
||||
"/api/admin/matrix-editor/export?format=csv_skills",
|
||||
headers={"X-Auth-Token": "t"},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert "skill_key" in r.text
|
||||
assert "Skill A" in r.text
|
||||
|
||||
|
||||
@patch("routers.matrix_editor._apply_matrix_import", return_value=0)
|
||||
@patch("routers.matrix_editor._apply_skills_import", return_value=1)
|
||||
@patch("routers.matrix_editor._build_model_lookup", return_value={})
|
||||
@patch("routers.matrix_editor._build_skill_lookup", return_value={"main|sub|Skill A": 42})
|
||||
def test_matrix_editor_import_json_ok(
|
||||
mock_skill_lookup,
|
||||
mock_model_lookup,
|
||||
mock_apply_skills,
|
||||
mock_apply_matrix,
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
def _superadmin():
|
||||
return {"profile_id": 1, "role": "superadmin"}
|
||||
|
||||
app.dependency_overrides[require_auth] = _superadmin
|
||||
|
||||
mock_cm = MagicMock()
|
||||
mock_conn = MagicMock()
|
||||
mock_cm.__enter__.return_value = mock_conn
|
||||
mock_cm.__exit__.return_value = False
|
||||
|
||||
with patch("routers.matrix_editor.get_db", return_value=mock_cm), patch(
|
||||
"routers.matrix_editor.get_cursor", return_value=MagicMock()
|
||||
):
|
||||
r = client.post(
|
||||
"/api/admin/matrix-editor/import",
|
||||
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
|
||||
json={
|
||||
"kind": "shinkan.matrix_editor.v1",
|
||||
"skills": [{"skill_key": "main|sub|Skill A", "description": "Neu"}],
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert body["skills_updated"] == 1
|
||||
|
|
@ -13,13 +13,10 @@ export function mergeActiveClubHeader(headers = {}) {
|
|||
if (cid && /^\d+$/.test(String(cid).trim())) {
|
||||
return { ...headers, 'X-Active-Club-Id': String(cid).trim() }
|
||||
}
|
||||
return { ...headers }
|
||||
return headers
|
||||
}
|
||||
|
||||
/**
|
||||
* Generischer API-Aufruf inkl. X-Auth-Token und X-Active-Club-Id.
|
||||
*/
|
||||
export async function request(endpoint, options = {}) {
|
||||
async function _fetchWithAuth(endpoint, options = {}) {
|
||||
const token = localStorage.getItem('authToken')
|
||||
const method = (options.method || 'GET').toUpperCase()
|
||||
|
||||
|
|
@ -65,7 +62,7 @@ export async function request(endpoint, options = {}) {
|
|||
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
return response
|
||||
} catch (e) {
|
||||
if (e instanceof TypeError && (e.message === 'Failed to fetch' || e.message.includes('fetch'))) {
|
||||
const hint =
|
||||
|
|
@ -77,3 +74,22 @@ export async function request(endpoint, options = {}) {
|
|||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generischer API-Aufruf inkl. X-Auth-Token und X-Active-Club-Id.
|
||||
*/
|
||||
export async function request(endpoint, options = {}) {
|
||||
const response = await _fetchWithAuth(endpoint, options)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/** Text-Download (z. B. CSV-Export) mit gleicher Auth wie request(). */
|
||||
export async function requestText(endpoint, options = {}) {
|
||||
const response = await _fetchWithAuth(endpoint, options)
|
||||
const disposition = response.headers.get('Content-Disposition') || ''
|
||||
const match = disposition.match(/filename="([^"]+)"/)
|
||||
return {
|
||||
text: await response.text(),
|
||||
filename: match ? match[1] : null,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,16 @@ function downloadJson(obj, filename) {
|
|||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function downloadText(text, filename, mime = 'text/csv;charset=utf-8') {
|
||||
const blob = new Blob([text], { type: mime })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function groupModelSkills(model) {
|
||||
if (!model?.model_skills?.length) return []
|
||||
const groups = new Map()
|
||||
|
|
@ -56,6 +66,8 @@ export default function MaturityMatrixToolsAdmin() {
|
|||
const [stackConfirmText, setStackConfirmText] = useState('')
|
||||
const [stackLoading, setStackLoading] = useState(false)
|
||||
const [stackImportLoading, setStackImportLoading] = useState(false)
|
||||
const [editorLoading, setEditorLoading] = useState(false)
|
||||
const [editorImportLoading, setEditorImportLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
|
@ -144,6 +156,94 @@ export default function MaturityMatrixToolsAdmin() {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleExportEditorJson() {
|
||||
setError('')
|
||||
setMessage('')
|
||||
setEditorLoading(true)
|
||||
try {
|
||||
const bundle = await api.exportMatrixEditorBundle()
|
||||
const name = `matrix-editor-${(bundle.bundle_export_id || 'export').slice(0, 8)}.json`
|
||||
downloadJson(bundle, name)
|
||||
setMessage('Editor-Export (JSON) heruntergeladen.')
|
||||
} catch (err) {
|
||||
setError(err.message || String(err))
|
||||
} finally {
|
||||
setEditorLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExportEditorCsv(part) {
|
||||
setError('')
|
||||
setMessage('')
|
||||
setEditorLoading(true)
|
||||
try {
|
||||
const { text, filename } = await api.exportMatrixEditorCsv(part)
|
||||
downloadText(
|
||||
text,
|
||||
filename || (part === 'skills' ? 'faehigkeiten-katalog.csv' : 'faehigkeitsmatrix-zellen.csv')
|
||||
)
|
||||
setMessage(part === 'skills' ? 'Katalog-CSV heruntergeladen.' : 'Matrix-CSV heruntergeladen.')
|
||||
} catch (err) {
|
||||
setError(err.message || String(err))
|
||||
} finally {
|
||||
setEditorLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImportEditorJson(e) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setError('')
|
||||
setMessage('')
|
||||
setEditorImportLoading(true)
|
||||
try {
|
||||
const data = JSON.parse(await file.text())
|
||||
if (data.kind !== 'shinkan.matrix_editor.v1') {
|
||||
setError('Erwartet wird kind: shinkan.matrix_editor.v1')
|
||||
return
|
||||
}
|
||||
const res = await api.importMatrixEditorBundle(data)
|
||||
const w = res.warnings || []
|
||||
setMessage(
|
||||
`Import OK: ${res.skills_updated || 0} Fähigkeit(en), ${res.matrix_cells_updated || 0} Matrix-Zelle(n) aktualisiert.` +
|
||||
(w.length ? ` ${w.length} Hinweis(e) in der Konsole.` : '')
|
||||
)
|
||||
if (w.length) console.warn('matrix_editor import warnings', w)
|
||||
} catch (err) {
|
||||
setError(err.message || String(err))
|
||||
} finally {
|
||||
setEditorImportLoading(false)
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImportEditorCsv(e, part) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setError('')
|
||||
setMessage('')
|
||||
setEditorImportLoading(true)
|
||||
try {
|
||||
const csv_text = await file.text()
|
||||
const res = await api.importMatrixEditorBundle({
|
||||
kind: 'shinkan.matrix_editor.csv',
|
||||
part,
|
||||
csv_text
|
||||
})
|
||||
const w = res.warnings || []
|
||||
setMessage(
|
||||
`CSV-Import (${part}) OK: ${res.skills_updated || 0} Fähigkeit(en), ${res.matrix_cells_updated || 0} Matrix-Zelle(n) aktualisiert.` +
|
||||
(w.length ? ` ${w.length} Hinweis(e) in der Konsole.` : '')
|
||||
)
|
||||
if (w.length) console.warn('matrix_editor csv import warnings', w)
|
||||
} catch (err) {
|
||||
setError(err.message || String(err))
|
||||
} finally {
|
||||
setEditorImportLoading(false)
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExportStack() {
|
||||
setError('')
|
||||
setMessage('')
|
||||
|
|
@ -234,9 +334,8 @@ export default function MaturityMatrixToolsAdmin() {
|
|||
return (
|
||||
<div className="admin-matrix-tools">
|
||||
<p className="admin-matrix-tools__intro muted">
|
||||
Matrix nach Kontext auflösen, hierarchisch nach Hauptkategorie und Kategorie darstellen, sowie JSON
|
||||
exportieren oder importieren (gespeichertes Modell inkl. optional Kontext-Bindings, aufgelöste Matrix, oder{' '}
|
||||
<strong>Komplett-Stack</strong> mit Fähigkeitskatalog und allen Reifegradmodellen für Test → Prod).
|
||||
Zentral Beschreibungen und Gewichtungen pflegen (Superadmin), Matrix nach Kontext anzeigen, sowie
|
||||
vollständige JSON-Stacks für Test → Prod.
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
|
|
@ -246,6 +345,76 @@ export default function MaturityMatrixToolsAdmin() {
|
|||
) : null}
|
||||
{message ? <p className="muted admin-matrix-tools__msg">{message}</p> : null}
|
||||
|
||||
<section className="card admin-matrix-tools__section">
|
||||
<h2 className="admin-matrix-tools__h2">Zentral bearbeiten (Export / Import)</h2>
|
||||
<p className="muted admin-matrix-tools__hint">
|
||||
Flaches Format für Excel oder JSON-Editor: Fähigkeits-Beschreibungen,{' '}
|
||||
<code className="admin-bindings__code">importance</code> (Gewichtung 1–5), globale Stufen-Texte (
|
||||
<code className="admin-bindings__code">level_1</code> … <code className="admin-bindings__code">level_5</code>
|
||||
), Matrix-Zelltexte und Zeilen-Relevanz pro Reifegradmodell. Import aktualisiert bestehende Einträge —
|
||||
es werden keine neuen Fähigkeiten oder Modelle angelegt.
|
||||
</p>
|
||||
<div className="admin-matrix-tools__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={editorLoading || editorImportLoading}
|
||||
onClick={handleExportEditorJson}
|
||||
>
|
||||
{editorLoading ? 'Export…' : 'JSON exportieren'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={editorLoading || editorImportLoading}
|
||||
onClick={() => handleExportEditorCsv('skills')}
|
||||
>
|
||||
Katalog als CSV
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={editorLoading || editorImportLoading}
|
||||
onClick={() => handleExportEditorCsv('matrix')}
|
||||
>
|
||||
Matrix-Zellen als CSV
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-matrix-tools__io-grid admin-matrix-tools__io-grid--editor">
|
||||
<div>
|
||||
<h3 className="admin-matrix-tools__h3">JSON importieren</h3>
|
||||
<label className="form-label">Datei (<code className="admin-bindings__code">shinkan.matrix_editor.v1</code>)</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
disabled={editorImportLoading}
|
||||
onChange={handleImportEditorJson}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="admin-matrix-tools__h3">CSV importieren</h3>
|
||||
<p className="muted admin-matrix-tools__hint">
|
||||
Nur geänderte Datei hochladen — Katalog-CSV oder Matrix-CSV getrennt.
|
||||
</p>
|
||||
<label className="form-label">Katalog-CSV</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="text/csv,.csv"
|
||||
disabled={editorImportLoading}
|
||||
onChange={(e) => handleImportEditorCsv(e, 'skills')}
|
||||
/>
|
||||
<label className="form-label admin-matrix-tools__btn-mt">Matrix-CSV</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="text/csv,.csv"
|
||||
disabled={editorImportLoading}
|
||||
onChange={(e) => handleImportEditorCsv(e, 'matrix')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{editorImportLoading ? <p className="muted admin-matrix-tools__msg">Import läuft…</p> : null}
|
||||
</section>
|
||||
|
||||
<section className="card admin-matrix-tools__section">
|
||||
<h2 className="admin-matrix-tools__h2">Kontext und Anzeige</h2>
|
||||
<div className="admin-matrix-tools__filters">
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Zentrale API-Kommunikation mit automatischer Token-Injektion
|
||||
*/
|
||||
|
||||
import { request, ACTIVE_CLUB_STORAGE_KEY } from '../api/client.js'
|
||||
import { request, ACTIVE_CLUB_STORAGE_KEY, requestText } from '../api/client.js'
|
||||
import * as exercises from '../api/exercises.js'
|
||||
import * as planning from '../api/planning.js'
|
||||
import * as skillProfiles from '../api/skillProfiles.js'
|
||||
|
|
@ -523,6 +523,23 @@ export async function importMatrixStackBundle(payload) {
|
|||
})
|
||||
}
|
||||
|
||||
/** Superadmin: flacher Export für zentrale Pflege (Beschreibungen, Gewichtungen) */
|
||||
export async function exportMatrixEditorBundle() {
|
||||
return request('/api/admin/matrix-editor/export')
|
||||
}
|
||||
|
||||
export async function exportMatrixEditorCsv(part) {
|
||||
const format = part === 'skills' ? 'csv_skills' : 'csv_matrix'
|
||||
return requestText(`/api/admin/matrix-editor/export?format=${format}`)
|
||||
}
|
||||
|
||||
export async function importMatrixEditorBundle(payload) {
|
||||
return request('/api/admin/matrix-editor/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}
|
||||
|
||||
// Style Directions (formerly Training Styles)
|
||||
export async function listStyleDirections(filters = {}) {
|
||||
const query = new URLSearchParams(filters).toString()
|
||||
|
|
@ -874,6 +891,9 @@ export const api = {
|
|||
exportResolvedMaturityBundle,
|
||||
exportMatrixStackBundle,
|
||||
importMatrixStackBundle,
|
||||
exportMatrixEditorBundle,
|
||||
exportMatrixEditorCsv,
|
||||
importMatrixEditorBundle,
|
||||
resolveMaturityModel,
|
||||
getMaturityModel,
|
||||
createMaturityModel,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user