Fähigkeitsimport verbessert #37

Merged
Lars merged 4 commits from develop into main 2026-05-16 11:08:39 +02:00
24 changed files with 1101 additions and 117 deletions

View File

@ -0,0 +1,178 @@
# KI-gestützte Trainingsplanung Zentrales Konzept
**Version:** 0.1
**Datum:** 2026-05-16
**Status:** Arbeitsdokument (Verfeinerung durch fachliche Konzept-Agentur vorgesehen)
**Ziel:** Einheitlicher Rahmen für **stufenweise** KI-Unterstützung bei der Planung (Abschnitte, Einheiten, später mehrtägig/Rahmen) ohne vollständigen Katalog im Prompt zu spiegeln.
**Verwandte Dokumente:**
`functional/DOMAIN_MODEL.md` · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (u.a. CURR-003 zu Progressions-/KI-Automatik) · `technical/TRAINING_FRAMEWORK_SPEC.md` · `technical/KI_FEATURES_SPEC.md` · `technical/AI_PROMPT_SYSTEM_SPEC.md` · `docs/FACHLICHE_NUTZERFUNKTIONEN.md` · `docs/HANDOVER.md`
---
## 1. Produktliche Leitlinien
- **Nutzer:** Trainer/Vereinskontext, **Gruppenplanung** keine Pflicht zur individuellen Sportler-Verfolgung; Kontext soll primär aus **Gruppe**, **bereits geplanten/durchgeführten Einheiten**, **Rahmen-/Zielen** und **berechtigtem Übungskorpus** bestehen.
- **Human-in-the-loop:** KI liefert **Vorschläge** (Liste, Reihenfolge, Begründung); schreibende Übernahme in Pläne nur nach **Trainer-Bestätigung** oder expliziter Aktion (analog „Manual First“ in `KI_FEATURES_SPEC.md`).
- **Governance-first:** Nur Übungen, die die API bereits für den Mandanten/Kontext **sichtbar** freigibt, dürfen in Kandidatenlisten landen **vor** Retrieval und **vor** jedem Prompt.
---
## 2. Kernproblem: Skalierung des Kontextes
Aus einer **großen Übungssammlung** („>1000 Übungen“) können weder alle **Felder** (Ziele, Ablauf, Skills, Varianten …) noch alle **Zeilen** sinnvoll in einen LLM-Prompt.
**Festlegung:** Der LLM-Prompt erhält immer nur ein **begrenztes Kontext-Paket** mit:
| Paketteil | Zweck |
|-----------|--------|
| **Auftrag** | z.B. Sektionstyp, Dauerbudget, Schwierigkeit, erlaubte Phasen/Streams |
| **Hard Constraints** | Gruppe, Termin/Zeitraum, Governance-Filter bereits angewendet |
| **Komprimierte Historie** | Letzte *N* Einheiten als **Liste von Übungs-IDs + Kurzlabels** (+ optional Haupt-Skills), keine vollen Fließtexte |
| **Ziele / Rahmen** | Kurztexte aus Rahmen-Slot/Zielblöcken oder Trainer-Prompt |
| **Kandidaten-TopK** | z.B. 30120 Übungen, **je Zeile gekürzt** (Titel, `summary`, 25 Skill-Namen/Stufen); **nie** der gesamte Katalog |
| **Strukturierte Kanten optional** | Kleine Mengen Kanten aus Progressionsgraph: „Nachbarn von zuletzt genutzten Übungen“ |
**ZahlenRichtwerte (überarbeitungsfähig):**
Kandidaten **vor** dem LLM typischerweise **50150** Einträge; im Prompt durch Token-Limit weiter **truncate** oder **zweistufig** (grober Ranking-Schritt ohne LLM, dann finer mit LLM auf Top40).
---
## 3. Pipeline: „Selektion vor dem Prompt“
Die **„optimale“** Auswahl entsteht **nicht**, indem das Modell den Katalog „im Kopf“ hält, sondern über eine **mehrstufige Pipeline**:
### Stufe 1 Harte Filter (deterministisch, DB)
Synchron zur bestehenden Suche/List-API-Logik, z.B.:
- Sichtbarkeit / Verein / `official`Regeln
- Aktivitäts-/Archiv-Status der Übung
- Fokusbereich, Stil, Zielgruppe (wenn Trainings-/Gruppenkontext das vorgibt)
- Ausschluss bereits in **dieser Einheit** fester Übernutzung (optional)
Ergebnis: Menge \(M\) kann noch sehr groß sein.
### Stufe 2 Kontext-Verankerung (deterministisch + Graph)
- **Historie:** aus letzten *N* Gruppeneinheiten extrahierte `exercise_id`s (optional Variant).
- **Progressionsgraph:** ausgehend davon Nachbarn (eingehend/ausgehend begrenzte Tiefe) bereits im Produkt als **unterstützend** modelliert (**CURR013**).
- **Rahmen/Slot-Ziele:** Überlapp mit Skill-Tags oder Stichwortliste (falls formalisiert).
- **Variantenketten:** `prerequisite_variant_id` / `progression_level` nur innerhalb bereits gewählter Übung prüfen oder als Hint an den LLM-Block durchreichen.
Ergebnis: **„AnkerMenge“** + **„GraphNachbarschaft“** → priorisierte Kandidaten.
### Stufe 3 Weiches Ranking / Retrieval (halb-strukturiert)
Mindestens **eine** der folgenden Optionen kombinierbar:
1. **Skill-/Facet-Overlap:** Punktezahl, wenn Übungs-Skills mit Ziel-/Matrix-Schwerpunkten übereinstimmen (bereits Daten in `exercise_skills`).
2. **Diversitäts-/Wiederholungsstrafe:** häufig in letzten Wochen geübte Übungen abwerten.
3. **Textsuche:** PostgreSQL **`tsvector`/Volltext** auf `title`, `summary`, ggf. gekürzte `goal` für Trainer-Stichwort „Koordination Sprung“.
4. **Semantische Suche:** Embeddings + **Ähnlichkeitsuche** auf Kurztexte (siehe §5).
Ergebnis: sortierte Liste, **TopK** für den Prompt.
### Stufe 4 LLM (optional zweistufig)
- **Optional 1:** LLM nur **sortiert/rankted** bereits vorgegebene IDs (Ranking mit kurzer Begründung).
- **Optional 2:** Zwei Calls: erst „welche drei Schwerpunkte“ / „Welche Constraints habe ich übersehen?“, zweiter Call nur mit gekürztem TopK nur wenn UX den Mehraufwand rechtfertigt.
**Ausgabe-Contract:** Zurückkommen dürfen **nur gültige `exercise_id`s** aus der übergebenen Kandidatenliste (Server validiert gegen Set); **Halluzinationsrisiko damit entschärft**.
---
## 4. Antwort auf die konkrete Frage: „Alle Übungen in den Prompt?“
**Nein.** Workflow:
1. **DB + Regeln + Graph + Historie** reduzieren auf **einige Hundert bis wenige tausend** Rohzeilen höchstens **intern** aber
2. in den **LLM-Prompt** gehen nur **TopK kompakte Artefakte** (siehe §2).
Das LLM löst dann **Ranking, Reihenfolge, Timing-Hinweise, Trainer-sprachliche Kurzkommentare** nicht die Frage „gibt es diese Übung überhaupt im System?“.
---
## 5. Vector DB (z.B. Qdrant) wann nötig, wann nicht?
### 5.1 Ziel embeddings
Semantic Retrieval: „wie springt Coordinative Belastung ohne das Wort Koordination im Titel zu stehen.“ Das ist **über** reine Filtersuche und oft **über** stumpfe Volltextsuche erreichbar.
### 5.2 Option A ohne separate Vector DB
- **PostgreSQL + `pgvector`** (Extension): Embeddings-Spalte an `exercises` (oder an „Search Document“-Zeilen), Indices, Abfrage zusammen mit SQL-Filtern in **einer Transaktions-DB**.
- **Größenordnung** einige 10k100k Zeilen für Übungen: für Shinkan **oft ausreichend**.
- Vorteile: ein Betriebspfad, Mandanten-/Governance weiter in SQL lösen; Backup wie heute.
### 5.3 Option B Qdrant (oder anderer Dediz-Vektorstore)
Sinnvoller zeitlicher Punkt oder technische Auslöser:
- sehr hohe Latenz-Anforderung mit **Hybrid-Filter** über viele kombinierte Metadaten in nahezu Echtzeit,
- Entkopplung: Embedding-Pipeline läuft asynchron und soll die **Operational DB** nicht beschweren,
- später **mehrsprachig** oder **mehrere Embedding-Versionen**/Re-Index ohne großen Migrationstress,
- Team bevorzugt **Dedicated** Vector-Ops gegenüber Postgres-Ops.
### 5.4 Empfehlung für diese Codebasis (überarbeitungsfähig)
1. **Phase 1:** Harte Filter + Graph + Historie + **PostgreSQL-Volltext** + TopK; LLM erst auf komprimierten Kandidaten → hoher Nutzen ohne neuen Infrastructure-Typen.
2. **Phase 2:** Bei nachweisbaren „Recall-Lücken“ (Trainer findet nichts Passendes trotz großem Korpus) **`pgvector` in Postgres** evaluieren **vor** zusätzlicher Infrastruktur wie Qdrant.
3. **Phase 3:** Qdrant (o. ä.) wenn Größenordnung, Betrieb oder Produkt-Anforderungen **pgvector** sprengen oder klar eine **embedding-first** Produktstraße geplant wird.
**Fazit:** Eine dedizierte **Vector DB ist nicht zwingende Voraussetzung** für vernünftige Selektion; sie ist eine **Ausbaustufe**, wenn **semantische Lücke** und Skalierung es rechtfertigen. **Selektion** ist immer **„Filter + Ranking + kleines TopK“**, unabhängig vom Speicherort der Vektoren.
---
## 6. Datenpflege für gutes Retrieval (fachlicher Hebel)
RetrievalQualität hängt stärker an **Metadaten** als an der Embedding-Technologie allein:
- verlässliche **Skills** (`exercise_skills`, ggf. KI-geholfen bereits laut Spez beim Übungs-Anlegen);
- sinnvolle **`summary`**-Felder für Karten/Liste/KI-Pack;
- **Progressionsgraph** dort, wo pädagogische Ketten gefestigt sind;
- konsistente **Fokusbereich/Stil**-Zuordnung.
Das fachliche Konzept sollte entscheiden: **wie viel automatische Pflege vs. Trainer-Pflichtfelder**.
---
## 7. Produkt-/Release-Stufen (Anknüpfung)
| Stufe | Nutzen | Technik-Schwerpunkt |
|-------|--------|---------------------|
| A | Backend-KI-Service + Prompt-Slugs unter `technical/AI_PROMPT_SYSTEM_SPEC.md` | OpenRouter, Timeouts, 503 ohne Key |
| B | „Übungen für Abschnitt vorschlagen“ | Pipeline §3 Stufen 13 + Prompt mit TopK |
| C | Reihenfolge / Zeitslots innerhalb bestehender Sektion | Graph + LLM Ranking |
| D | Ganze Einheit (inkl. Phasen/Streams vereinfacht) | strukturiertere JSON-Ausgabe, strikte Schema-Validation |
| E | Mehreinheiten / RahmenAlignment | Ziele aus Rahmenprogramm, Serie von Slots |
Die **SelektionsPipeline §3 bleibt** über alle Stufen konsistent und wird nur parametrierbar erweitert.
---
## 8. Compliance & Datenschutz (Kurzhinweis)
- Datenminimierung: **keine Teilnehmerliste** ohne expliziten Scope; Kontext über **Einheiten-Metadaten** und Übungen.
- **OpenRouter**/Modellwahl: Organisation intern klären (AV/Verarbeitungsvertrag, Datenflüsse außerhalb EU siehe Repo-Compliance-Dokumente).
- **Logging:** Prompts keine unnötigen personenbezogenen Daten; wenn Debug: Retention definieren.
---
## 9. Offene Punkte für die fachliche Verfeinerung
- Gewichtung „**Wiederholung** vs. **Progression** vs. **Motivation**“ (domänenspezifisch).
- Umgang mit **Kombinationsübungen** und **Coach-Stufen B/C** in der Datenübergabe.
- Soll das System **„Lücken“** aus der **Matrix-Auflösung** aktiv quantifizieren oder nur Narrative verwenden?
- Akzeptierte **Übersetzungen**: nur Deutsch oder mehrsprachige Embeddings erforderlich?
---
## 10. Glossar
| Begriff | Bedeutung |
|---------|-----------|
| **TopK** | Feste kleine Obergrenze Übungen pro LLM-Anfrage |
| **Hard filter** | Deterministische SQL-/Policy-Einschränkung vor KI |
| **Kontext-Paket** | Zusammengesetztes, tokenlimitiertes Eingabeobjekt für den Prompt |
| **Retrieval** | algorithmischer Schritt ohne Generierung („wer kommt überhaupt in Frage“) |

