Fähigkeitsimport verbessert #37
178
.claude/docs/technical/AI_TRAINING_PLANNING_CONCEPT.md
Normal file
178
.claude/docs/technical/AI_TRAINING_PLANNING_CONCEPT.md
Normal 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-Top‑K** | z. B. 30–120 Übungen, **je Zeile gekürzt** (Titel, `summary`, 2–5 Skill-Namen/Stufen); **nie** der gesamte Katalog |
|
||||
| **Strukturierte Kanten optional** | Kleine Mengen Kanten aus Progressionsgraph: „Nachbarn von zuletzt genutzten Übungen“ |
|
||||
|
||||
**Zahlen‑Richtwerte (überarbeitungsfähig):**
|
||||
Kandidaten **vor** dem LLM typischerweise **50–150** Einträge; im Prompt durch Token-Limit weiter **truncate** oder **zweistufig** (grober Ranking-Schritt ohne LLM, dann finer mit LLM auf Top‑40).
|
||||
|
||||
---
|
||||
|
||||
## 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 (**CURR‑013**).
|
||||
- **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: **„Anker‑Menge“** + **„Graph‑Nachbarschaft“** → 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, **Top‑K** 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 Top‑K – 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 **Top‑K 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 10 k–100 k 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** + Top‑K; 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 Top‑K“**, unabhängig vom Speicherort der Vektoren.
|
||||
|
||||
---
|
||||
|
||||
## 6. Datenpflege für gutes Retrieval (fachlicher Hebel)
|
||||
|
||||
Retrieval‑Qualitä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 1–3 + Prompt mit Top‑K |
|
||||
| C | Reihenfolge / Zeitslots innerhalb bestehender Sektion | Graph + LLM Ranking |
|
||||
| D | Ganze Einheit (inkl. Phasen/Streams vereinfacht) | strukturiertere JSON-Ausgabe, strikte Schema-Validation |
|
||||
| E | Mehreinheiten / Rahmen‑Alignment | Ziele aus Rahmenprogramm, Serie von Slots |
|
||||
|
||||
Die **Selektions‑Pipeline §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 |
|
||||
|---------|-----------|
|
||||
| **Top‑K** | 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“) |
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
11
backend/migrations/065_skills_wiki_karate_relevance.sql
Normal file
11
backend/migrations/065_skills_wiki_karate_relevance.sql
Normal 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 1–3 (Semantic MediaWiki)';
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""
|
||||
Progressionsgraph zwischen Übungen (Übung → Übung), Migration 032–034.
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
Trainingsmodule — wiederverwendbare Planungsbausteine (Bibliothek).
|
||||
|
||||
Governance wie Trainings‑Mikrovorlagen (`training_plan_templates`):
|
||||
Liste/Detail über `library_content_visibility_sql`; Schreiben: Ersteller oder Plattform‑Admin.
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 1–3
|
||||
}
|
||||
|
||||
# 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 1–3). 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
|
||||
|
||||
|
|
|
|||
18
backend/tests/test_import_wiki_category_default.py
Normal file
18
backend/tests/test_import_wiki_category_default.py
Normal 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
|
||||
21
backend/tests/test_smw_client_category_normalize.py
Normal file
21
backend/tests/test_smw_client_category_normalize.py
Normal 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: ")
|
||||
|
||||
49
backend/tests/test_smw_mapper_skill_wiki_fields.py
Normal file
49
backend/tests/test_smw_mapper_skill_wiki_fields.py
Normal 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]
|
||||
|
|
@ -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 1–3); 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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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, 1–3)</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, 1–3)</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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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; Vereinsinhalte als Vereinsadmin; offizielle nur als
|
||||
Plattform‑Admin. 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}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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, 1–3)</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>
|
||||
|
|
|
|||
28
frontend/src/utils/libraryContentPermissions.js
Normal file
28
frontend/src/utils/libraryContentPermissions.js
Normal 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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user