feat: update version to 0.7.6 and add matrix stack bundle functionality
- Incremented application version to 0.7.6 and updated maturity models version to 1.4.0. - Introduced new API endpoints for exporting and importing matrix stack bundles, enhancing the capabilities for managing maturity models and context bindings. - Updated frontend components to support the new matrix stack export and import features, including UI elements for stack management. - Documented changes in the changelog for version 0.7.6, detailing the new matrix stack functionality and its usage.
This commit is contained in:
parent
863535aa26
commit
2452b5e2e8
|
|
@ -1,348 +1,11 @@
|
|||
# Shinkan Jinkendo - Session Handover
|
||||
# Session Handover (Verweis)
|
||||
|
||||
**Datum:** 2026-04-22
|
||||
**Kontext:** App-Entwicklung nach Login-Setup - Kern-Features implementieren
|
||||
**Dieses Dokument ist veraltet.** Der aktuelle Entwicklungsstand und die Handover-Basis stehen hier:
|
||||
|
||||
👉 **`docs/HANDOVER.md`** (Projektroot: `c:\Dev\shinkan-jinkendo\docs\HANDOVER.md`)
|
||||
|
||||
Dort: Fähigkeits-/Reifegrad-Stand (Bindings, Resolve, Export/Import, Matrix-Stack), Verweise auf `.claude/docs` für Anforderungen, und **nächste Priorität Übungen (UI/CRUD/Medien)**.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Projekt-Mission
|
||||
|
||||
**Shinkan Jinkendo** (真観) - Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung.
|
||||
|
||||
**NICHT:** Persönliches Athleten-Tracking (das ist Mitai)
|
||||
**SONDERN:** Trainer verwalten Vereine, Gruppen, Übungen, Training
|
||||
|
||||
---
|
||||
|
||||
## 📚 Pflicht-Dokumentation (ZUERST LESEN!)
|
||||
|
||||
### Fachliches Design
|
||||
```
|
||||
c:\Dev\shinkan-jinkendo\.claude\docs\working\SHINKAN_PROJECT_SETUP.md
|
||||
```
|
||||
**Enthält:**
|
||||
- Domain Model (Clubs, Groups, Skills, Methods, Exercises)
|
||||
- MVP Features
|
||||
- Datenbank-Schema
|
||||
- User Stories
|
||||
|
||||
### Technisches Setup (bereits erstellt von vorheriger Session)
|
||||
Siehe: `SHINKAN_PROJECT_SETUP.md` - Abschnitte:
|
||||
- Tech Stack (React 18, FastAPI, PostgreSQL 16, Docker)
|
||||
- Ports: Dev 3098/8098, Prod 3003/8003
|
||||
- Deployment: Gitea Actions auf develop Branch
|
||||
|
||||
### Referenz-Codebase
|
||||
```
|
||||
c:\Dev\mitai-jinkendo\
|
||||
```
|
||||
**Nutzen für Standards:**
|
||||
- Router-Struktur (`backend/routers/`)
|
||||
- Frontend-Patterns (`frontend/src/pages/`)
|
||||
- CSS-System (`frontend/src/app.css`)
|
||||
|
||||
**⚠️ WARNUNG:** Mitai-Code NICHT blind kopieren!
|
||||
- Mitai hat AI-Features, Export, komplexes Membership
|
||||
- Shinkan ist einfacher, andere DB-Spalten
|
||||
- **Immer Schema prüfen** vor Copy/Paste!
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Aktueller Stand
|
||||
|
||||
### ✅ Was funktioniert
|
||||
- **Login/Auth:** lars@stommer.com / 12345678 (admin, premium)
|
||||
- **Backend API:** http://192.168.2.49:8098
|
||||
- **Frontend:** http://192.168.2.49:3098
|
||||
- **Datenbank:**
|
||||
- profiles, sessions (Auth)
|
||||
- clubs, divisions, training_groups (Organisation)
|
||||
- skills (12 Einträge), training_methods (8 Einträge) - bereits geseedet!
|
||||
- exercises, exercise_skills (leer)
|
||||
|
||||
### ❌ Bekannte Probleme
|
||||
|
||||
**1. Navigation erscheint nicht im Browser**
|
||||
- Code existiert: `frontend/src/components/Navigation.jsx`
|
||||
- Routes hinzugefügt: ProfilePage, ExercisesPage, ClubsPage
|
||||
- Deploy erfolgt (Commit c4b1b54)
|
||||
- **Problem:** Nicht im Browser sichtbar - vermutlich Browser-Cache oder Build-Issue
|
||||
|
||||
**2. Sessions funktionieren nicht**
|
||||
- User muss sich ständig neu einloggen
|
||||
- Backend-Fix gepusht (Commit 08326bd) - Mitai-Spalten entfernt
|
||||
- **Problem:** Unklar ob Fix deployed wurde
|
||||
|
||||
**Erste Aufgabe:** Diese beiden Issues fixen, DANN Features bauen!
|
||||
|
||||
### 📁 Code-Struktur
|
||||
|
||||
```
|
||||
c:\Dev\shinkan-jinkendo\
|
||||
├── backend/
|
||||
│ ├── main.py (FastAPI setup, Router registration)
|
||||
│ ├── auth.py (Session management, require_auth)
|
||||
│ ├── db.py (PostgreSQL connection pool)
|
||||
│ ├── models.py (Pydantic models)
|
||||
│ ├── routers/
|
||||
│ │ ├── auth.py (Login, Register, Logout)
|
||||
│ │ └── profiles.py (User profile CRUD)
|
||||
│ └── migrations/
|
||||
│ ├── 001_auth_membership.sql
|
||||
│ ├── 002_organization.sql
|
||||
│ ├── 003_catalogs.sql (Skills + Methods seeded!)
|
||||
│ └── 004_add_auth_columns.sql
|
||||
│
|
||||
├── frontend/src/
|
||||
│ ├── App.jsx (Routing + ProtectedRoute)
|
||||
│ ├── context/
|
||||
│ │ └── AuthContext.jsx (User state, login/logout)
|
||||
│ ├── components/
|
||||
│ │ └── Navigation.jsx (Header-Menü - NICHT SICHTBAR)
|
||||
│ ├── pages/
|
||||
│ │ ├── LoginPage.jsx (✅ funktioniert)
|
||||
│ │ ├── Dashboard.jsx (✅ zeigt Welcome)
|
||||
│ │ ├── ProfilePage.jsx (NEU - Platzhalter)
|
||||
│ │ ├── ExercisesPage.jsx (NEU - leer)
|
||||
│ │ └── ClubsPage.jsx (NEU - leer)
|
||||
│ └── utils/
|
||||
│ └── api.js (Zentrale API-Client mit Token-Injektion)
|
||||
│
|
||||
└── .claude/docs/
|
||||
└── working/
|
||||
└── SHINKAN_PROJECT_SETUP.md (LESEN!)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Was JETZT gebaut werden muss (MVP Scope)
|
||||
|
||||
### Phase 1: Core CRUD (Priorität)
|
||||
|
||||
**1. Übungsverwaltung** (Kernobjekt) - 2-3h
|
||||
- `backend/routers/exercises.py`
|
||||
- GET /exercises (Liste mit Filter)
|
||||
- POST /exercises (Create)
|
||||
- GET /exercises/{id} (Detail)
|
||||
- PUT /exercises/{id} (Update)
|
||||
- DELETE /exercises/{id}
|
||||
- `frontend/src/pages/ExercisesPage.jsx`
|
||||
- Liste (Tabelle oder Cards)
|
||||
- Create-Modal/Form
|
||||
- Edit inline oder Modal
|
||||
- Delete mit Confirm
|
||||
|
||||
**Schema:** Siehe `migrations/001_auth_membership.sql` - Tabelle `exercises`
|
||||
|
||||
**Felder:**
|
||||
- title, summary, goal, execution, preparation, trainer_notes
|
||||
- equipment (JSONB array), duration_min/max, group_size_min/max
|
||||
- age_groups (JSONB), focus_area, secondary_areas (JSONB)
|
||||
- training_character, visibility (private/club/public)
|
||||
- primary_method_id, secondary_method_ids (JSONB)
|
||||
|
||||
**Referenz:** Mitai hat ähnliche CRUD-Pattern in `routers/weight.py`, `routers/activity.py`
|
||||
|
||||
---
|
||||
|
||||
**2. Vereinsverwaltung** - 1-2h
|
||||
- `backend/routers/clubs.py`
|
||||
- CRUD für clubs
|
||||
- CRUD für divisions (optional)
|
||||
- `frontend/src/pages/ClubsPage.jsx`
|
||||
|
||||
**Schema:** Siehe `migrations/002_organization.sql`
|
||||
|
||||
---
|
||||
|
||||
**3. Gruppenverwaltung** - 1-2h
|
||||
- `backend/routers/groups.py`
|
||||
- CRUD für training_groups
|
||||
- Zuordnung zu Clubs
|
||||
- Trainer-Zuordnung (trainer_id, co_trainer_ids)
|
||||
- `frontend/src/pages/GroupsPage.jsx`
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Kataloge & Zuordnungen (Später)
|
||||
|
||||
**4. Skills & Methods anzeigen** - 0.5h
|
||||
- `/skills` Route (Read-only, bereits geseedet)
|
||||
- `/methods` Route (Read-only, bereits geseedet)
|
||||
|
||||
**5. Übungen ↔ Skills verknüpfen** - 1h
|
||||
- M:N Relationship über `exercise_skills` Tabelle
|
||||
- UI: Multi-Select für Skills beim Übung-Erstellen
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Debugging-Checkliste (Vor neuen Features!)
|
||||
|
||||
### Problem: Navigation nicht sichtbar
|
||||
|
||||
```bash
|
||||
# SSH zum Server
|
||||
ssh lars@192.168.2.49
|
||||
|
||||
# Frontend Container prüfen
|
||||
docker exec dev-shinkan-ui ls -la /usr/share/nginx/html/assets/
|
||||
# Erwartung: Datei index-CYNK--85.js oder neuer
|
||||
|
||||
# Prüfen ob Navigation.jsx im Bundle
|
||||
docker exec dev-shinkan-ui grep -o "Navigation\|Übungen" /usr/share/nginx/html/assets/index-*.js | head -5
|
||||
|
||||
# Falls nicht: Frontend neu bauen
|
||||
cd /home/lars/docker/shinkan-dev
|
||||
git pull
|
||||
docker compose -f docker-compose.dev-env.yml build --no-cache frontend
|
||||
docker compose -f docker-compose.dev-env.yml up -d
|
||||
```
|
||||
|
||||
### Problem: Sessions funktionieren nicht
|
||||
|
||||
```bash
|
||||
# Backend Logs prüfen
|
||||
docker logs dev-shinkan-api --tail 50 | grep -E "error|Error|column.*does not exist"
|
||||
|
||||
# Erwartung: KEINE "column p.ai_enabled does not exist" Fehler mehr
|
||||
|
||||
# Session-Test
|
||||
curl -X POST http://192.168.2.49:8098/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"lars@stommer.com","password":"12345678"}' | python3 -m json.tool
|
||||
|
||||
# Erwartung: {"token": "...", "role": "admin", ...}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Entwicklungs-Workflow
|
||||
|
||||
### 1. Feature implementieren (Lokal)
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd c:\Dev\shinkan-jinkendo
|
||||
# Router erstellen: backend/routers/exercises.py
|
||||
# Models ergänzen: backend/models.py
|
||||
# Router registrieren: backend/main.py
|
||||
|
||||
# Frontend
|
||||
# Seite erstellen: frontend/src/pages/ExercisesPage.jsx
|
||||
# API-Funktionen: frontend/src/utils/api.js
|
||||
# Route in App.jsx
|
||||
```
|
||||
|
||||
### 2. Committen & Pushen
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat: Exercise CRUD implementation"
|
||||
git push origin develop
|
||||
```
|
||||
|
||||
### 3. Gitea Action deployt automatisch
|
||||
|
||||
- Kein manuelles `docker compose` mehr!
|
||||
- Warte 1-2 Min
|
||||
- Prüfe: http://192.168.2.49:3098
|
||||
|
||||
### 4. Testen im Browser
|
||||
|
||||
- Login: lars@stommer.com / 12345678
|
||||
- Feature testen
|
||||
- Console prüfen (F12) bei Fehlern
|
||||
|
||||
---
|
||||
|
||||
## 📊 Datenbank-Zugriff
|
||||
|
||||
```bash
|
||||
# SSH
|
||||
ssh lars@192.168.2.49
|
||||
|
||||
# PostgreSQL CLI
|
||||
docker exec -it dev-shinkan-postgres psql -U shinkan_dev -d shinkan_dev
|
||||
|
||||
# Hilfreiche Queries
|
||||
\d profiles # Schema anzeigen
|
||||
\d exercises # Exercise-Schema
|
||||
SELECT * FROM skills; # Alle Skills (12 Einträge)
|
||||
SELECT * FROM training_methods; # Alle Methods (8 Einträge)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Lessons Learned (Vermeide diese Fehler!)
|
||||
|
||||
### 1. Mitai vs Shinkan Schema
|
||||
**Problem:** Auth.py von Mitai kopiert, aber Spalten fehlen in Shinkan
|
||||
|
||||
**Fehler:**
|
||||
- `ai_enabled`, `ai_limit_day`, `export_enabled` - existieren nur in Mitai
|
||||
- `auth_type`, `verification_expires`, `trial_ends_at` - mussten hinzugefügt werden
|
||||
- `created` vs `created_at` - unterschiedliche Spaltennamen
|
||||
|
||||
**Regel:** Vor Mitai-Code kopieren → **Schema prüfen**!
|
||||
|
||||
### 2. Frontend-Cache
|
||||
**Problem:** Code deployed aber nicht sichtbar im Browser
|
||||
|
||||
**Lösung:**
|
||||
- Browser-Cache leeren (Ctrl+Shift+R)
|
||||
- Oder `docker compose build --no-cache frontend`
|
||||
|
||||
### 3. Gitea Actions nicht mit manuellen Deploys mischen
|
||||
**Problem:** Container-Namens-Konflikte
|
||||
|
||||
**Regel:** Nur `git push` → Gitea macht den Rest
|
||||
|
||||
---
|
||||
|
||||
## 📞 Server-Info
|
||||
|
||||
| System | Wert |
|
||||
|--------|------|
|
||||
| **Pi IP** | 192.168.2.49 |
|
||||
| **Gitea** | http://192.168.2.144:3000/Lars/shinkan-jinkendo |
|
||||
| **Dev Frontend** | http://192.168.2.49:3098 |
|
||||
| **Dev Backend** | http://192.168.2.49:8098 |
|
||||
| **Deploy Path** | /home/lars/docker/shinkan-dev |
|
||||
| **DB Name** | shinkan_dev |
|
||||
| **DB User** | shinkan_dev |
|
||||
| **DB Pass** | dev_password |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Erfolgs-Kriterien MVP
|
||||
|
||||
Nach MVP soll User können:
|
||||
|
||||
1. ✅ Login/Logout (erledigt)
|
||||
2. ⏳ Übungen verwalten (CRUD)
|
||||
3. ⏳ Vereine anlegen
|
||||
4. ⏳ Trainingsgruppen anlegen
|
||||
5. ⏳ Übungen Skills zuordnen
|
||||
6. ⏳ Skills/Methods katalog ansehen
|
||||
|
||||
**Geschätzte Zeit:** 6-8h für Punkte 2-6
|
||||
|
||||
---
|
||||
|
||||
## 🚦 Start-Kommando für neue Session
|
||||
|
||||
```
|
||||
Ich übernehme die Entwicklung von Shinkan Jinkendo.
|
||||
|
||||
Kontext gelesen: HANDOVER_NEXT_SESSION.md
|
||||
|
||||
Erste Schritte:
|
||||
1. Navigation + Sessions debuggen
|
||||
2. Dann: Exercise CRUD implementieren
|
||||
|
||||
Bitte bestätige dass du bereit bist und zeige mir den aktuellen Status (Container, letzte Commits, bekannte Probleme).
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Viel Erfolg!** 🥋
|
||||
*Historischer Inhalt aus April 2026 wurde durch `docs/HANDOVER.md` ersetzt.*
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ def read_root():
|
|||
}
|
||||
|
||||
# Register routers
|
||||
from routers import auth, profiles, exercises, clubs, skills, training_planning, catalogs, maturity_models, import_wiki, import_wiki_admin
|
||||
from routers import auth, profiles, exercises, clubs, skills, training_planning, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(profiles.router)
|
||||
|
|
@ -80,6 +80,7 @@ app.include_router(skills.router)
|
|||
app.include_router(training_planning.router)
|
||||
app.include_router(catalogs.router)
|
||||
app.include_router(maturity_models.router)
|
||||
app.include_router(matrix_stack_bundle.router)
|
||||
app.include_router(import_wiki.router)
|
||||
app.include_router(import_wiki_admin.router)
|
||||
|
||||
|
|
|
|||
770
backend/routers/matrix_stack_bundle.py
Normal file
770
backend/routers/matrix_stack_bundle.py
Normal file
|
|
@ -0,0 +1,770 @@
|
|||
"""
|
||||
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, 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], 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"])
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.7.5"
|
||||
APP_VERSION = "0.7.6"
|
||||
BUILD_DATE = "2026-04-27"
|
||||
DB_SCHEMA_VERSION = "20260427027"
|
||||
|
||||
|
|
@ -19,10 +19,17 @@ MODULE_VERSIONS = {
|
|||
"admin": "1.0.0",
|
||||
"membership": "1.0.0",
|
||||
"catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012)
|
||||
"maturity_models": "1.3.1", # Resolve: kein Legacy, wenn Fokus bereits Bindings hat
|
||||
"maturity_models": "1.4.0", # matrix_stack_bundle: vollständiger Katalog+Modelle+Bindings Export/Import
|
||||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.7.6",
|
||||
"date": "2026-04-27",
|
||||
"changes": [
|
||||
"API: GET/POST /api/admin/matrix-stack (shinkan.matrix_stack.v1) – Fähigkeitskatalog, Reifegradmodelle, Kontext-Bindings für Test→Prod",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.7.5",
|
||||
"date": "2026-04-27",
|
||||
|
|
|
|||
91
docs/HANDOVER.md
Normal file
91
docs/HANDOVER.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||
|
||||
**Stand:** 2026-04-27
|
||||
**App-Version:** 0.7.6 (`backend/version.py`)
|
||||
**DB-Schema-Version:** `20260427027` (Migration 027)
|
||||
|
||||
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand** und **nächste Baustellen**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Pflichtlektüre (Kontext & Anforderungen)
|
||||
|
||||
| Thema | Pfad |
|
||||
|--------|------|
|
||||
| Projekt-Setup, Domain grob | `.claude/docs/working/SHINKAN_PROJECT_SETUP.md` |
|
||||
| Projekt-Status (Skills, Wiki, Stats) | `.claude/docs/PROJECT_STATUS.md` |
|
||||
| Übungen: API, DB, Architektur, Routing | `.claude/docs/technical/EXERCISES_API_SPEC.md`, `EXERCISES_DATABASE_FINAL.md`, `EXERCISES_ARCHITECTURE.md`, `EXERCISES_FRONTEND_ROUTING.md` |
|
||||
| Media / Upload | `.claude/docs/technical/MEDIA_UPLOAD_SPEC.md` |
|
||||
| MediaWiki-Import | `.claude/docs/technical/MEDIAWIKI_IMPORT_SPEC.md` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Implementierter Stand: Fähigkeiten & Reifegradmodelle
|
||||
|
||||
### 2.1 Datenbank
|
||||
|
||||
- **`maturity_models`**, **`model_levels`**, **`model_skills`**, **`model_skill_levels`**: Matrix-Inhalt pro Modell.
|
||||
- **Kontext am Modell (Legacy, M:N):** `maturity_model_focus_areas`, `maturity_model_style_directions`, `maturity_model_target_groups` (Migration 025).
|
||||
- **Hierarchische Kontext-Zuordnung (Resolve):** `maturity_model_context_bindings` mit optional `style_direction_id`, `training_type_id` (Migration 026, 027).
|
||||
- **027:** u. a. `Fokus + Trainingsstil` ohne Stilrichtung (partielle Unique-Indizes).
|
||||
|
||||
### 2.2 Resolve-Logik (Backend)
|
||||
|
||||
- **`GET /api/maturity-models/resolve`**: Bindings zum Fokus, die zur Anfrage passen; Merge nach Spezifität (weniger spezifisch zuerst); spezifischere Zeilen überschreiben Zelltexte.
|
||||
- **Matching:** Gesetzte Spalten einer Binding-Zeile müssen mit der Anfrage übereinstimmen; `NULL` in der Zeile = Wildcard (z. B. Fokus+Trainingsstil gilt für alle Stilrichtungen, aber nur für diesen Trainingsstil).
|
||||
- **Legacy-Fallback:** Nur wenn für den **Fokus keine einzige** Zeile in `maturity_model_context_bindings` existiert. Sonst bei fehlendem Treffer **`null`** (kein stilles Legacy mit falschem Trainingsstil).
|
||||
|
||||
### 2.3 Export / Import (einzelnes Modell & aufgelöst)
|
||||
|
||||
- **`GET /api/maturity-models/{id}/export`**: `shinkan.maturity_model.v1` inkl. `context_bindings_for_model` (IDs).
|
||||
- **`GET /api/maturity-models/export-resolved`**: `shinkan.maturity_matrix_resolved.v1` (Query: `focus_area_id`, optional `style_direction_id`, `training_type_id`).
|
||||
- **`POST /api/maturity-models/import`**: `create` | `replace`, optional `import_bindings` (nur bei `maturity_model.v1`).
|
||||
|
||||
### 2.4 Komplett-Stack Test → Prod
|
||||
|
||||
- **`GET /api/admin/matrix-stack/export`**: `shinkan.matrix_stack.v1` – Katalog (`skill_main_categories`, `skill_categories`, `skills`, `skill_level_definitions`) + alle Reifegradmodelle + Bindings mit **Namen** (Fokus/Stil/Trainingsstil).
|
||||
- **`POST /api/admin/matrix-stack/import`**: Upsert Katalog per Slug; Skills per Kategorie+Name; Modelle neu anlegen; Bindings per Katalognamen. Optional **`replace_all_maturity_models`** + **`confirm_replace_all: "DELETE_MATURITY_STACK"`** (nur Superadmin).
|
||||
- Router: `backend/routers/matrix_stack_bundle.py`, in `main.py` registriert.
|
||||
|
||||
### 2.5 Frontend (Admin)
|
||||
|
||||
- **`frontend/src/pages/AdminMaturityModelsPage.jsx`**: Tabs u. a. Katalog, Modelle, Kontext-Zuordnung, **Matrix-Ansicht und Export**.
|
||||
- **`MaturityModelBindingsAdmin.jsx`**: Bindings CRUD, Erklärung Merge/Legacy.
|
||||
- **`MaturityMatrixToolsAdmin.jsx`**: Kontext auflösen, hierarchische Matrix-Ansicht, Export einzelnes Modell / aufgelöst, Import, **Komplett-Stack** Export/Import.
|
||||
- **`frontend/src/utils/api.js`**: u. a. `exportMatrixStackBundle`, `importMatrixStackBundle`, Reifegrad-APIs.
|
||||
|
||||
---
|
||||
|
||||
## 3. Stand: Übungen (Lücke für nächste Session)
|
||||
|
||||
**Ist (laut Projektdoku und aktuellem Produktziel):**
|
||||
|
||||
- Backend: Übungen-CRUD, M:N, Suche, Blöcke, Medien-Struktur u. a. sind in `PROJECT_STATUS.md` als umgesetzt geführt; viele Übungen stammen aus **MediaWiki-Import** (Wiki-Tracking-Tabellen).
|
||||
- **Soll / Nutzerfeedback:** In der Praxis fehlt oder ist unzureichend: **stabile Liste**, **gerenderte Detailansicht**, **Bearbeiten/Anlegen**, **Medien zuweisen/Upload** – konkrete Fehler (404, leere Liste, falsche Route) sind vor Ort zu verifizieren.
|
||||
|
||||
**Nächste Session sollte:**
|
||||
|
||||
1. Aktuelle Routen und Seiten prüfen (`App.jsx`, `EXERCISES_FRONTEND_ROUTING.md`).
|
||||
2. `GET /api/exercises` (Filter, Auth) und eine Beispiel-Übung gegen die Dev-DB testen.
|
||||
3. UI schrittweise: Liste → Detail → Formular → Medien (an Specs in `.claude/docs/technical/` ausrichten).
|
||||
|
||||
---
|
||||
|
||||
## 4. Technische Referenz (kurz)
|
||||
|
||||
| Bereich | Einstieg |
|
||||
|---------|----------|
|
||||
| Backend API | `backend/main.py`, `backend/routers/maturity_models.py`, `matrix_stack_bundle.py`, `exercises.py`, `catalogs.py`, `skills.py` |
|
||||
| Migrationen | `backend/migrations/` (u. a. 024–027 Reifegrad/Bindings) |
|
||||
| Frontend API | `frontend/src/utils/api.js` |
|
||||
| Version / Changelog | `backend/version.py` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Veraltete Hinweise
|
||||
|
||||
Die Datei `.claude/docs/working/HANDOVER_NEXT_SESSION.md` (2026-04-22) ist **historisch**; für den aktuellen Stand gilt **`docs/HANDOVER.md`**.
|
||||
|
||||
---
|
||||
|
||||
*Ende Handover-Dokument.*
|
||||
|
|
@ -52,6 +52,9 @@ export default function MaturityMatrixToolsAdmin() {
|
|||
const [replaceModelId, setReplaceModelId] = useState('')
|
||||
const [exportModelId, setExportModelId] = useState('')
|
||||
const [importBindings, setImportBindings] = useState(true)
|
||||
const [stackWipe, setStackWipe] = useState(false)
|
||||
const [stackConfirmText, setStackConfirmText] = useState('')
|
||||
const [stackLoading, setStackLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
|
@ -140,6 +143,22 @@ export default function MaturityMatrixToolsAdmin() {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleExportStack() {
|
||||
setError('')
|
||||
setMessage('')
|
||||
setStackLoading(true)
|
||||
try {
|
||||
const bundle = await api.exportMatrixStackBundle()
|
||||
const name = `matrix-stack-${(bundle.bundle_export_id || 'export').slice(0, 8)}.json`
|
||||
downloadJson(bundle, name)
|
||||
setMessage('Komplett-Stack exportiert (Download).')
|
||||
} catch (err) {
|
||||
setError(err.message || String(err))
|
||||
} finally {
|
||||
setStackLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImportFile(e) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
|
@ -147,18 +166,39 @@ export default function MaturityMatrixToolsAdmin() {
|
|||
setMessage('')
|
||||
try {
|
||||
const data = JSON.parse(await file.text())
|
||||
const payload = { ...data, mode: importMode, import_bindings: importBindings }
|
||||
if (importMode === 'replace') {
|
||||
if (!replaceModelId) {
|
||||
setError('Bei „Ersetzen“ die Ziel-Modell-ID angeben.')
|
||||
if (data.kind === 'shinkan.matrix_stack.v1') {
|
||||
if (stackWipe && stackConfirmText !== 'DELETE_MATURITY_STACK') {
|
||||
setError('Vollständiges Ersetzen: Bestätigung exakt „DELETE_MATURITY_STACK“ eintragen (Superadmin).')
|
||||
e.target.value = ''
|
||||
return
|
||||
}
|
||||
payload.replace_model_id = parseInt(replaceModelId, 10)
|
||||
const payload = {
|
||||
...data,
|
||||
replace_all_maturity_models: stackWipe,
|
||||
confirm_replace_all: stackWipe ? stackConfirmText : undefined
|
||||
}
|
||||
const res = await api.importMatrixStackBundle(payload)
|
||||
const w = res.warnings || []
|
||||
setMessage(
|
||||
`Stack-Import OK. Modell-Zuordnung: ${Object.keys(res.model_id_map || {}).length} Modell(e).` +
|
||||
(w.length ? ` ${w.length} Hinweis(e).` : '')
|
||||
)
|
||||
if (w.length) console.warn('matrix_stack import warnings', w)
|
||||
setModels(await api.listMaturityModels({}))
|
||||
} else {
|
||||
const payload = { ...data, mode: importMode, import_bindings: importBindings }
|
||||
if (importMode === 'replace') {
|
||||
if (!replaceModelId) {
|
||||
setError('Bei „Ersetzen“ die Ziel-Modell-ID angeben.')
|
||||
e.target.value = ''
|
||||
return
|
||||
}
|
||||
payload.replace_model_id = parseInt(replaceModelId, 10)
|
||||
}
|
||||
const res = await api.importMaturityModelBundle(payload)
|
||||
setMessage(`Import erfolgreich. Modell-ID: ${res.id}`)
|
||||
setModels(await api.listMaturityModels({}))
|
||||
}
|
||||
const res = await api.importMaturityModelBundle(payload)
|
||||
setMessage(`Import erfolgreich. Modell-ID: ${res.id}`)
|
||||
setModels(await api.listMaturityModels({}))
|
||||
} catch (err) {
|
||||
setError(err.message || String(err))
|
||||
} finally {
|
||||
|
|
@ -170,7 +210,8 @@ export default function MaturityMatrixToolsAdmin() {
|
|||
<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, oder aufgelöste Matrix).
|
||||
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).
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
|
|
@ -307,6 +348,57 @@ export default function MaturityMatrixToolsAdmin() {
|
|||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="card admin-matrix-tools__section">
|
||||
<h2 className="admin-matrix-tools__h2">Komplett-Stack (Katalog + Modelle + Bindings)</h2>
|
||||
<p className="muted admin-matrix-tools__hint">
|
||||
Export enthält <code className="admin-bindings__code">skill_main_categories</code>,{' '}
|
||||
<code className="admin-bindings__code">skill_categories</code>, <code className="admin-bindings__code">skills</code>,{' '}
|
||||
<code className="admin-bindings__code">skill_level_definitions</code>, alle Reifegradmodelle (Stufen, Matrix-Zellen,
|
||||
Legacy-Kontext M:N) sowie <code className="admin-bindings__code">context_bindings</code> mit{' '}
|
||||
<strong>Fokus-/Stil-/Trainingsstil-Namen</strong> für die Ziel-DB. Auf Prod müssen dieselben
|
||||
Katalognamen für Fokusbereiche, Stilrichtungen und Trainingsstile existieren.
|
||||
</p>
|
||||
<div className="admin-matrix-tools__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={stackLoading}
|
||||
onClick={handleExportStack}
|
||||
>
|
||||
{stackLoading ? 'Export…' : 'Komplett-Stack exportieren'}
|
||||
</button>
|
||||
</div>
|
||||
<h3 className="admin-matrix-tools__h3">Stack-Import (JSON-Datei unten)</h3>
|
||||
<p className="muted admin-matrix-tools__hint">
|
||||
Datei mit <code className="admin-bindings__code">kind: shinkan.matrix_stack.v1</code>. Katalog wird per Slug
|
||||
zusammengeführt; Skills per Kategorie + Name. Optional alle Reifegradmodelle auf der Ziel-DB vorher löschen
|
||||
(nur Superadmin, Vorsicht).
|
||||
</p>
|
||||
<label className="form-label admin-matrix-tools__check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stackWipe}
|
||||
onChange={(e) => {
|
||||
setStackWipe(e.target.checked)
|
||||
if (!e.target.checked) setStackConfirmText('')
|
||||
}}
|
||||
/>{' '}
|
||||
Zuerst alle Reifegradmodelle auf dieser Datenbank löschen (nur Superadmin)
|
||||
</label>
|
||||
{stackWipe ? (
|
||||
<>
|
||||
<label className="form-label">Bestätigung (exakt)</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={stackConfirmText}
|
||||
onChange={(e) => setStackConfirmText(e.target.value)}
|
||||
placeholder="DELETE_MATURITY_STACK"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="card admin-matrix-tools__section">
|
||||
<h2 className="admin-matrix-tools__h2">Export / Import (JSON)</h2>
|
||||
<div className="admin-matrix-tools__io-grid">
|
||||
|
|
@ -339,9 +431,10 @@ export default function MaturityMatrixToolsAdmin() {
|
|||
<div>
|
||||
<h3 className="admin-matrix-tools__h3">Import</h3>
|
||||
<p className="muted admin-matrix-tools__hint">
|
||||
Datei <code className="admin-bindings__code">shinkan.maturity_model.v1</code> oder{' '}
|
||||
<code className="admin-bindings__code">shinkan.maturity_matrix_resolved.v1</code>. Aufgelöste
|
||||
Matrizen legen ein neues Modell an bzw. ersetzen den Inhalt des Zielmodells (ohne Bindings).
|
||||
<code className="admin-bindings__code">shinkan.matrix_stack.v1</code> (Komplett-Stack),{' '}
|
||||
<code className="admin-bindings__code">shinkan.maturity_model.v1</code> oder{' '}
|
||||
<code className="admin-bindings__code">shinkan.maturity_matrix_resolved.v1</code>. Aufgelöste Matrizen
|
||||
legen ein neues Modell an bzw. ersetzen den Inhalt des Zielmodells (ohne Bindings).
|
||||
</p>
|
||||
<label className="form-label">Modus</label>
|
||||
<select
|
||||
|
|
|
|||
|
|
@ -360,6 +360,18 @@ export async function exportResolvedMaturityBundle(filters = {}) {
|
|||
return request(`/api/maturity-models/export-resolved${query ? '?' + query : ''}`)
|
||||
}
|
||||
|
||||
/** Komplett: Fähigkeitskatalog + Reifegradmodelle + Kontext-Bindings (slug-/namensbasiert auf Ziel-DB) */
|
||||
export async function exportMatrixStackBundle() {
|
||||
return request('/api/admin/matrix-stack/export')
|
||||
}
|
||||
|
||||
export async function importMatrixStackBundle(payload) {
|
||||
return request('/api/admin/matrix-stack/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}
|
||||
|
||||
// Style Directions (formerly Training Styles)
|
||||
export async function listStyleDirections(filters = {}) {
|
||||
const query = new URLSearchParams(filters).toString()
|
||||
|
|
@ -720,6 +732,8 @@ export const api = {
|
|||
importMaturityModelBundle,
|
||||
exportMaturityModelBundle,
|
||||
exportResolvedMaturityBundle,
|
||||
exportMatrixStackBundle,
|
||||
importMatrixStackBundle,
|
||||
resolveMaturityModel,
|
||||
getMaturityModel,
|
||||
createMaturityModel,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user