View File

@ -3,7 +3,7 @@ Vereins-Mandanten: Mitgliedschaften, aktiver Vereinskontext, einfache Berechtigu
Siehe .claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md
"""
from typing import Any, Dict, List, Optional, Set
from typing import Any, Dict, List, Mapping, Optional, Set, Union
from fastapi import HTTPException
@ -155,6 +155,165 @@ def club_ids_for_profile_with_roles(cur, profile_id: int, *role_codes: str) -> S
_GOVERNANCE_VISIBILITY = frozenset({"private", "club", "official"})
def _library_governance_triplet(
row: Mapping[str, Any],
) -> tuple[str, Optional[int], Optional[int]]:
"""visibility, club_id, created_by als normalisierte Werte für Bibliotheks-/Planungsartefakte."""
vis = str(row.get("visibility") or "private").strip().lower()
if vis not in _GOVERNANCE_VISIBILITY:
vis = "private"
cid_raw = row.get("club_id")
try:
ex_cid = int(cid_raw) if cid_raw is not None else None
except (TypeError, ValueError):
ex_cid = None
cr_raw = row.get("created_by")
try:
creator = int(cr_raw) if cr_raw is not None else None
except (TypeError, ValueError):
creator = None
return vis, ex_cid, creator
def assert_library_content_editable(
cur,
profile_id: int,
role: Optional[str],
row: Union[Dict[str, Any], Mapping[str, Any]],
) -> None:
"""Inhalt bearbeiten: wie Übungen — Ersteller, Plattform-Admin oder Planungsberechtigter im Verein."""
pid = int(profile_id)
ex_vis, ex_cid, creator = _library_governance_triplet(row)
if creator is not None and creator == pid:
return
if is_platform_admin(role):
return
if ex_vis == "club" and ex_cid is not None and can_plan_in_club(cur, pid, ex_cid, role):
return
raise HTTPException(status_code=403, detail="Keine Berechtigung zum Bearbeiten dieses Inhalts")
def assert_library_content_deletable(
cur,
profile_id: int,
role: Optional[str],
row: Union[Dict[str, Any], Mapping[str, Any]],
) -> None:
"""Löschen: wie Übungen — privat Eigentümer/Vereins-Admin-Kontext, Verein nur Vereinsadmin, offiziell nur Plattform."""
pid = int(profile_id)
if is_platform_admin(role):
return
vis, cid, creator = _library_governance_triplet(row)
try:
creator_int = int(creator) if creator is not None else None
except (TypeError, ValueError):
creator_int = None
if vis == "official":
raise HTTPException(
status_code=403,
detail="Offizielle Inhalte dürfen nur von Plattform-Admins gelöscht werden.",
)
if vis == "club":
try:
ex_club = int(cid) if cid is not None else None
except (TypeError, ValueError):
ex_club = None
if ex_club is None:
raise HTTPException(status_code=400, detail="Vereinsinhalt ohne gültige Vereinszuordnung")
if not has_club_role(cur, pid, ex_club, "club_admin"):
raise HTTPException(
status_code=403,
detail="Nur Vereins-Admins dürfen Vereins-Inhalte löschen.",
)
return
if creator_int is not None and creator_int == pid:
return
if creator_int is not None and club_admin_shares_club_with_creator(cur, pid, creator_int):
return
raise HTTPException(status_code=403, detail="Keine Berechtigung zum Löschen dieses Inhalts")
def assert_library_content_governance_transition(
cur,
profile_id: int,
role: Optional[str],
prev_row: Union[Dict[str, Any], Mapping[str, Any]],
next_visibility: str,
next_club_id: Optional[int],
) -> None:
"""
Zusätzliche Regeln beim Ändern von visibility/club_id (Zielzustand vor assert_valid_governance_visibility prüfen).
- Abwahl official: nur Plattform-Admin.
- private club: nur Ersteller (oder Plattform-Admin).
- club private: Ersteller, Vereinsadmin im bisherigen Verein oder Plattform-Admin.
- club club mit Wechsel club_id: Vereinsadmin im alten oder neuen Verein oder Plattform-Admin.
"""
nv = str(next_visibility or "").strip().lower()
if nv not in _GOVERNANCE_VISIBILITY:
raise HTTPException(status_code=400, detail="Ungültige visibility")
old_vis, old_cid, creator = _library_governance_triplet(prev_row)
new_cid: Optional[int]
try:
new_cid = int(next_club_id) if next_club_id is not None else None
except (TypeError, ValueError):
new_cid = None
pid = int(profile_id)
try:
creator_int = int(creator) if creator is not None else None
except (TypeError, ValueError):
creator_int = None
if old_vis == nv and (nv != "club" or old_cid == new_cid):
return
if old_vis == "official" and nv != "official":
if not is_platform_admin(role):
raise HTTPException(
status_code=403,
detail="Nur Plattform-Admins dürfen offizielle Inhalte auf Verein oder privat setzen.",
)
if nv == "official":
return
if old_vis == "private" and nv == "club":
if creator_int is not None and creator_int != pid and not is_platform_admin(role):
raise HTTPException(
status_code=403,
detail="Nur der Ersteller darf private Inhalte für den Verein freigeben.",
)
return
if old_vis == "club" and nv == "private":
if is_platform_admin(role):
return
if creator_int is not None and creator_int == pid:
return
if old_cid is not None and has_club_role(cur, pid, old_cid, "club_admin"):
return
raise HTTPException(
status_code=403,
detail="Nur Ersteller, Vereins-Admins oder Plattform-Admins dürfen Vereins-Inhalte auf privat setzen.",
)
if old_vis == "club" and nv == "club" and old_cid != new_cid:
if is_platform_admin(role):
return
ok_old = old_cid is not None and has_club_role(cur, pid, old_cid, "club_admin")
ok_new = new_cid is not None and has_club_role(cur, pid, new_cid, "club_admin")
if ok_old or ok_new:
return
raise HTTPException(
status_code=403,
detail="Nur Vereins-Admins oder Plattform-Admins dürfen die Vereinszuordnung ändern.",
)
def assert_valid_governance_visibility(
cur,
profile_id: int,

View File

@ -0,0 +1,11 @@
-- Migration 065: Wiki-spezifische Felder fuer Fähigkeiten (KarateRelevanz, RelevanzLevel)
-- SMW karatetrainer.net; Import mappt in strukturierte Spalten statt nur Freitext in description
ALTER TABLE skills
ADD COLUMN IF NOT EXISTS karate_relevance TEXT;
ALTER TABLE skills
ADD COLUMN IF NOT EXISTS relevance_level SMALLINT CHECK (relevance_level IS NULL OR relevance_level BETWEEN 1 AND 3);
COMMENT ON COLUMN skills.karate_relevance IS 'Wiki Karate-Relevanz (Plaintext aus SMW Property KarateRelevanz)';
COMMENT ON COLUMN skills.relevance_level IS 'Wiki-RelevanzLevel 13 (Semantic MediaWiki)';

View File

@ -117,6 +117,8 @@ class SkillCreate(BaseModel):
description: Optional[str] = None
importance: Optional[int] = Field(None, ge=1, le=5)
keywords: Optional[List[str]] = []
karate_relevance: Optional[str] = None
relevance_level: Optional[int] = Field(None, ge=1, le=3)
class SkillResponse(BaseModel):
id: int
@ -125,6 +127,8 @@ class SkillResponse(BaseModel):
description: Optional[str]
importance: Optional[int]
keywords: Optional[List[str]]
karate_relevance: Optional[str] = None
relevance_level: Optional[int] = None
status: str
created_at: datetime

View File

@ -1,7 +1,7 @@
"""
Progressionsgraph zwischen Übungen (Übung Übung), Migration 032034.
Optional Übungsvarianten als Knoten-Endpunkte; Sequenz-Bulk-Anlage.
AuthZ analog training_plan_templates (_template_access / _has_planning_role).
AuthZ analog training_plan_templates: Bearbeiten/Löschen wie Übungen; Governance-Übergänge zentral.
"""
from typing import Any, List, Optional
@ -12,8 +12,10 @@ from psycopg2 import IntegrityError
from db import get_db, get_cursor, r2d
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
from club_tenancy import (
assert_library_content_deletable,
assert_library_content_editable,
assert_library_content_governance_transition,
assert_valid_governance_visibility,
is_platform_admin,
library_content_visible_to_profile,
)
@ -111,13 +113,7 @@ def _assert_graph_readable(cur, row: dict, profile_id: int, role: str) -> None:
def _assert_graph_writable(cur, row: dict, profile_id: int, role: str) -> None:
if is_platform_admin(role):
return
created_by = row.get("created_by")
if created_by is not None:
created_by = int(created_by)
if created_by != profile_id:
raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Progressionsgraph")
assert_library_content_editable(cur, profile_id, role, row)
def _require_graph_read(cur, graph_id: int, profile_id: int, role: str) -> dict:
@ -333,6 +329,7 @@ def update_progression_graph(
)
gov_club = next_club if next_vis == "club" else None
assert_library_content_governance_transition(cur, profile_id, role, row, next_vis, gov_club)
assert_valid_governance_visibility(cur, profile_id, role, next_vis, gov_club)
fields: List[str] = []
@ -376,7 +373,9 @@ def delete_progression_graph(graph_id: int, tenant: TenantContext = Depends(get_
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
_require_graph_write(cur, graph_id, profile_id, role)
row = _graph_row(cur, graph_id)
_assert_graph_readable(cur, row, profile_id, role)
assert_library_content_deletable(cur, profile_id, role, row)
cur.execute("DELETE FROM exercise_progression_graphs WHERE id = %s", (graph_id,))
conn.commit()
return {"ok": True}

View File

@ -30,6 +30,17 @@ CATEGORY_METHODS = os.getenv("MEDIAWIKI_CATEGORY_METHODS", "Methodenbeschrei
CATEGORY_MODELS = os.getenv("MEDIAWIKI_CATEGORY_MODELS", "Reifegradmodelle")
def _wiki_category_or_default(category: Optional[str], import_type: str) -> str:
"""Leeres category ⇒ Standard je import_type."""
if (category or "").strip():
return category.strip()
if import_type == "skill":
return CATEGORY_SKILLS
if import_type == "method":
return CATEGORY_METHODS
return CATEGORY_EXERCISES
# ------------------------------------------------------------------ #
# Pydantic Models #
# ------------------------------------------------------------------ #
@ -59,7 +70,7 @@ def require_admin(session: dict = Depends(require_auth)) -> dict:
@router.get("/import/mediawiki/preview")
async def preview_import(
category: str = Query(default=CATEGORY_EXERCISES),
category: Optional[str] = Query(default=None),
import_type: str = Query(default="exercise"),
limit: int = Query(default=10, ge=1, le=500),
session: dict = Depends(require_admin),
@ -68,9 +79,10 @@ async def preview_import(
Zeigt Vorschau: Welche Seiten würden importiert werden?
Überprüft Duplikate und mapped Felder ohne zu speichern.
"""
resolved_category = _wiki_category_or_default(category, import_type)
client = SmwClient()
try:
members = await client.get_category_members(category, limit=limit)
members = await client.get_category_members(resolved_category, limit=limit)
except SmwClientError as e:
raise HTTPException(status_code=502, detail=f"Wiki-API nicht erreichbar: {e}")
@ -129,7 +141,7 @@ async def preview_import(
})
return {
"category": category,
"category": resolved_category,
"import_type": import_type,
"total_found": len(members),
"preview": preview,
@ -151,6 +163,7 @@ async def execute_import(
Gibt sofort log_id zurück Status via GET /import/mediawiki/status/{log_id}.
"""
profile_id = session["profile_id"]
resolved_category = _wiki_category_or_default(body.category, body.import_type)
# Log-Eintrag anlegen
with get_db() as conn:
@ -160,7 +173,7 @@ async def execute_import(
(import_type, import_status, category, dry_run, reimport, imported_by)
VALUES (%s, 'running', %s, %s, %s, %s)
RETURNING id""",
(body.import_type, body.category, body.dry_run, body.reimport_existing, profile_id)
(body.import_type, resolved_category, body.dry_run, body.reimport_existing, profile_id)
)
log_id = cur.fetchone()['id']
conn.commit()
@ -169,7 +182,7 @@ async def execute_import(
background_tasks.add_task(
_run_import,
log_id=log_id,
category=body.category,
category=resolved_category,
import_type=body.import_type,
reimport=body.reimport_existing,
dry_run=body.dry_run,
@ -615,8 +628,8 @@ def _assign_exercise_skills(cur, conn, exercise_id: int, skill_assignments: list
conn.commit()
def _upsert_skill(mapped: dict, reimport: bool) -> Optional[int]:
"""Legt Skill an oder überspringt bei Duplikat."""
def _upsert_skill(mapped: dict, _reimport: bool) -> Optional[int]:
"""Legt Skill an oder aktualisiert bestehenden Datensatz bei Namenskonflikt (Wiki-Reimport)."""
with get_db() as conn:
cur = get_cursor(conn)
@ -626,17 +639,27 @@ def _upsert_skill(mapped: dict, reimport: bool) -> Optional[int]:
cur.execute("SELECT id FROM skill_categories WHERE name ILIKE %s", (mapped["category_name"],))
row = cur.fetchone()
if row:
category_id = row['id']
category_id = row["id"]
cur.execute(
"""INSERT INTO skills (name, description, category_id)
VALUES (%s, %s, %s)
"""INSERT INTO skills (name, description, category_id, karate_relevance, relevance_level)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (name) DO UPDATE SET
description = EXCLUDED.description
description = COALESCE(EXCLUDED.description, skills.description),
category_id = COALESCE(EXCLUDED.category_id, skills.category_id),
karate_relevance = COALESCE(EXCLUDED.karate_relevance, skills.karate_relevance),
relevance_level = COALESCE(EXCLUDED.relevance_level, skills.relevance_level),
updated_at = NOW()
RETURNING id""",
(mapped["name"], mapped.get("description"), category_id)
(
mapped["name"],
mapped.get("description"),
category_id,
mapped.get("karate_relevance"),
mapped.get("relevance_level"),
),
)
skill_id = cur.fetchone()['id']
skill_id = cur.fetchone()["id"]
conn.commit()
return skill_id

View File

@ -181,9 +181,10 @@ def create_skill(data: dict, session=Depends(require_auth)):
"""
INSERT INTO skills (
name, category, description, importance, keywords, status,
category_id, main_category_id, focus_areas, sort_order
category_id, main_category_id, focus_areas, sort_order,
karate_relevance, relevance_level
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s)
RETURNING id
""",
(
@ -197,6 +198,8 @@ def create_skill(data: dict, session=Depends(require_auth)):
main_id,
focus if focus is not None else "[]",
data.get("sort_order"),
data.get("karate_relevance"),
data.get("relevance_level"),
),
)
@ -232,6 +235,8 @@ def update_skill(skill_id: int, data: dict, session=Depends(require_auth)):
"category_id",
"main_category_id",
"sort_order",
"karate_relevance",
"relevance_level",
):
if key in data:
sets.append(f"{key} = %s")

View File

@ -3,13 +3,16 @@ Trainingsrahmenprogramm — wiederverwendbare Vorlage (Bibliothek) über mehrere
Zuordnung zu Trainingsgruppen / konkreten Einheiten erfolgt aus der Planung (Kopie + Lineage),
nicht über group_id oder training_unit_id am Rahmen.
Lesen wie Übungen (official / private / club); Schreiben nur Ersteller oder Plattform-Admin.
Lesen wie Übungen (official / private / club); Bearbeiten wie Übungen; Löschen nach Rolle (s. club_tenancy).
"""
from typing import Any, Dict, List, Optional, Sequence
from fastapi import APIRouter, Depends, HTTPException
from club_tenancy import (
assert_library_content_deletable,
assert_library_content_editable,
assert_library_content_governance_transition,
assert_valid_governance_visibility,
is_platform_admin,
library_content_visible_to_profile,
@ -56,13 +59,6 @@ def _framework_assert_readable(
raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Trainingsrahmen")
def _framework_assert_writable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None:
if is_platform_admin(role):
return
if row.get("created_by") != profile_id:
raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Trainingsrahmen")
def _framework_access(cur, framework_id: int, profile_id: int, role: str) -> Dict[str, Any]:
row = _fetch_framework_row(cur, framework_id)
_framework_assert_readable(cur, row, profile_id, role)
@ -459,7 +455,7 @@ def update_training_framework_program(
with get_db() as conn:
cur = get_cursor(conn)
row_prev = _fetch_framework_row(cur, framework_id)
_framework_assert_writable(cur, row_prev, profile_id, role)
assert_library_content_editable(cur, profile_id, role, row_prev)
merged_vis = row_prev.get("visibility") or "private"
merged_club = row_prev.get("club_id")
@ -472,7 +468,12 @@ def update_training_framework_program(
merged_club = data.get("club_id")
if merged_club in ("", []):
merged_club = None
if merged_vis == "club" and merged_club is None:
merged_club = tenant.effective_club_id
if "visibility" in data or "club_id" in data:
assert_library_content_governance_transition(
cur, profile_id, role, row_prev, merged_vis, merged_club
)
assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club)
header_fields = []
@ -574,7 +575,7 @@ def delete_training_framework_program(
with get_db() as conn:
cur = get_cursor(conn)
row_fw = _fetch_framework_row(cur, framework_id)
_framework_assert_writable(cur, row_fw, profile_id, role)
assert_library_content_deletable(cur, profile_id, role, row_fw)
cur.execute(
"DELETE FROM training_framework_programs WHERE id = %s",
(framework_id,),

View File

@ -2,7 +2,7 @@
Trainingsmodule wiederverwendbare Planungsbausteine (Bibliothek).
Governance wie TrainingsMikrovorlagen (`training_plan_templates`):
Liste/Detail über `library_content_visibility_sql`; Schreiben: Ersteller oder PlattformAdmin.
Liste/Detail über `library_content_visibility_sql`; Bearbeiten/Löschen wie Übungen (s. club_tenancy).
Siehe `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md`.
"""
@ -13,6 +13,9 @@ from fastapi import APIRouter, Depends, HTTPException
from db import get_db, get_cursor, r2d
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
from club_tenancy import (
assert_library_content_deletable,
assert_library_content_editable,
assert_library_content_governance_transition,
assert_valid_governance_visibility,
is_platform_admin,
library_content_visible_to_profile,
@ -47,13 +50,6 @@ def _module_assert_readable(cur, row: Dict[str, Any], profile_id: int, role: Opt
raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Modul")
def _module_assert_writable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None:
if is_platform_admin(role):
return
if row.get("created_by") != profile_id:
raise HTTPException(status_code=403, detail="Nur der Ersteller darf dieses Modul ändern")
def _module_access(cur, mid: int, profile_id: int, role: str) -> Dict[str, Any]:
row = _fetch_training_module_row(cur, mid)
_module_assert_readable(cur, row, profile_id, role)
@ -288,7 +284,7 @@ def update_training_module(
with get_db() as conn:
cur = get_cursor(conn)
row_prev = _fetch_training_module_row(cur, module_id)
_module_assert_writable(cur, row_prev, profile_id, role)
assert_library_content_editable(cur, profile_id, role, row_prev)
merged_vis = row_prev.get("visibility") or "club"
merged_club = row_prev.get("club_id")
@ -303,8 +299,12 @@ def update_training_module(
merged_club = data.get("club_id")
if merged_club in ("", []):
merged_club = None
if merged_vis == "club" and merged_club is None:
merged_club = tenant.effective_club_id
if "visibility" in data or "club_id" in data:
assert_library_content_governance_transition(
cur, profile_id, role, row_prev, merged_vis, merged_club
)
assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club)
fields: List[str] = []
@ -375,7 +375,7 @@ def delete_training_module(module_id: int, tenant: TenantContext = Depends(get_t
with get_db() as conn:
cur = get_cursor(conn)
row_del = _fetch_training_module_row(cur, module_id)
_module_assert_writable(cur, row_del, profile_id, role)
assert_library_content_deletable(cur, profile_id, role, row_del)
cur.execute("DELETE FROM training_modules WHERE id = %s", (module_id,))
conn.commit()
return {"ok": True}

View File

@ -2,7 +2,7 @@
Training Planning Trainingseinheiten, strukturierter Ablauf (Sektionen + Übungen/Notizen)
und wiederverwendbare Trainingsvorlagen (Sektions-Gliederung).
Governance: Sichtbarkeit wie Übungen (private / club / official); Schreiben nur Ersteller oder Plattform-Admin.
Governance: Sichtbarkeit wie Übungen (private / club / official); Bearbeiten wie Übungen; Löschen nach Rolle (s. club_tenancy).
"""
from datetime import date, datetime, time as dt_time, timedelta
from typing import Any, Dict, List, Optional, Tuple
@ -15,6 +15,9 @@ from fastapi_param_unwrap import unwrap_query_default
from db import get_db, get_cursor, r2d
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
from club_tenancy import (
assert_library_content_deletable,
assert_library_content_editable,
assert_library_content_governance_transition,
assert_valid_governance_visibility,
can_manage_club_org,
is_platform_admin,
@ -1748,13 +1751,6 @@ def _template_assert_readable(cur, row: Dict[str, Any], profile_id: int, role: O
raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Vorlage")
def _template_assert_writable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None:
if is_platform_admin(role):
return
if row.get("created_by") != profile_id:
raise HTTPException(status_code=403, detail="Nur der Ersteller darf diese Vorlage ändern")
def _template_access(cur, tid: int, profile_id: int, role: str) -> Dict[str, Any]:
"""Lesender Zugriff (Liste der Vorlage für Einheit); Schreiben: _template_assert_writable."""
row = _fetch_training_plan_template_row(cur, tid)
@ -1853,7 +1849,7 @@ def update_training_plan_template(template_id: int, data: dict, tenant: TenantCo
with get_db() as conn:
cur = get_cursor(conn)
row_prev = _fetch_training_plan_template_row(cur, template_id)
_template_assert_writable(cur, row_prev, profile_id, role)
assert_library_content_editable(cur, profile_id, role, row_prev)
merged_vis = row_prev.get("visibility") or "club"
merged_club = row_prev.get("club_id")
if "visibility" in data:
@ -1865,7 +1861,12 @@ def update_training_plan_template(template_id: int, data: dict, tenant: TenantCo
merged_club = data.get("club_id")
if merged_club in ("", []):
merged_club = None
if merged_vis == "club" and merged_club is None:
merged_club = tenant.effective_club_id
if "visibility" in data or "club_id" in data:
assert_library_content_governance_transition(
cur, profile_id, role, row_prev, merged_vis, merged_club
)
assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club)
fields = []
params: List[Any] = []
@ -1911,7 +1912,7 @@ def delete_training_plan_template(template_id: int, tenant: TenantContext = Depe
with get_db() as conn:
cur = get_cursor(conn)
row_del = _fetch_training_plan_template_row(cur, template_id)
_template_assert_writable(cur, row_del, profile_id, role)
assert_library_content_deletable(cur, profile_id, role, row_del)
cur.execute("DELETE FROM training_plan_templates WHERE id = %s", (template_id,))
conn.commit()
return {"ok": True}

View File

@ -20,6 +20,26 @@ class SmwClientError(Exception):
pass
def _normalize_mw_category(category: str) -> str:
"""
Bereinigt Kategorienamen für API cmtitle=Kategorie:
Erlaubt z. B. 'Fähigkeitsbeschreibung', ' Kategorie:X ', 'kategorie:X' ohne Doppel-Prefix.
"""
c = (category or "").strip()
if not c:
raise SmwClientError("Kategorie (Seitenlisten-Name ohne Präfix) darf nicht leer sein")
pref = "kategorie:"
while c.lower().startswith(pref):
c = c[len(pref) :].lstrip()
remaining = (c or "").strip()
if not remaining:
raise SmwClientError("Kategorie (Seitenlisten-Name ohne Präfix) darf nicht leer sein")
return remaining
class SmwClient:
"""Stateless MediaWiki/SMW API Client mit Session-Login."""
@ -110,12 +130,13 @@ class SmwClient:
"""
members = []
cmcontinue = None
cat = _normalize_mw_category(category)
while True:
params = {
"action": "query",
"list": "categorymembers",
"cmtitle": f"Kategorie:{category}",
"cmtitle": f"Kategorie:{cat}",
"cmlimit": min(limit, 500),
"cmtype": "page", # Nur Seiten, keine Unterkategorien
"cmprop": "ids|title",
@ -134,8 +155,8 @@ class SmwClient:
# Rekursiv durch Unterkategorien gehen
if recursive:
subcats = await self._get_subcategories(category)
logger.info(f"Kategorie '{category}': {len(members)} direkte Seiten, {len(subcats)} Unterkategorien")
subcats = await self._get_subcategories(cat)
logger.info(f"Kategorie '{cat}': {len(members)} direkte Seiten, {len(subcats)} Unterkategorien")
for subcat in subcats:
if len(members) >= limit:
@ -150,12 +171,13 @@ class SmwClient:
"""Gibt alle Unterkategorien einer Kategorie zurück."""
subcats = []
cmcontinue = None
cat = _normalize_mw_category(category)
while True:
params = {
"action": "query",
"list": "categorymembers",
"cmtitle": f"Kategorie:{category}",
"cmtitle": f"Kategorie:{cat}",
"cmlimit": 500,
"cmtype": "subcat", # Nur Unterkategorien
"cmprop": "ids|title",

View File

@ -63,8 +63,8 @@ EXERCISE_PROPERTY_MAP = {
# Fähigkeiten (skills) Kategorie: Fähigkeitsbeschreibung
SKILL_PROPERTY_MAP = {
"Summary": "description",
"KarateRelevanz": "karate_relevance", # Wird in description ergänzt
"RelevanzLevel": "relevance_level", # 1-3, nicht direkt in skills DB
"KarateRelevanz": "karate_relevance", # Spalte skills.karate_relevance (+ Wiki-Import)
"RelevanzLevel": "relevance_level", # Spalte skills.relevance_level 13
}
# Trainingsmethoden Kategorie: Methodenbeschreibung
@ -172,6 +172,27 @@ def map_capability_level(level_str: str) -> str:
return CAPABILITY_LEVEL_MAP.get(level_str.strip(), "basis")
def parse_wiki_relevance_level(raw: str | None) -> Optional[int]:
"""
RelevanzLevel aus Wiki (typisch 13). Erlaubt Zahl oder Text mit Ziffer z.B. "Level_2".
"""
if raw is None:
return None
s = str(raw).strip()
if not s:
return None
digits = re.findall(r"\d+", s)
if not digits:
return None
try:
n = int(digits[0])
except ValueError:
return None
if n < 1 or n > 3:
return None
return n
# ------------------------------------------------------------------ #
# SMW-Property-Label → Mapper-Zielfeld (Werte wie in EXERCISE_PROPERTY_MAP) #
# browse_subject liefert Anzeigenamen, nicht zwingend interne Property-IDs. #
@ -184,6 +205,23 @@ def _norm_prop_synonym(name: str) -> str:
return "".join(c for c in s if c.isalnum())
# Alternative SMW-Anzeigelabel → Zielfeld (gleiche Targets wie SKILL_PROPERTY_MAP)
SKILL_PROPERTY_SYNONYM_TO_TARGET: dict[str, str] = {
"karaterelevanz": "karate_relevance",
"karatebezug": "karate_relevance",
"relevanzlevel": "relevance_level",
"wikirelevanz": "karate_relevance",
}
def _skill_property_target(prop_name: str) -> str | None:
"""Ermittelt Zielfeld für eine Skill-SMW-Property."""
if prop_name in SKILL_PROPERTY_MAP:
return SKILL_PROPERTY_MAP[prop_name]
n = _norm_prop_synonym(prop_name)
return SKILL_PROPERTY_SYNONYM_TO_TARGET.get(n)
# alternative Labels → Zielfeld-Name (gleiche Strings wie Werte in EXERCISE_PROPERTY_MAP)
EXERCISE_PROPERTY_SYNONYM_TO_TARGET: dict[str, str] = {
"primarycapability": "skill_names",
@ -359,24 +397,29 @@ def map_wiki_to_skill(
"warnings": [],
}
description_parts = []
description_text: Optional[str] = None
for prop_name, values in smw_props.items():
if not values:
continue
target = SKILL_PROPERTY_MAP.get(prop_name)
target = _skill_property_target(prop_name)
if not target:
continue
first_value = values[0] if isinstance(values, list) else values
if target == "description":
description_parts.insert(0, wikitext_to_plaintext(first_value))
description_text = wikitext_to_plaintext(str(first_value))
elif target == "karate_relevance":
rel = wikitext_to_plaintext(first_value)
description_parts.append(f"\nKarate-Relevanz: {rel}")
mapped["karate_relevance"] = wikitext_to_plaintext(str(first_value))
elif target == "relevance_level":
parsed = parse_wiki_relevance_level(first_value if isinstance(first_value, str) else str(first_value))
if parsed is None:
mapped["warnings"].append(f"Unbekanntes RelevanzLevel: {first_value!r}")
else:
mapped["relevance_level"] = parsed
if description_parts:
mapped["description"] = "\n".join(description_parts).strip()
if description_text:
mapped["description"] = description_text
return mapped

View File

@ -0,0 +1,18 @@
"""Resolver für leere/fehlende Kategorie je import_type."""
from routers.import_wiki import (
CATEGORY_EXERCISES,
CATEGORY_METHODS,
CATEGORY_SKILLS,
_wiki_category_or_default,
)
def test_wiki_category_or_default_explicit():
assert _wiki_category_or_default(" MeineKat ", "skill") == "MeineKat"
def test_wiki_category_or_default_falls_through():
assert _wiki_category_or_default(None, "exercise") == CATEGORY_EXERCISES
assert _wiki_category_or_default("", "skill") == CATEGORY_SKILLS
assert _wiki_category_or_default(" ", "method") == CATEGORY_METHODS

View File

@ -0,0 +1,21 @@
import pytest
from smw_client import _normalize_mw_category, SmwClientError
def test_normalize_strips_optional_kategorie_prefix():
assert _normalize_mw_category("Fähigkeitsbeschreibung") == "Fähigkeitsbeschreibung"
assert _normalize_mw_category(" Kategorie:Fähigkeitsbeschreibung ") == "Fähigkeitsbeschreibung"
assert _normalize_mw_category("kategorie:test") == "test"
def test_normalize_strips_duplicate_prefix_chain():
assert _normalize_mw_category("Kategorie:Kategorie:Foo") == "Foo"
def test_normalize_rejects_empty():
with pytest.raises(SmwClientError, match="leer"):
_normalize_mw_category("")
with pytest.raises(SmwClientError, match="leer"):
_normalize_mw_category(" Kategorie: ")

View File

@ -0,0 +1,49 @@
"""Tests: Wiki → Skill Mapping (KarateRelevanz, RelevanzLevel)."""
from smw_mapper import (
map_wiki_to_skill,
parse_wiki_relevance_level,
)
def test_parse_wiki_relevance_level_accepted_range():
assert parse_wiki_relevance_level("1") == 1
assert parse_wiki_relevance_level("3") == 3
assert parse_wiki_relevance_level("Level_2_-_foo") == 2
def test_parse_wiki_relevance_level_rejects():
assert parse_wiki_relevance_level("") is None
assert parse_wiki_relevance_level("4") is None
assert parse_wiki_relevance_level("0") is None
assert parse_wiki_relevance_level("text") is None
def test_map_wiki_to_skill_karate_and_level():
out = map_wiki_to_skill(
"Test_Skill",
99,
{
"Summary": ["Kurztext"],
"KarateRelevanz": ["''Wichtig'' für [[Kumite]]"],
"RelevanzLevel": ["2"],
},
)
assert out["name"] == "Test_Skill"
assert out["description"] == "Kurztext"
assert "Kumite" in out["karate_relevance"]
assert out["relevance_level"] == 2
assert out["warnings"] == []
def test_map_wiki_to_skill_synonym_props():
out = map_wiki_to_skill("S", None, {"relevanzlevel": ["3"], "karaterelevanz": ["Nur Karate"]})
assert out["relevance_level"] == 3
assert out["karate_relevance"] == "Nur Karate"
def test_map_wiki_to_skill_invalid_level_warning():
out = map_wiki_to_skill("S", None, {"RelevanzLevel": ["9"]})
assert "relevance_level" not in out
assert len(out["warnings"]) == 1
assert "9" in out["warnings"][0]

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.141"
BUILD_DATE = "2026-05-14"
DB_SCHEMA_VERSION = "20260515064"
APP_VERSION = "0.8.145"
BUILD_DATE = "2026-05-16"
DB_SCHEMA_VERSION = "20260516065"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@ -19,15 +19,15 @@ MODULE_VERSIONS = {
"media_legal_hold": "1.0.0", # P-11: Sofortsperre-Services (set_legal_hold, release_legal_hold)
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
"groups": "0.1.0",
"skills": "0.1.0",
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
"methods": "0.1.0",
"exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break
"training_units": "0.3.0", # GET /api/training-units Keyset cursor_planned_date + cursor_id (+ optional cursor_planned_time); Sort mit id-Tiebreak
"training_programs": "0.1.0",
"planning": "0.12.0", # Trainingsvorlagen: Phasen/Streams in template_sections (064); Instantiate über _replace_unit_phases
"planning": "0.13.0", # Vorlagen/Framework/Module/Graphs: RBAC wie Übungen (edit/delete/governance transition); Planungs-UI Sichtbarkeit neue Vorlage
"dashboard": "1.1.0", # GET /api/dashboard/kpis inkl. training_home (ein Client-Roundtrip für KPIs + nächste Termine)
"training_modules": "1.0.0",
"import_wiki": "1.0.0",
"training_modules": "1.1.0", # PUT/DELETE: assert_library_content_* (Vereinsadmin löscht Vereins-Inhalt, Trainer bearbeitet club wie Übungen)
"import_wiki": "1.0.3", # Default-Kategorie Fähigkeiten: Fähigkeitsbeschreibung; cmtitle-Normalisierung; UI Preview/Execute Defaults je Typ
"admin": "1.0.0",
"membership": "1.0.0",
"catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012)
@ -36,6 +36,36 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.145",
"date": "2026-05-16",
"changes": [
"Wiki-Import: Standard-Kategorie Fähigkeiten korrigiert auf „Fähigkeitsbeschreibung“ (korrekter Wiki-Name).",
],
},
{
"version": "0.8.144",
"date": "2026-05-16",
"changes": [
"Wiki-Import: Default-Kategorie Fähigkeiten; SMW-Client entfernt versehentliches „Kategorie:“-Prefix; Vorschau/Ausführung nutzen Default-Kategorie je import_type wenn leer.",
"Admin UI Wiki-Import: Wechsel Import-Typ setzt passende Standard-Kategorie.",
],
},
{
"version": "0.8.143",
"date": "2026-05-16",
"changes": [
"DB 065: skills.karate_relevance + skills.relevance_level (Wiki KarateRelevanz / RelevanzLevel 13); smw_mapper + Wiki-Import upsert.",
],
},
{
"version": "0.8.142",
"date": "2026-05-16",
"changes": [
"Bibliothek Planung: Bearbeiten/Löschen von Vorlagen, Rahmenprogrammen, Trainingsmodulen, Progressionsgraphen nach RBAC wie Übungen (club_tenancy.assert_library_content_* + Governance-Übergänge)",
"Planungs-UI: Sichtbarkeit/Verein beim Speichern neuer Trainingsvorlage",
],
},
{
"version": "0.8.141",
"date": "2026-05-14",

View File

@ -3342,6 +3342,12 @@ a.analysis-split__nav-item {
.admin-matrix-skill-list__name {
font-size: 15px;
}
.admin-matrix-skill-list__buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.admin-matrix-skill-list__path {
font-size: 12px;
margin-top: 4px;
@ -3388,6 +3394,16 @@ a.analysis-split__nav-item {
margin-top: 6px;
line-height: 1.35;
}
.admin-matrix-matrix__skill-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.admin-matrix-matrix__skill-head .btn-tiny {
flex-shrink: 0;
margin-top: 2px;
}
.admin-matrix-matrix__cell {
vertical-align: top;
padding: 6px;

View File

@ -25,6 +25,13 @@ export default function MaturityModelsAdminPanel() {
const [levelsForm, setLevelsForm] = useState([])
const [cellDraft, setCellDraft] = useState({})
const [skillToAdd, setSkillToAdd] = useState('')
/** Modal: Stammdaten Fähigkeit (Wiki-Felder) aus Matrix-Kontext */
const [skillWikiModal, setSkillWikiModal] = useState(null)
const [skillWikiForm, setSkillWikiForm] = useState({
karate_relevance: '',
relevance_level: ''
})
const [skillWikiLoading, setSkillWikiLoading] = useState(false)
useEffect(() => {
let cancelled = false
@ -219,6 +226,57 @@ export default function MaturityModelsAdminPanel() {
}
}
async function openSkillWikiModal(skillId, skillName) {
setSkillWikiModal({ skill_id: skillId, skill_name: skillName })
setSkillWikiLoading(true)
setSkillWikiForm({ karate_relevance: '', relevance_level: '' })
try {
const s = await api.getSkill(skillId)
setSkillWikiForm({
karate_relevance: s.karate_relevance || '',
relevance_level:
s.relevance_level != null && s.relevance_level !== ''
? String(s.relevance_level)
: ''
})
} catch (e) {
setError(e.message || String(e))
setSkillWikiModal(null)
} finally {
setSkillWikiLoading(false)
}
}
function closeSkillWikiModal() {
setSkillWikiModal(null)
}
async function handleSaveSkillWikiFields(e) {
e.preventDefault()
if (!skillWikiModal) return
setError('')
setSkillWikiLoading(true)
try {
await api.updateSkill(skillWikiModal.skill_id, {
karate_relevance:
skillWikiForm.karate_relevance && skillWikiForm.karate_relevance.trim()
? skillWikiForm.karate_relevance.trim()
: null,
relevance_level:
skillWikiForm.relevance_level === '' || skillWikiForm.relevance_level == null
? null
: Number(skillWikiForm.relevance_level)
})
const sk = await api.listSkills({ status: 'active' })
setAllSkills(sk)
closeSkillWikiModal()
} catch (e) {
setError(e.message || String(e))
} finally {
setSkillWikiLoading(false)
}
}
async function handleDeleteModel() {
if (!selectedId || !isSuperadmin) return
if (!confirm('Reifegradmodell dauerhaft löschen?')) return
@ -552,13 +610,24 @@ export default function MaturityModelsAdminPanel() {
<li key={ms.skill_id} className="admin-matrix-skill-list__item">
<div className="admin-matrix-skill-list__row">
<strong className="admin-matrix-skill-list__name">{ms.skill_name}</strong>
<button
type="button"
className="btn btn-secondary btn-small"
onClick={() => handleRemoveSkill(ms.skill_id)}
>
Entfernen
</button>
<span className="admin-matrix-skill-list__buttons">
<button
type="button"
className="btn btn-secondary btn-small"
disabled={saving || skillWikiLoading}
title="Karate-Relevanz und Relevanzgrad bearbeiten"
onClick={() => openSkillWikiModal(ms.skill_id, ms.skill_name)}
>
Wiki-Felder
</button>
<button
type="button"
className="btn btn-secondary btn-small"
onClick={() => handleRemoveSkill(ms.skill_id)}
>
Entfernen
</button>
</span>
</div>
{(ms.skill_main_category_name || ms.skill_subcategory_name) ? (
<div className="admin-matrix-skill-list__path muted">
@ -595,13 +664,26 @@ export default function MaturityModelsAdminPanel() {
{(detail.model_skills || []).map((ms) => (
<tr key={ms.skill_id}>
<td className="admin-matrix-matrix__skill-cell">
<div>{ms.skill_name}</div>
{(ms.skill_main_category_name || ms.skill_subcategory_name) ? (
<div className="admin-matrix-matrix__skill-path muted">
{ms.skill_main_category_name || '—'}
{ms.skill_subcategory_name ? ` ${ms.skill_subcategory_name}` : ''}
<div className="admin-matrix-matrix__skill-head">
<div>
<div>{ms.skill_name}</div>
{(ms.skill_main_category_name || ms.skill_subcategory_name) ? (
<div className="admin-matrix-matrix__skill-path muted">
{ms.skill_main_category_name || '—'}
{ms.skill_subcategory_name ? ` ${ms.skill_subcategory_name}` : ''}
</div>
) : null}
</div>
) : null}
<button
type="button"
className="btn btn-ghost btn-tiny"
title="Karate-Relevanz und Relevanzgrad bearbeiten"
disabled={saving || skillWikiLoading}
onClick={() => openSkillWikiModal(ms.skill_id, ms.skill_name)}
>
</button>
</div>
</td>
{(detail.levels || []).map((l) => {
const key = `${ms.skill_id}-${l.level_number}`
@ -643,6 +725,90 @@ export default function MaturityModelsAdminPanel() {
)}
</div>
</div>
{skillWikiModal ? (
<div
className="admin-modal-backdrop"
role="presentation"
onClick={closeSkillWikiModal}
>
<div
className="admin-modal-sheet"
role="dialog"
aria-modal="true"
aria-labelledby="matrix-skill-wiki-title"
onClick={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
<h3 id="matrix-skill-wiki-title" className="admin-modal-sheet__title">
Fähigkeit: {skillWikiModal.skill_name}
</h3>
<button
type="button"
className="btn btn-secondary admin-modal-sheet__close"
disabled={skillWikiLoading}
onClick={closeSkillWikiModal}
aria-label="Schließen"
>
</button>
</div>
<div className="admin-modal-sheet__body">
{skillWikiLoading ? (
<p className="muted">Lade Daten</p>
) : (
<form className="skills-catalog-form" onSubmit={handleSaveSkillWikiFields}>
<label className="form-label">Karate-Relevanz (Wiki)</label>
<textarea
className="form-input"
rows={4}
value={skillWikiForm.karate_relevance}
onChange={(e) =>
setSkillWikiForm((f) => ({ ...f, karate_relevance: e.target.value }))
}
disabled={skillWikiLoading}
placeholder="Freitext (Wiki-Eigenschaft KarateRelevanz)"
/>
<label className="form-label">Relevanzgrad (Wiki, 13)</label>
<select
className="form-input"
value={
skillWikiForm.relevance_level === ''
? ''
: String(skillWikiForm.relevance_level)
}
onChange={(e) =>
setSkillWikiForm((f) => ({ ...f, relevance_level: e.target.value }))
}
disabled={skillWikiLoading}
>
<option value=""> nicht gesetzt </option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
</select>
<p className="muted admin-matrix-hint">
Änderungen gelten für die Fähigkeit im gesamten Katalog (gemeinsame Stammdaten).
</p>
<div className="skills-catalog-form__actions">
<button type="submit" className="btn btn-primary" disabled={skillWikiLoading}>
Speichern
</button>
<button
type="button"
className="btn btn-secondary"
disabled={skillWikiLoading}
onClick={closeSkillWikiModal}
>
Abbrechen
</button>
</div>
</form>
)}
</div>
</div>
</div>
) : null}
</div>
)
}

View File

@ -71,7 +71,9 @@ export default function SkillsCatalogAdmin() {
keywords: '',
status: 'active',
sort_order: '',
category_id: ''
category_id: '',
karate_relevance: '',
relevance_level: ''
})
const [newMainName, setNewMainName] = useState('')
@ -176,7 +178,12 @@ export default function SkillsCatalogAdmin() {
keywords: s.keywords || '',
status: s.status || 'active',
sort_order: s.sort_order ?? '',
category_id: s.category_id ?? ''
category_id: s.category_id ?? '',
karate_relevance: s.karate_relevance || '',
relevance_level:
s.relevance_level != null && s.relevance_level !== ''
? String(s.relevance_level)
: ''
})
setEditDialog({ type: 'skill', id: s.id })
}
@ -304,7 +311,15 @@ export default function SkillsCatalogAdmin() {
skillForm.sort_order === '' || skillForm.sort_order == null
? null
: Number(skillForm.sort_order),
category_id: cid
category_id: cid,
karate_relevance:
typeof skillForm.karate_relevance === 'string' && skillForm.karate_relevance.trim()
? skillForm.karate_relevance.trim()
: null,
relevance_level:
skillForm.relevance_level === '' || skillForm.relevance_level == null
? null
: Number(skillForm.relevance_level)
})
})
if (ok) {
@ -922,6 +937,31 @@ export default function SkillsCatalogAdmin() {
onChange={(e) => setSkillForm((f) => ({ ...f, description: e.target.value }))}
disabled={busy}
/>
<label className="form-label">Karate-Relevanz (Wiki)</label>
<textarea
className="form-input"
rows={3}
value={skillForm.karate_relevance}
onChange={(e) =>
setSkillForm((f) => ({ ...f, karate_relevance: e.target.value }))
}
disabled={busy}
placeholder="Freitext aus Wiki / eigene Erläuterung"
/>
<label className="form-label">Relevanzgrad (Wiki, 13)</label>
<select
className="form-input"
value={skillForm.relevance_level === '' ? '' : String(skillForm.relevance_level)}
onChange={(e) =>
setSkillForm((f) => ({ ...f, relevance_level: e.target.value }))
}
disabled={busy}
>
<option value=""> nicht gesetzt </option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
</select>
<label className="form-label">Sortierung (optional)</label>
<input
className="form-input"

View File

@ -643,12 +643,31 @@ function TrainingPlanningPageRoot() {
})
}, [user?.id, loading, searchParams, handleEdit, setSearchParams])
const handleSaveAsTemplate = async () => {
const handleSaveAsTemplate = async (opts = {}) => {
const name = window.prompt('Name für die neue Trainingsvorlage (nur Abschnitts-Gliederung):')
if (!name?.trim()) return
const visibility =
typeof opts.visibility === 'string' && opts.visibility.trim()
? String(opts.visibility).trim().toLowerCase()
: 'private'
let club_id = opts.club_id != null && opts.club_id !== '' ? Number(opts.club_id) : null
if (visibility === 'club') {
if (!Number.isFinite(club_id) || club_id < 1) {
const fb = planningModalClubId != null ? Number(planningModalClubId) : NaN
if (Number.isFinite(fb) && fb >= 1) club_id = fb
}
if (!Number.isFinite(club_id) || club_id < 1) {
toast.error('Bitte einen Verein wählen (Sichtbarkeit „Verein“).')
return
}
} else {
club_id = null
}
try {
await api.createTrainingPlanTemplate({
name: name.trim(),
visibility,
club_id: visibility === 'club' ? club_id : null,
sections: templateSectionsPayloadFromFormSections(formData.sections),
})
await loadPlanTemplates()

View File

@ -1,7 +1,9 @@
import React from 'react'
import React, { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import TrainingPlanExerciseVisibilityPanel from '../TrainingPlanExerciseVisibilityPanel'
import TrainingUnitSectionsEditor from '../TrainingUnitSectionsEditor'
import { activeClubMemberships } from '../../utils/activeClub'
import { canDeleteLibraryContent } from '../../utils/libraryContentPermissions'
import { frameworkLineageText } from '../../utils/trainingPlanningPageHelpers'
/**
@ -31,6 +33,22 @@ export default function TrainingPlanningUnitFormModal({
onRequestExercisePick,
onPeekExercise,
}) {
const [newTplVisibility, setNewTplVisibility] = useState('private')
const [newTplClubId, setNewTplClubId] = useState('')
const memberClubs = useMemo(() => activeClubMemberships(user?.clubs), [user?.clubs])
const roleLc = String(user?.role || '').toLowerCase()
const isSuperadmin = roleLc === 'superadmin'
useEffect(() => {
if (!open) return
if (planningModalClubId != null && planningModalClubId !== '') {
setNewTplClubId(String(planningModalClubId))
} else if (memberClubs.length === 1) {
setNewTplClubId(String(memberClubs[0].id))
}
}, [open, planningModalClubId, memberClubs])
if (!open) return null
return (
@ -116,12 +134,17 @@ export default function TrainingPlanningUnitFormModal({
onChange={(e) => onDraftTemplateSelect(e.target.value)}
>
<option value="">Ohne Vorlage leere Gliederung (ein Abschnitt)</option>
{planTemplates.map((t) => (
<option key={t.id} value={String(t.id)}>
{t.name}
{typeof t.sections_count === 'number' ? ` · ${t.sections_count} Abschn.` : ''}
</option>
))}
{planTemplates.map((t) => {
const v = String(t.visibility || 'club').toLowerCase()
const vLabel = v === 'private' ? 'Privat' : v === 'official' ? 'Offiziell' : 'Verein'
return (
<option key={t.id} value={String(t.id)}>
{t.name}
{typeof t.sections_count === 'number' ? ` · ${t.sections_count} Abschn.` : ''}{' '}
· {vLabel}
</option>
)
})}
</select>
<p className="training-planning-template-panel__help">
Übernimmt nur die <strong>Sektionsstruktur</strong> aus der Bibliothek; Übungen trägst du unten bei den
@ -144,17 +167,12 @@ export default function TrainingPlanningUnitFormModal({
Gespeicherte Vorlagen löschen
</summary>
<p style={{ margin: '0.65rem 0 0.75rem', fontSize: '0.82rem', color: 'var(--text2)', lineHeight: 1.45 }}>
Du kannst eigene Vorlagen entfernen. Plattform-Admins dürfen auch fremde Vorlagen löschen. Einheiten, die
noch auf eine Vorlage verweisen, behalten ihren Ablauf; die Verknüpfung zur Vorlage wird vom Server
entfernt.
Entfernen nach Rolle: eigene private Vorlagen; Vereins­inhalte als Vereins­admin; offizielle nur als
PlattformAdmin. Einheiten mit Verweis behalten den Ablauf; die Vorlage wird entkoppelt.
</p>
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{planTemplates.map((t, ti) => {
const roleLc = String(user?.role || '').toLowerCase()
const isPlatformAdmin = roleLc === 'admin' || roleLc === 'superadmin'
const canDel =
user &&
(isPlatformAdmin || Number(t.created_by) === Number(user.id))
const canDel = user && canDeleteLibraryContent(user, t)
return (
<li
key={t.id}
@ -169,6 +187,15 @@ export default function TrainingPlanningUnitFormModal({
>
<span style={{ minWidth: 0, flex: 1, fontSize: '0.9rem' }}>
<strong style={{ color: 'var(--text1)' }}>{t.name}</strong>
<span style={{ fontSize: '0.78rem', color: 'var(--text3)', marginLeft: '6px' }}>
(
{String(t.visibility || 'club').toLowerCase() === 'private'
? 'Privat'
: String(t.visibility || 'club').toLowerCase() === 'official'
? 'Offiziell'
: 'Verein'}
)
</span>
{typeof t.sections_count === 'number' ? (
<span style={{ fontSize: '0.82rem', color: 'var(--text2)', marginLeft: '6px' }}>
· {t.sections_count} Abschn.
@ -401,9 +428,71 @@ export default function TrainingPlanningUnitFormModal({
heading="Abschnitte & Übungen"
headingAccessory={
<>
<button type="button" className="btn btn-secondary" onClick={onSaveAsTemplate}>
Vorlage aus Aufbau speichern
</button>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'flex-end',
gap: '10px',
marginBottom: '10px',
}}
>
<div className="form-row" style={{ marginBottom: 0, minWidth: 'min(160px, 100%)' }}>
<label className="form-label" style={{ fontSize: '0.82rem' }}>
Neue Vorlage: Sichtbarkeit
</label>
<select
className="form-input"
value={newTplVisibility}
onChange={(e) => {
const v = e.target.value
setNewTplVisibility(v)
if (v === 'club' && !newTplClubId && planningModalClubId != null) {
setNewTplClubId(String(planningModalClubId))
}
}}
>
<option value="private">Privat (nur du)</option>
<option value="club">Verein</option>
{isSuperadmin ? <option value="official">Offiziell (global)</option> : null}
</select>
</div>
{newTplVisibility === 'club' ? (
<div className="form-row" style={{ marginBottom: 0, flex: '1 1 200px' }}>
<label className="form-label" style={{ fontSize: '0.82rem' }}>
Verein
</label>
<select
className="form-input"
value={newTplClubId}
onChange={(e) => setNewTplClubId(e.target.value)}
>
<option value=""> Verein wählen </option>
{memberClubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || `Verein #${c.id}`}
</option>
))}
</select>
</div>
) : null}
<button
type="button"
className="btn btn-secondary"
style={{ marginBottom: '2px' }}
onClick={() =>
onSaveAsTemplate?.({
visibility: newTplVisibility,
club_id:
newTplVisibility === 'club' && newTplClubId
? parseInt(newTplClubId, 10)
: null,
})
}
>
Vorlage aus Aufbau speichern
</button>
</div>
</>
}
sections={formData.sections}

View File

@ -10,6 +10,13 @@ const WIKI_IMPORT_TABS = [
{ id: 'history', label: 'Historie', icon: History },
]
/** MediaWiki-Kategorienamen ohne Präfix „Kategorie:“ — karatetrainer.net aktueller Stand */
function defaultWikiCategory(importType) {
if (importType === 'skill') return 'Fähigkeitsbeschreibung'
if (importType === 'method') return 'Methodenbeschreibung'
return 'Übungen'
}
export default function MediaWikiImportPage() {
const [activeTab, setActiveTab] = useState('preview')
const [loading, setLoading] = useState(false)
@ -169,7 +176,11 @@ export default function MediaWikiImportPage() {
</label>
<select
value={previewType}
onChange={(e) => setPreviewType(e.target.value)}
onChange={(e) => {
const t = e.target.value
setPreviewType(t)
setPreviewCategory(defaultWikiCategory(t))
}}
style={{
width: '100%',
padding: '12px',
@ -315,7 +326,11 @@ export default function MediaWikiImportPage() {
</label>
<select
value={executeType}
onChange={(e) => setExecuteType(e.target.value)}
onChange={(e) => {
const t = e.target.value
setExecuteType(t)
setExecuteCategory(defaultWikiCategory(t))
}}
style={{
width: '100%',
padding: '12px',

View File

@ -52,7 +52,9 @@ function SkillsPage() {
description: '',
importance: 3,
keywords: [],
status: 'active'
status: 'active',
karate_relevance: '',
relevance_level: ''
})
} else {
setFormData({
@ -102,10 +104,20 @@ function SkillsPage() {
try {
if (modalType === 'skill') {
const raw = { ...formData }
raw.karate_relevance =
typeof raw.karate_relevance === 'string' && raw.karate_relevance.trim()
? raw.karate_relevance.trim()
: null
raw.relevance_level =
raw.relevance_level === '' || raw.relevance_level == null || raw.relevance_level === undefined
? null
: Number(raw.relevance_level)
if (editing) {
await api.updateSkill(editing.id, formData)
await api.updateSkill(editing.id, raw)
} else {
await api.createSkill(formData)
await api.createSkill(raw)
}
} else {
if (editing) {
@ -394,6 +406,41 @@ function SkillsPage() {
/>
</div>
{modalType === 'skill' && (
<>
<div className="form-row">
<label className="form-label">Karate-Relevanz (Wiki)</label>
<textarea
className="form-input"
rows={3}
value={formData.karate_relevance || ''}
onChange={(e) => updateFormField('karate_relevance', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Relevanzgrad (Wiki, 13)</label>
<select
className="form-input"
value={
formData.relevance_level === null ||
formData.relevance_level === undefined ||
formData.relevance_level === ''
? ''
: String(formData.relevance_level)
}
onChange={(e) =>
updateFormField('relevance_level', e.target.value === '' ? '' : e.target.value)
}
>
<option value=""> nicht gesetzt </option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
</select>
</div>
</>
)}
{modalType === 'skill' && (
<div className="form-row">
<label className="form-label">Wichtigkeit (1-5)</label>

View File

@ -0,0 +1,28 @@
import { activeClubMemberships } from './activeClub'
export function clubAdminInClub(user, clubId) {
const cid = Number(clubId)
if (!Number.isFinite(cid)) return false
const row = activeClubMemberships(user?.clubs).find((c) => Number(c.id) === cid)
return Boolean(row?.roles?.includes('club_admin'))
}
/**
* Löschen von Bibliotheks-/Planungsinhalten (Vorlage, Modul, Rahmen, Graph) grob wie Backend club_tenancy.
* Vereins-Admins können fremde private Einträge im API löschen (gemeinsamer Verein); das blenden wir hier nicht ein.
*/
export function canDeleteLibraryContent(user, row) {
const grole = String(user?.role || '').toLowerCase()
if (grole === 'admin' || grole === 'superadmin') return true
const uid = Number(user?.id)
if (!Number.isFinite(uid)) return false
const vis = String(row?.visibility ?? 'club').toLowerCase()
const createdBy = row?.created_by != null ? Number(row.created_by) : null
const clubId = row?.club_id != null ? Number(row.club_id) : null
if (vis === 'official') return false
if (vis === 'club') return clubAdminInClub(user, clubId)
if (vis === 'private') return Number.isFinite(createdBy) && createdBy === uid
return false
}