From bc1790bd82cc9dc34792094f72826d400eab5039 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 16 May 2026 09:04:09 +0200 Subject: [PATCH 1/4] Refactor section movement logic in TrainingUnitSectionsEditor - Streamlined the section movement process by consolidating validation checks and enhancing the handling of parallel phase indices. - Improved the overall clarity and efficiency of the section management functionality, ensuring a smoother user experience during edits. --- .../technical/AI_TRAINING_PLANNING_CONCEPT.md | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 .claude/docs/technical/AI_TRAINING_PLANNING_CONCEPT.md 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“) | From 0275f76432f11be8f335d232b638f2dd017ca5f0 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 16 May 2026 10:53:00 +0200 Subject: [PATCH 2/4] Implement RBAC for library content management in club tenancy - Introduced new functions for managing edit, delete, and governance transition permissions for library content, aligning with role-based access control (RBAC) principles. - Updated existing routers to utilize these new functions, ensuring consistent permission checks across training frameworks, modules, and progression graphs. - Enhanced visibility and governance handling for training plan templates and library content, improving overall content management and user experience. - Incremented app version to 0.8.142 and updated changelog to reflect these changes. --- backend/club_tenancy.py | 161 +++++++++++++++++- .../routers/exercise_progression_graphs.py | 19 +-- .../routers/training_framework_programs.py | 21 +-- backend/routers/training_modules.py | 22 +-- backend/routers/training_planning.py | 21 +-- backend/version.py | 14 +- .../planning/TrainingPlanningPageRoot.jsx | 21 ++- .../TrainingPlanningUnitFormModal.jsx | 125 ++++++++++++-- .../src/utils/libraryContentPermissions.js | 28 +++ 9 files changed, 368 insertions(+), 64 deletions(-) create mode 100644 frontend/src/utils/libraryContentPermissions.js 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/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/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/version.py b/backend/version.py index 1c29c8d..6e94c70 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.141" +APP_VERSION = "0.8.142" BUILD_DATE = "2026-05-14" DB_SCHEMA_VERSION = "20260515064" @@ -24,9 +24,9 @@ MODULE_VERSIONS = { "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", + "training_modules": "1.1.0", # PUT/DELETE: assert_library_content_* (Vereinsadmin löscht Vereins-Inhalt, Trainer bearbeitet club wie Übungen) "import_wiki": "1.0.0", "admin": "1.0.0", "membership": "1.0.0", @@ -36,6 +36,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "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/components/planning/TrainingPlanningPageRoot.jsx b/frontend/src/components/planning/TrainingPlanningPageRoot.jsx index 5a2680e..e72c214 100644 --- a/frontend/src/components/planning/TrainingPlanningPageRoot.jsx +++ b/frontend/src/components/planning/TrainingPlanningPageRoot.jsx @@ -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() diff --git a/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx b/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx index f119029..7136320 100644 --- a/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx +++ b/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx @@ -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)} > - {planTemplates.map((t) => ( - - ))} + {planTemplates.map((t) => { + const v = String(t.visibility || 'club').toLowerCase() + const vLabel = v === 'private' ? 'Privat' : v === 'official' ? 'Offiziell' : 'Verein' + return ( + + ) + })}

Übernimmt nur die Sektionsstruktur aus der Bibliothek; Übungen trägst du unten bei den @@ -144,17 +167,12 @@ export default function TrainingPlanningUnitFormModal({ Gespeicherte Vorlagen löschen

- Du kannst eigene Vorlagen entfernen. Plattform-Admins dürfen auch fremde Vorlagen löschen. Einheiten, die - noch auf eine Vorlage verweisen, behalten ihren Ablauf; die Verknüpfung zur Vorlage wird vom Server - entfernt. + Entfernen nach Rolle: eigene private Vorlagen; Vereins­inhalte als Vereins­admin; offizielle nur als + Plattform‑Admin. Einheiten mit Verweis behalten den Ablauf; die Vorlage wird entkoppelt.