diff --git a/.claude/docs/technical/AI_TRAINING_PLANNING_CONCEPT.md b/.claude/docs/technical/AI_TRAINING_PLANNING_CONCEPT.md new file mode 100644 index 0000000..372f92d --- /dev/null +++ b/.claude/docs/technical/AI_TRAINING_PLANNING_CONCEPT.md @@ -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“) | diff --git a/backend/club_tenancy.py b/backend/club_tenancy.py index 3a28f6d..e06341a 100644 --- a/backend/club_tenancy.py +++ b/backend/club_tenancy.py @@ -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, diff --git a/backend/migrations/065_skills_wiki_karate_relevance.sql b/backend/migrations/065_skills_wiki_karate_relevance.sql new file mode 100644 index 0000000..601e56e --- /dev/null +++ b/backend/migrations/065_skills_wiki_karate_relevance.sql @@ -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)'; diff --git a/backend/models.py b/backend/models.py index c2dc7b0..a60798e 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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 diff --git a/backend/routers/exercise_progression_graphs.py b/backend/routers/exercise_progression_graphs.py index 80dfdd1..212359f 100644 --- a/backend/routers/exercise_progression_graphs.py +++ b/backend/routers/exercise_progression_graphs.py @@ -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} diff --git a/backend/routers/import_wiki.py b/backend/routers/import_wiki.py index 3a1a2fd..4dbb918 100644 --- a/backend/routers/import_wiki.py +++ b/backend/routers/import_wiki.py @@ -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 diff --git a/backend/routers/skills.py b/backend/routers/skills.py index acc4c66..e3ea747 100644 --- a/backend/routers/skills.py +++ b/backend/routers/skills.py @@ -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") diff --git a/backend/routers/training_framework_programs.py b/backend/routers/training_framework_programs.py index 2b7e11c..afa338a 100644 --- a/backend/routers/training_framework_programs.py +++ b/backend/routers/training_framework_programs.py @@ -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,), diff --git a/backend/routers/training_modules.py b/backend/routers/training_modules.py index 1fb40b3..84fdf07 100644 --- a/backend/routers/training_modules.py +++ b/backend/routers/training_modules.py @@ -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} diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index af32f85..e0f215a 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -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} diff --git a/backend/smw_client.py b/backend/smw_client.py index 0409bdb..1412d24 100644 --- a/backend/smw_client.py +++ b/backend/smw_client.py @@ -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", diff --git a/backend/smw_mapper.py b/backend/smw_mapper.py index 9115061..970713e 100644 --- a/backend/smw_mapper.py +++ b/backend/smw_mapper.py @@ -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 diff --git a/backend/tests/test_import_wiki_category_default.py b/backend/tests/test_import_wiki_category_default.py new file mode 100644 index 0000000..aa4cec3 --- /dev/null +++ b/backend/tests/test_import_wiki_category_default.py @@ -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 diff --git a/backend/tests/test_smw_client_category_normalize.py b/backend/tests/test_smw_client_category_normalize.py new file mode 100644 index 0000000..1abb1bb --- /dev/null +++ b/backend/tests/test_smw_client_category_normalize.py @@ -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: ") + diff --git a/backend/tests/test_smw_mapper_skill_wiki_fields.py b/backend/tests/test_smw_mapper_skill_wiki_fields.py new file mode 100644 index 0000000..94d989b --- /dev/null +++ b/backend/tests/test_smw_mapper_skill_wiki_fields.py @@ -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] diff --git a/backend/version.py b/backend/version.py index 1c29c8d..a3c7b7d 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", diff --git a/frontend/src/app.css b/frontend/src/app.css index a53ce54..a3d4378 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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; diff --git a/frontend/src/components/admin/MaturityModelsAdminPanel.jsx b/frontend/src/components/admin/MaturityModelsAdminPanel.jsx index e9e2de4..dba4e4c 100644 --- a/frontend/src/components/admin/MaturityModelsAdminPanel.jsx +++ b/frontend/src/components/admin/MaturityModelsAdminPanel.jsx @@ -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() {
  • {ms.skill_name} - + + + +
    {(ms.skill_main_category_name || ms.skill_subcategory_name) ? (
    @@ -595,13 +664,26 @@ export default function MaturityModelsAdminPanel() { {(detail.model_skills || []).map((ms) => ( -
    {ms.skill_name}
    - {(ms.skill_main_category_name || ms.skill_subcategory_name) ? ( -
    - {ms.skill_main_category_name || '—'} - {ms.skill_subcategory_name ? ` › ${ms.skill_subcategory_name}` : ''} +
    +
    +
    {ms.skill_name}
    + {(ms.skill_main_category_name || ms.skill_subcategory_name) ? ( +
    + {ms.skill_main_category_name || '—'} + {ms.skill_subcategory_name ? ` › ${ms.skill_subcategory_name}` : ''} +
    + ) : null}
    - ) : null} + +
    {(detail.levels || []).map((l) => { const key = `${ms.skill_id}-${l.level_number}` @@ -643,6 +725,90 @@ export default function MaturityModelsAdminPanel() { )}
    + + {skillWikiModal ? ( +
    +
    e.stopPropagation()} + > +
    +

    + Fähigkeit: {skillWikiModal.skill_name} +

    + +
    +
    + {skillWikiLoading ? ( +

    Lade Daten…

    + ) : ( +
    + +