feat: update application version to 0.8.37 and enhance training planning features
Some checks failed
Deploy Development / deploy (push) Failing after 14s
Test Suite / pytest-backend (push) Successful in 5s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Failing after 2s
Test Suite / playwright-tests (push) Successful in 23s

- Bumped application version to 0.8.37 in both backend and frontend files.
- Updated training planning API to include new session assignment features, allowing for lead trainer and assistant trainer assignments.
- Enhanced the TrainingPlanningPage to support dynamic loading of club member directories based on selected groups.
- Improved validation for trainer assignments, ensuring only active club members can be assigned as trainers.
- Updated changelog to reflect the new version and changes made in this release.
This commit is contained in:
Lars 2026-05-05 23:35:41 +02:00
parent 14745b347d
commit c778d21b26
10 changed files with 597 additions and 91 deletions

View File

@ -1,7 +1,7 @@
# Einheitliche Zugriffsschicht & Governance Umsetzungsplan
**Status:** verbindliche Umsetzungsreihenfolge (nachgelagert zum Zielbild in `MULTI_TENANCY_RBAC_ARCHITECTURE.md`)
**Stand:** 2026-05-05
**Stand:** 2026-05-06
**Zweck:** Drift vermeiden eine nachvollziehbare Schicht für Mandanten-Kontext, Sichtbarkeit und Berechtigungen, auf die alle inhaltsbezogenen Module konsistent aufbauen.
**Explizit zurückgestellt (wie vereinbart):** kostenpflichtiges Vereins-Membership / Tier-Limits pro Verein (`club_subscriptions` o. Ä.) kommt nach stabiler Zugriffs- und Datenisolationsbasis.
@ -101,7 +101,7 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
| **PR-Checkliste** | Neuer/changed Endpoint: TenantContext verwendet? Governance für Listen + Detail? Tests für zweiten Verein? |
| **Single Source of Truth** | Sichtbarkeitsregeln nur in Zugriffsmodul(en), nicht in Routers dupliziert. |
| **Änderungen am Enum** | Nur zusammen mit Migration + Kurzbeschreibung in diesem Dokument (Datum/Changelog-Zeile). |
| **Beziehung zu MULTI_TENANCY-Doc** | Phasen 14 dort größtenteils umgesetzt; **Gap-Analyse §3** im alten Dokument historisch lesen fachlicher Zielabgleich bleibt dort, **operative Reihenfolge** hier. |
| **Beziehung zu MULTI_TENANCY-Doc** | Zielbild und Gap-Analyse §3 dort pflegen (**§3.0** = aktueller Umsetzungsstand); **operative Reihenfolge** hier. |
---
@ -109,7 +109,7 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
1. **TenantContext-Spezifikation** (ein Abschnitt in diesem Dokument oder Kurz-ADR): Request-Lebenszyklus, Fehlerbilder, Superadmin.
2. **Endpoint-Audit-Tabelle** (Working-Dokument, bei jedem Merge pflegen bis Stufe C abgeschlossen).
3. **Testplan „Cross-Tenant“** (manuell oder pytest): Minimalsetup zweier Vereine — erste rein-funktionale Tests unter `backend/tests/test_access_layer.py` (ohne DB); Integration folgt.
3. **Testplan „Cross-Tenant“** (manuell oder pytest): Minimalsetup zweier Vereine — Unit-Tests `backend/tests/test_access_layer.py`; Integration `backend/tests/test_access_layer_integration.py` bei `ACCESS_LAYER_INTEGRATION=1` / CI im Backend-Container.
**Audit-Tabelle (fortlaufend):** `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md`
@ -122,4 +122,4 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
---
**Letzte Aktualisierung:** 2026-05-05
**Letzte Aktualisierung:** 2026-05-06

View File

@ -1,7 +1,7 @@
# Multi-Tenancy, Vereins-Membership und Rollenmodell Zielarchitektur & Umsetzungsplan
**Status:** verbindliche Zielrichtung (Architekturpapier)
**Stand:** 2026-05-05
**Stand:** 2026-05-06
**Bezug:** `functional/shinkan_anforderungsdokument_entwurf.md` §5, §1718 · `functional/DOMAIN_MODEL.md` (Sichtbarkeit §5.5) · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR-004008)
**Operative Reihenfolge & einheitliche Zugriffsschicht:** [`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`](./ACCESS_LAYER_AND_GOVERNANCE_PLAN.md) dort sind Stufen AF, Drift-Prävention und die Zurückstellung von Vereinsabo/Limits festgehalten; dieses Dokument bleibt das übergeordnete **Zielbild**.
@ -20,60 +20,68 @@ Dieses Dokument fasst den **Soll-Zustand** für Mandantenfähigkeit (Verein = Ma
|--------|-----------------------------------|-------------------------|
| `shinkan_anforderungsdokument_entwurf.md` §5.4 | Rollen: Superadmin, Vereinsadmin, Trainer, Co-Trainer, Redakteur | Deckt sich; „Superadmin“ entspricht fachlich **Systemadmin** |
| §17.1 | Erweiterung: Systemadmin, Spartenadmin | Entspricht den gewünschten **Spartenverantwortlichen** |
| §5.5 / §17 | Sichtbarkeit: privat, Verein, Sparte, global, offiziell | DOMAIN_MODEL listet ähnliche Stufen; **technische Durchsetzung** ist noch lückenhaft |
| §5.5 / §17 | Sichtbarkeit: privat, Verein, Sparte, global, offiziell | DOMAIN_MODEL listet ähnliche Stufen; Bibliothek **`private` \| `club` \| `official`** technisch über Zugriffsschicht durchgesetzt; **Sparte/community** folgt |
| §18.5 | MVP: Datenmodell mandantenfähig, Rechte zunächst einfach | Bestätigt schrittweise Verschärfung |
| `DOMAIN_MODEL.md` §5.5 | Freigabeebenen inkl. Sparte | Zielbild; DB/API nutzen derzeit überwiegend `private` \| `club` \| `official` |
| `TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` | CURR-004008: Governance-Kern, spätere Policy | Datenfelder vorbereitet; **Policy/Erzwingung** folgt |
| `working/SHINKAN_PROJECT_SETUP.md` §6 | „Multi-Tenant-Administration“ ausgeschlossen (MVP-Liste) | Historisch; **technische Mandanten** sind dennoch Ziel UI-Komplexität kontrolliert einführen |
**Fazit:** Die fachlichen Rollen und Sichtbarkeitsebenen sind **in den funktionalen Docs bereits skizziert**. Es fehlt die **stringente technische Schicht**: Vereinszugehörigkeit, aktiver Vereinskontext, effektive Berechtigungen pro Anfrage und konsequente Filterung bei `club`-sichtbaren Objekten.
**Fazit:** Die fachlichen Rollen und Sichtbarkeitsebenen sind **in den funktionalen Docs skizziert**. Für die **Kernteile Bibliothek und Vereinskontext** ist eine **technische Zugriffsschicht** (`TenantContext`, `club_members`, einheitliche Sichtbarkeits-SQL/-Prüfungen) umgesetzt — Details und Restarbeit (**Sparte**, Konsolidierung der Hilfen, Planungs-/Admin-Flows) siehe §3 und `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`.
---
## 3. Ist-Stand im Code (Gap-Analyse)
> **Hinweis:** Dieser Abschnitt beschreibt den Ausgangspunkt vor Ausbauschritten (**Mitgliedschaften, gefilterte Vereinsliste, Teilen von Governance für Übungen/Rahmen/Planung** sind bereits angegangen). Verbindliche **offene Arbeit und Reihenfolge** sind im Dokument [`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`](./ACCESS_LAYER_AND_GOVERNANCE_PLAN.md) festgehalten.
> **Hinweis:** Die Unterabschnitte **3.13.6** enthalten weiterhin **historische Problemstellungen** (Ausgangsbild). Ergänzend beschreibt **3.0** den **aktualisierten Umsetzungsstand** nach Mitgliedschafts-, Tenant- und Bibliotheksarbeit. Verbindliche **offene Arbeit und Reihenfolge:** [`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`](./ACCESS_LAYER_AND_GOVERNANCE_PLAN.md).
### 3.0 Aktualisierung des Umsetzungsstands (kurz)
- **Mitgliedschaft:** Tabellen `club_members` und `club_member_roles`; aktiver Verein über Profilfeld und Header `X-Active-Club-Id`; Auflösung in **`TenantContext`** (`tenant_context.py`).
- **Bibliothek** (Übungen, Trainingsplan-Vorlagen, Rahmenprogramme u.a.): gemeinsame Leselogik **`library_content_visibility_sql`** / **`library_content_visible_to_profile`** — Vereinsinhalte **`club`** nur bei passendem **`club_id`** und **aktiver Mitgliedschaft** im Objekt-Verein (normale Nutzer ohne gültigen Vereinskontext: kein „beliebiges club“).
- **`GET /api/clubs`:** Nicht-Admins sehen nur Vereine mit Mitgliedschaft; **`POST /api/clubs`:** nur **Plattform-Admin**, mit Pflicht **`primary_admin_profile_id`**.
- **Organisation** (Sparten/Gruppen): Schreibzugriff über **`can_manage_club_org`** / **`can_plan_in_club`** auf Basis von **`club_member_roles`** (nicht mehr nur globales `admin`).
- **Profil-API:** eingeschränktes **`GET /profiles/{id}`**, **`DELETE`**, **`POST /profiles`** (Plattform-Admin / Selbstzugriff) — Details `backend/routers/profiles.py`.
- **Tests:** pytest inkl. optionaler Mandanten-Integration (`ACCESS_LAYER_INTEGRATION`); CI-Anbindung siehe `.gitea/workflows/test.yml` (Ausführung im Backend-Container wie Schwesterprojekt).
### 3.1 Identität und Rollen
- `profiles.role` ist eine **globale** Kennzeichnung (`admin`, `superadmin`, `trainer`, `user`, …).
- **Keine** Tabelle für Vereinsmitgliedschaft mit **Mehrfachrollen pro Verein**.
- Sessions liefern nur `profile_id` + globale `role` (`auth.py` → `get_session`).
- *(Historisch)* Fehlende Abbildung von Vereinsrollen **ohne** eigene Tabellen.
- **Ist:** Zusätzlich **`club_member_roles`** pro Verein (z.B. `club_admin`, `trainer`, …); Sessions liefern weiter **`profile_id`** + globale **`role`** (`auth.py` → `get_session`), Vereinsrechte werden aus Mitgliedschaft abgeleitet.
**Konsequenz:** Mehrere Vereine mit unterschiedlichen Rollen pro User sind **nicht modelliert**; ein „Vereinsadmin“ kann nicht sauber von einem reinen Trainer unterschieden werden, sobald beides nur über `profiles.role` laufen soll.
**Konsequenz:** Globale Rolle und Vereinsrollen **koexistieren**; Produkt und Code sollten langfristig klar trennen, was nur global vs. nur über Mitgliedschaft gilt (vgl. Zielarchitektur §4).
### 3.2 Organisation & APIs
- `clubs`, `divisions`, `training_groups` existieren (`002_organization.sql`).
- `GET /api/clubs` listiert **alle** Vereine für jeden eingeloggten Nutzer.
- `POST /api/clubs` erlaubt Anlage für `trainer` und `user` **nicht** nur Systemadmin.
- Sparten/Gruppen: Schreibzugriff über globale `admin`/`superadmin`, nicht über **Vereinsadmin** im Kontext „sein Verein“.
- *(Historisch)* Zu offene Vereinsliste und Club-Anlage für jeden Trainer/User.
- **Ist:** siehe **3.0** — gefilterte Liste, eingeschränktes Anlegen, kontextbezogene Organisationsrechte.
**Konsequenz:** Weder **Datenisolation** noch **Produktdifferenzierung** „nur Systemadmin legt Verein an“ sind umgesetzt.
**Konsequenz:** Offene Punkte verlagern sich in **feine Produktregeln** und **Sparten-/Community-Stufen** (ACCESS_LAYER Stufe D bzw. spätere Epics).
### 3.3 Trainingsplanung
- Zugriff auf Einheiten gruppenbasiert: Trainer/Co-Trainer der `training_groups`, plus `lead_trainer_profile_id` (Migration/Pfad `training_planning`).
- `_assert_club_visible_for_trainer` bindet Vereinssicht für Teile der Planung an „aktive Gruppe als Trainer/Co im Verein“ **kein** generelles Mitgliedschaftsmodell.
- Zugriff auf Einheiten weiterhin stark **gruppenbezogen** (`training_groups`, optional **`lead_trainer_profile_id`** auf Einheiten).
- Mitgliedschaft/`TenantContext` unterstützen andere Endpoints; **`GET /training-units`** hat **keinen** impliziten Filter nur auf **`effective_club_id`** (Multi-Verein-Kalender; bei Bedarf Query **`club_id`**).
**Konsequenz:** Planung ist **gruppenzentriert**, nicht **mitgliedschaftszentriert**; Vereinsweite Aufgaben des Vereinsadmins fehlen als konsistentes Recht.
**Konsequenz:** Vereinsweite oder „Administrations“-Planungsaufgaben können weiter ausgebaut werden (eigenes Produkt-Thema; nicht identisch mit Bibliotheks-Governance).
### 3.4 Governance / Sichtbarkeit (kritisch)
### 3.4 Governance / Sichtbarkeit (Bibliothek)
- Übungen (`list_exercises`): Bedingung sinngemäß „official OR club OR created_by = ich“ **`club` gilt für alle Mandanten**, ohne Prüfung `exercise.club_id` ∈ Vereine des Nutzers.
- Detailzugriff `private`: nur Owner **ok**.
- Rahmenprogramme (`training_framework_programs`): Lesen fremder Rahmen über `visibility=club` ist in `_framework_access` **nicht** gelöst (faktisch stark creator-basiert für Nicht-Admins).
- *(Historisch)* Risiko: **`club`**-Objekte ohne Bindung an **`club_id`** / Mitgliedschaft → mögliche Cross-Tenant-Sicht.
- **Ist:** Listen und Detail für die genannten Bibliotheksmodule nutzen die **einheitliche** Logik in **`club_tenancy`** / **`tenant_context`** (siehe **3.0**).
**Konsequenz:** **Cross-Tenant-Leaks** bei als `club` markierten Bibliotheksobjekten sind möglich bzw. Leselogik ist inkonsistent zwischen Modulen.
**Konsequenz:** Die historische „Leak“-Diagnose für **Übungen und Rahmenprogramme** in dieser Form ist **überholt**. Verbleibend: **Konsolidierung auf wenige Hilfsfunktionen** (ACCESS_LAYER Stufe C), **Sparte** als eigene Stufe, ggf. **community**.
### 3.5 Frontend
- **Stand 2026-05:** `GET /api/profiles/me` liefert `clubs[]`, `active_club_id`; Frontend setzt `X-Active-Club-Id`. Details und Pflicht zur serverseitigen **TenantContext**-Validierung siehe `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`.
- `GET /api/profiles/me` liefert u.a. **`clubs[]`**, **`active_club_id`**; Client setzt **`X-Active-Club-Id`**. Geschützte Backend-Routen nutzen **`Depends(get_tenant_context)`** wo im Audit festgehalten.
### 3.6 Membership (kommerziell/limits)
- Mitai-artige Tabellen (`tiers`, `subscriptions`, `tier_limits`, …) sind **nutzerbezogen**, nicht **vereinsbezogen**.
- Kein konzipiertes **`club_subscription` / `club_plan`** im Schema.
- Kein konzipiertes **`club_subscription` / `club_plan`** im Schema — bewusst nach ACCESS_LAYER-Plan zurückgestellt.
**Letzte Überarbeitung dieses Abschnitts (3.x):** 2026-05-06.
---

View File

@ -5,6 +5,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
| Router / Bereich | Beispiel-Endpunkt | tenant-relevant | `Depends(get_tenant_context)` / Kontext | Governance geprüft (Liste+Detail) | Notizen |
|------------------|-------------------|-----------------|----------------------------------------|-------------------------------------|---------|
| profiles | `GET /api/profiles/me` | ja | `resolve_tenant_context` inline (`invalid_header_policy=ignore`) | teils | + `effective_club_id`; veralteter Header bricht Refresh nicht |
| profiles | `GET /api/profiles`, `GET /profiles/{pid}`, `POST /profiles`, `DELETE /profiles/{pid}` | ja/teils | `require_auth` | ja | Liste nur Plattform-Admin; GET nach ID eigenes Profil oder Admin; POST/DELETE nur Admin |
| profiles | `PUT /api/profiles/{id}`, `PUT /api/profile` | ja | `get_tenant_context` | `active_club_id` Mitgliedschaft | Validiert `X-Active-Club-Id` konsistent zu Mitgliedschaft |
| clubs | geschützte `/api/clubs*`, `/divisions*`, `/groups*` | ja | `get_tenant_context` | Mitgliedschaft / `can_manage_*` | Öffentlich: `/clubs/public-directory` ohne Auth |
| club_memberships | `/clubs/{id}/members*` | ja | `get_tenant_context` | ja | |
@ -24,7 +25,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
**Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen.
Letzte Änderung: 2026-05-05 — Cursor-Regel + Architektur-/Coding-Pflicht + Script `backend/scripts/check_access_layer_hints.py`; Katalog-Router im Audit als global dokumentiert.
Letzte Änderung: 2026-05-06 — MULTI_TENANCY §3 Gap-Analyse aktualisiert; Audit um geschützte Profil-Endpunkte ergänzt.
---

View File

@ -0,0 +1,9 @@
-- Session-spezifische Co-Trainer: NULL = wie training_groups.co_trainer_ids; [] = explizit keine Co-Trainer
ALTER TABLE training_units
ADD COLUMN IF NOT EXISTS assistant_trainer_profile_ids JSONB;
COMMENT ON COLUMN training_units.assistant_trainer_profile_ids IS
'Co-Trainer nur für diese Einheit; NULL vererbt training_groups.co_trainer_ids; leeres Array = keine Co-Trainer';
CREATE INDEX IF NOT EXISTS idx_training_units_assistant_trainers
ON training_units USING GIN (assistant_trainer_profile_ids);

View File

@ -12,6 +12,7 @@ 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_valid_governance_visibility,
can_manage_club_org,
is_platform_admin,
library_content_visible_to_profile,
)
@ -53,7 +54,7 @@ def _validate_variant_for_exercise(cur, exercise_id: Optional[int], variant_id:
def _can_access_group_for_create(cur, group_id: int, profile_id: int, role: str) -> None:
cur.execute(
"SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s",
"SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s",
(group_id,),
)
group = cur.fetchone()
@ -64,9 +65,83 @@ def _can_access_group_for_create(cur, group_id: int, profile_id: int, role: str)
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Trainingseinheiten erstellen")
if role not in ["admin", "superadmin"]:
if group["trainer_id"] != profile_id and profile_id not in co_trainers:
raise HTTPException(
status_code=403, detail="Nur der zuständige Trainer darf für diese Gruppe planen"
)
if not can_manage_club_org(cur, profile_id, int(group["club_id"]), role):
raise HTTPException(
status_code=403, detail="Nur der zuständige Trainer darf für diese Gruppe planen"
)
def _profile_active_in_club(cur, club_id: int, profile_id: int) -> bool:
cur.execute(
"""
SELECT 1 FROM club_members
WHERE club_id = %s AND profile_id = %s AND status = 'active'
LIMIT 1
""",
(club_id, profile_id),
)
return cur.fetchone() is not None
def _caller_may_assign_session_trainers(
cur,
group_row: Dict[str, Any],
profile_id: int,
role: str,
unit_created_by: Optional[int],
) -> bool:
if is_platform_admin(role):
return True
cid = group_row.get("club_id")
if cid is not None and can_manage_club_org(cur, profile_id, int(cid), role):
return True
if unit_created_by is not None and unit_created_by == profile_id:
return True
if group_row.get("trainer_id") == profile_id:
return True
co = group_row.get("co_trainer_ids") or []
return profile_id in co
def _effective_co_trainer_ids_for_row(unit_row: Dict[str, Any]) -> List[int]:
"""Leseregel: Session-Co-Trainer überschreiben die Gruppe; NULL auf der Einheit = Gruppen-Standard."""
unit_asst = unit_row.get("assistant_trainer_profile_ids")
if unit_asst is not None:
src = unit_asst
else:
src = unit_row.get("co_trainer_ids") or []
seen: set = set()
out: List[int] = []
for x in src:
try:
i = int(x)
except (TypeError, ValueError):
continue
if i not in seen:
seen.add(i)
out.append(i)
return sorted(out)
def effective_co_trainer_profile_ids_for_merge(
unit_assistant: Any, group_co: Any
) -> List[int]:
"""Reine Hilfsfunktion (pytest): gleiche Semantik wie _effective_co_trainer_ids_for_row."""
if unit_assistant is not None:
src = unit_assistant
else:
src = group_co or []
seen: set = set()
out: List[int] = []
for x in src:
try:
i = int(x)
except (TypeError, ValueError):
continue
if i not in seen:
seen.add(i)
out.append(i)
return sorted(out)
def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]:
@ -74,7 +149,8 @@ def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]:
"""
SELECT tu.id, tu.created_by, tu.group_id, tu.plan_template_id, tu.framework_slot_id,
tu.lead_trainer_profile_id,
tg.trainer_id, tg.co_trainer_ids,
tu.assistant_trainer_profile_ids,
tg.trainer_id, tg.co_trainer_ids, tg.club_id AS group_club_id,
fwp.created_by AS framework_created_by
FROM training_units tu
LEFT JOIN training_groups tg ON tu.group_id = tg.id
@ -103,12 +179,12 @@ def _assert_training_unit_permission(
return
raise HTTPException(status_code=403, detail="Keine Berechtigung")
co_trainers = unit_row["co_trainer_ids"] or []
co_eff = _effective_co_trainer_ids_for_row(unit_row)
if role not in ["admin", "superadmin"]:
if (
unit_row["created_by"] != profile_id
and unit_row["trainer_id"] != profile_id
and profile_id not in co_trainers
and profile_id not in co_eff
and unit_row.get("lead_trainer_profile_id") != profile_id
):
raise HTTPException(status_code=403, detail="Keine Berechtigung")
@ -120,9 +196,21 @@ def _assert_delete_training_unit(role: str, created_by: int, profile_id: int) ->
def _assert_club_visible_for_trainer(cur, club_id: int, profile_id: int, role: str) -> None:
"""Nicht-Admin: mindestens eine aktive Gruppe im Verein als Trainer/Co-Trainer."""
"""Nicht-Admin: Vereinsbezug für Listen mit club_id (Mitglied genügt; Details filtert WHERE)."""
if role in ("admin", "superadmin"):
return
if can_manage_club_org(cur, profile_id, club_id, role):
return
cur.execute(
"""
SELECT 1 FROM club_members
WHERE club_id = %s AND profile_id = %s AND status = 'active'
LIMIT 1
""",
(club_id, profile_id),
)
if cur.fetchone():
return
cur.execute(
"""
SELECT 1 FROM training_groups g
@ -145,8 +233,9 @@ def _normalize_lead_trainer_profile_id(
raw_lead: Any,
profile_id: int,
role: str,
unit_created_by: Optional[int],
) -> Optional[int]:
"""NULL = Vertretung aufheben; sonst Profil-ID mit Profil-Check und Gruppenkontext."""
"""NULL = Standard (Gruppen-Haupttrainer); sonst gültiges Profil i.d.R. mit Vereinsbezug."""
if raw_lead is None:
return None
if raw_lead in ("", []):
@ -160,27 +249,130 @@ def _normalize_lead_trainer_profile_id(
cur.execute("SELECT 1 FROM profiles WHERE id = %s", (nid,))
if not cur.fetchone():
raise HTTPException(status_code=400, detail="Profil für Leitung nicht gefunden")
if role in ("admin", "superadmin"):
return nid
if nid == profile_id:
return nid
cur.execute(
"SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s",
"SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s",
(group_id,),
)
gr = cur.fetchone()
if not gr:
raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
eligible = {gr["trainer_id"]} if gr.get("trainer_id") else set()
for x in gr.get("co_trainer_ids") or []:
eligible.add(x)
if nid in eligible:
return nid
raise HTTPException(
status_code=403,
detail="Lead-Trainer kann nur eigene Person, Haupttrainer oder Co-Trainer der Gruppe sein",
)
grd = dict(gr)
cid = grd.get("club_id")
if cid is None:
raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
club_i = int(cid)
if is_platform_admin(role):
return nid
eligible = {grd["trainer_id"]} if grd.get("trainer_id") else set()
for x in grd.get("co_trainer_ids") or []:
try:
eligible.add(int(x))
except (TypeError, ValueError):
continue
if nid == profile_id:
if not _profile_active_in_club(cur, club_i, profile_id):
raise HTTPException(
status_code=403,
detail="Nur aktive Vereinsmitglieder können die Leitung dieser Einheit übernehmen",
)
return nid
if nid not in eligible:
if not _profile_active_in_club(cur, club_i, nid):
raise HTTPException(
status_code=400,
detail="Leitung nur für Profile mit aktiver Mitgliedschaft im Verein der Gruppe",
)
if not _caller_may_assign_session_trainers(cur, grd, profile_id, role, unit_created_by):
raise HTTPException(
status_code=403,
detail="Keine Berechtigung, die Leitung zuzuweisen",
)
return nid
if nid != profile_id and not _caller_may_assign_session_trainers(
cur, grd, profile_id, role, unit_created_by
):
raise HTTPException(status_code=403, detail="Keine Berechtigung, die Leitung anderen zuzuweisen")
return nid
def _normalize_assistant_trainer_profile_ids(
cur,
group_id: int,
raw_val: Any,
profile_id: int,
role: str,
unit_created_by: Optional[int],
lead_nid: Optional[int],
) -> Any:
"""
None = Vererbung aus training_groups.co_trainer_ids (SQL NULL);
Liste = Session-Co-Trainer (JSONB Array; leeres Array ausdrücklich ohne Co.)
"""
if raw_val is None:
return None
if not isinstance(raw_val, list):
raise HTTPException(status_code=400, detail="assistant_trainer_profile_ids muss Liste oder null sein")
ids_in: List[int] = []
for x in raw_val:
try:
i = int(x)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="assistant_trainer_profile_ids ungültig")
if i < 1:
raise HTTPException(status_code=400, detail="assistant_trainer_profile_ids ungültig")
ids_in.append(i)
uniq = sorted(set(ids_in))
cur.execute(
"SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s",
(group_id,),
)
gr = cur.fetchone()
if not gr:
raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
grd = dict(gr)
cid = grd.get("club_id")
if cid is None:
raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
club_i = int(cid)
if not is_platform_admin(role) and not _caller_may_assign_session_trainers(
cur, grd, profile_id, role, unit_created_by
):
raise HTTPException(status_code=403, detail="Keine Berechtigung, Co-Trainer zuzuweisen")
eligible = {grd["trainer_id"]} if grd.get("trainer_id") else set()
for x in grd.get("co_trainer_ids") or []:
try:
eligible.add(int(x))
except (TypeError, ValueError):
continue
eff_lead = lead_nid if lead_nid is not None else (grd.get("trainer_id") or None)
for nid in uniq:
cur.execute("SELECT 1 FROM profiles WHERE id = %s", (nid,))
if not cur.fetchone():
raise HTTPException(status_code=400, detail="Profil für Co-Trainer nicht gefunden")
if eff_lead is not None and nid == eff_lead:
raise HTTPException(status_code=400, detail="Leitung und Co-Trainer dürfen sich nicht überschneiden")
if is_platform_admin(role):
continue
if nid in eligible:
continue
if not _profile_active_in_club(cur, club_i, nid):
raise HTTPException(
status_code=400,
detail="Co-Trainer nur mit aktiver Mitgliedschaft im Verein dieser Gruppe",
)
return uniq
# Nachverfolgbarkeit: Übernahmen aus Rahmenprogramm über origin_framework_slot_id
_ORIGIN_LINEAGE_JOIN = """
@ -775,14 +967,18 @@ def list_training_units(
if gid and role not in ["admin", "superadmin"]:
cur.execute(
"SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s AND status = 'active'",
"SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s AND status = 'active'",
(gid,),
)
gr = cur.fetchone()
if not gr:
raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden")
cob = gr["co_trainer_ids"] or []
if gr["trainer_id"] != profile_id and profile_id not in cob:
gd = dict(gr)
cob = gd.get("co_trainer_ids") or []
ok_staff = gd.get("trainer_id") == profile_id or profile_id in cob
ok_org = can_manage_club_org(cur, profile_id, int(gd["club_id"]), role)
ok_member = _profile_active_in_club(cur, int(gd["club_id"]), profile_id)
if not (ok_staff or ok_org or ok_member):
raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Gruppe")
order_dir = "ASC" if (sort or "").strip().lower() == "asc" else "DESC"
@ -805,6 +1001,8 @@ def list_training_units(
p.name as trainer_name,
p.name as creator_name,
COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id,
COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb)
AS effective_assistant_trainer_profile_ids,
leadp.name AS lead_trainer_name
"""
query += "," + _ORIGIN_LINEAGE_FIELDS
@ -822,10 +1020,11 @@ def list_training_units(
if role not in ["admin", "superadmin"]:
where.append(
"(tu.created_by = %s OR tg.trainer_id = %s OR "
"(tg.co_trainer_ids IS NOT NULL AND tg.co_trainer_ids @> jsonb_build_array(%s::int)))"
"(tu.created_by = %s OR tg.trainer_id = %s OR tu.lead_trainer_profile_id = %s OR "
"COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb) "
"@> jsonb_build_array(%s::int))"
)
params.extend([profile_id, profile_id, profile_id])
params.extend([profile_id, profile_id, profile_id, profile_id])
where.append("tu.framework_slot_id IS NULL")
@ -840,7 +1039,8 @@ def list_training_units(
if assigned_to_me:
where.append(
"(COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) = %s OR "
"(tg.co_trainer_ids IS NOT NULL AND tg.co_trainer_ids @> jsonb_build_array(%s::int)))"
"COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb) "
"@> jsonb_build_array(%s::int))"
)
params.extend([profile_id, profile_id])
@ -891,6 +1091,8 @@ def get_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_c
tg.trainer_id AS trainer_id,
tg.co_trainer_ids AS co_trainer_ids,
COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id,
COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb)
AS effective_assistant_trainer_profile_ids,
leadp.name AS lead_trainer_name,
""" + _ORIGIN_LINEAGE_FIELDS.strip() + """
FROM training_units tu
@ -957,27 +1159,77 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_
tpl_id_safe = plan_template_id
cur.execute(
"""
INSERT INTO training_units (
group_id, planned_date, planned_time_start, planned_time_end,
planned_focus, status, notes, trainer_notes, created_by,
plan_template_id
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
group_id,
planned_date,
data.get("planned_time_start"),
data.get("planned_time_end"),
data.get("planned_focus"),
data.get("status", "planned"),
data.get("notes"),
data.get("trainer_notes"),
profile_id,
tpl_id_safe,
),
"SELECT trainer_id FROM training_groups WHERE id = %s",
(int(group_id),),
)
g0 = cur.fetchone()
default_group_trainer = g0["trainer_id"] if g0 else None
lead_ins: Optional[int] = None
if "lead_trainer_profile_id" in data:
lead_ins = _normalize_lead_trainer_profile_id(
cur,
int(group_id),
data.get("lead_trainer_profile_id"),
profile_id,
role,
profile_id,
)
assistant_val: Any = None
assistant_set = False
if "assistant_trainer_profile_ids" in data:
assistant_set = True
eff_lead_for_co = lead_ins if lead_ins is not None else default_group_trainer
assistant_val = _normalize_assistant_trainer_profile_ids(
cur,
int(group_id),
data.get("assistant_trainer_profile_ids"),
profile_id,
role,
profile_id,
eff_lead_for_co,
)
base_params = (
group_id,
planned_date,
data.get("planned_time_start"),
data.get("planned_time_end"),
data.get("planned_focus"),
data.get("status", "planned"),
data.get("notes"),
data.get("trainer_notes"),
profile_id,
tpl_id_safe,
lead_ins,
)
if assistant_set:
cur.execute(
"""
INSERT INTO training_units (
group_id, planned_date, planned_time_start, planned_time_end,
planned_focus, status, notes, trainer_notes, created_by,
plan_template_id,
lead_trainer_profile_id,
assistant_trainer_profile_ids
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
base_params + (assistant_val,),
)
else:
cur.execute(
"""
INSERT INTO training_units (
group_id, planned_date, planned_time_start, planned_time_end,
planned_focus, status, notes, trainer_notes, created_by,
plan_template_id,
lead_trainer_profile_id
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
base_params,
)
unit_id = cur.fetchone()["id"]
@ -1066,8 +1318,13 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
tuple(blueprint_params),
)
else:
cur_lead = unit_row.get("lead_trainer_profile_id")
base_tr = unit_row.get("trainer_id")
lead_sql = ""
lead_params: List[Any] = []
assist_sql = ""
assist_params: List[Any] = []
nl: Optional[int]
if "lead_trainer_profile_id" in data:
nl = _normalize_lead_trainer_profile_id(
cur,
@ -1075,9 +1332,27 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
data.get("lead_trainer_profile_id"),
profile_id,
role,
unit_row.get("created_by"),
)
lead_sql = ", lead_trainer_profile_id = %s"
lead_params.append(nl)
eff_lead_for_co = nl if nl is not None else base_tr
else:
nl = cur_lead if cur_lead is not None else base_tr
eff_lead_for_co = nl
if "assistant_trainer_profile_ids" in data:
na = _normalize_assistant_trainer_profile_ids(
cur,
unit_row["group_id"],
data.get("assistant_trainer_profile_ids"),
profile_id,
role,
unit_row.get("created_by"),
eff_lead_for_co,
)
assist_sql = ", assistant_trainer_profile_ids = %s"
assist_params.append(na)
cur.execute(
f"""
@ -1096,6 +1371,7 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
plan_template_id = COALESCE(%s, plan_template_id),
updated_at = NOW()
{lead_sql}
{assist_sql}
WHERE id = %s
""",
(
@ -1113,6 +1389,7 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
tpl_id_val,
)
+ tuple(lead_params)
+ tuple(assist_params)
+ (unit_id,),
)

View File

@ -0,0 +1,17 @@
"""Unit-Tests ohne DB: Zusammenführung Session-Co vs. Gruppe."""
import pytest
from routers.training_planning import effective_co_trainer_profile_ids_for_merge
@pytest.mark.parametrize(
"unit_side,group_side,expected",
[
(None, [10, 22], [10, 22]),
(None, None, []),
([], [10, 22], []),
([7, "8", 7], None, [7, 8]),
],
)
def test_effective_co_trainer_profile_ids_for_merge(unit_side, group_side, expected):
assert effective_co_trainer_profile_ids_for_merge(unit_side, group_side) == expected

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.36"
APP_VERSION = "0.8.37"
BUILD_DATE = "2026-05-05"
DB_SCHEMA_VERSION = "20260505041"
DB_SCHEMA_VERSION = "20260505042"
MODULE_VERSIONS = {
"auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute)
@ -16,7 +16,7 @@ MODULE_VERSIONS = {
"skills": "0.1.0",
"methods": "0.1.0",
"exercises": "2.7.0", # PATCH /exercises/bulk-metadata — Massenänderung Sichtbarkeit/Status
"training_units": "0.1.0",
"training_units": "0.2.0",
"training_programs": "0.1.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
"import_wiki": "1.0.0",
@ -27,6 +27,16 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.37",
"date": "2026-05-05",
"changes": [
"DB 042: training_units.assistant_trainer_profile_ids (Co-Trainer-Zuweisung je Termin; NULL = Gruppen-Standard)",
"Trainingseinheiten: POST/PUT lead_trainer_profile_id & assistant_trainer_profile_ids; Leitung für Vereinsmitglieder (Vertretung); GET-Listen inkl. Zuweisung für Sichtbarkeit/assigned_to_me",
"Frontend Trainingsplanung: Leitung/Co-Trainer pro Einheit; Dashboard-Text",
"pytest: tests/test_training_unit_assignments.py",
],
},
{
"version": "0.8.36",
"date": "2026-05-05",

View File

@ -153,11 +153,12 @@ function Dashboard() {
</ul>
) : (
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', margin: 0 }}>
Keine anstehenden Termine mit dir als Leitung oder CoTrainer. Unter{' '}
Keine anstehenden Termine, bei denen du als Leitung oder Co-Trainer dieser Einheit eingetragen
bist. Unter{' '}
<Link to="/planning" style={{ color: 'var(--accent-dark)' }}>
Trainingsplanung
</Link>{' '}
kannst du den Vereins oder GruppenZeitraum einblenden.
kannst du Zeiträume und Zuordnungen bearbeiten.
</p>
)}
</div>

View File

@ -72,6 +72,21 @@ function enumerateIsoDays(fromIso, toIso) {
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
function toNumList(arr) {
if (!Array.isArray(arr)) return []
const out = []
for (const x of arr) {
const n = Number(x)
if (Number.isFinite(n) && n >= 1) out.push(n)
}
return out
}
const sessionAssignDefaults = () => ({
lead_trainer_profile_id: '',
session_assistants_inherit: true,
session_assistant_profile_ids: [],
})
function TrainingPlanningPage() {
const { user } = useAuth()
const [groups, setGroups] = useState([])
@ -107,6 +122,7 @@ function TrainingPlanningPage() {
const [calendarMonthStr, setCalendarMonthStr] = useState(() => today.slice(0, 7))
const [planScope, setPlanScope] = useState('group')
const [assignedToMeOnly, setAssignedToMeOnly] = useState(false)
const [clubDirectory, setClubDirectory] = useState([])
const [formData, setFormData] = useState({
group_id: '',
@ -121,7 +137,8 @@ function TrainingPlanningPage() {
status: 'planned',
notes: '',
trainer_notes: '',
sections: [defaultSection()]
sections: [defaultSection()],
...sessionAssignDefaults()
})
const loadPlanTemplates = useCallback(async () => {
@ -206,6 +223,39 @@ function TrainingPlanningPage() {
}
}, [selectedGroupId, loadUnits])
useEffect(() => {
if (!showModal) {
setClubDirectory([])
return undefined
}
const gid = parseInt(formData.group_id || selectedGroupId || '0', 10)
if (!Number.isFinite(gid) || gid < 1) {
setClubDirectory([])
return undefined
}
const g = groups.find((x) => x.id === gid)
const cid = g?.club_id
if (!cid) {
setClubDirectory([])
return undefined
}
let cancelled = false
;(async () => {
try {
const d = await api.clubMembersDirectory(cid)
if (!cancelled) setClubDirectory(Array.isArray(d) ? d : [])
} catch (err) {
if (!cancelled) {
console.error('Mitgliederverzeichnis:', err)
setClubDirectory([])
}
}
})()
return () => {
cancelled = true
}
}, [showModal, formData.group_id, selectedGroupId, groups])
useEffect(() => {
if (!frameworkImportOpen) return
let cancelled = false
@ -380,7 +430,8 @@ function TrainingPlanningPage() {
status: 'planned',
notes: '',
trainer_notes: '',
sections: [defaultSection('Hauptteil')]
sections: [defaultSection('Hauptteil')],
...sessionAssignDefaults()
})
setShowModal(true)
}
@ -406,12 +457,9 @@ function TrainingPlanningPage() {
status: 'planned',
notes: '',
trainer_notes: '',
sections: [defaultSection('Hauptteil')]
})
setShowModal(true)
}
const applyTemplateFromSelect = async (templateId) => {
sections: [defaultSection('Hauptteil')],
...sessionAssignDefaults()
}) = async (templateId) => {
setDraftPlanTemplateId(templateId)
if (!templateId) return
try {
@ -452,6 +500,14 @@ function TrainingPlanningPage() {
notes: fullUnit.notes || '',
trainer_notes: fullUnit.trainer_notes || '',
sections,
lead_trainer_profile_id:
fullUnit.lead_trainer_profile_id != null && fullUnit.lead_trainer_profile_id !== ''
? String(fullUnit.lead_trainer_profile_id)
: '',
session_assistants_inherit:
fullUnit.assistant_trainer_profile_ids == null ||
fullUnit.assistant_trainer_profile_ids === undefined,
session_assistant_profile_ids: toNumList(fullUnit.assistant_trainer_profile_ids),
})
setShowModal(true)
} catch (err) {
@ -519,6 +575,19 @@ function TrainingPlanningPage() {
trainer_notes: formData.trainer_notes || null,
sections: sectionsPayload
}
const leadStr = String(formData.lead_trainer_profile_id || '').trim()
if (leadStr) {
payload.lead_trainer_profile_id = parseInt(leadStr, 10)
} else if (editingUnit) {
payload.lead_trainer_profile_id = null
}
if (formData.session_assistants_inherit) {
if (editingUnit) payload.assistant_trainer_profile_ids = null
} else {
payload.assistant_trainer_profile_ids = [...formData.session_assistant_profile_ids].sort(
(a, b) => a - b
)
}
if (!editingUnit) {
payload.group_id = parseInt(formData.group_id, 10)
if (draftPlanTemplateId) {
@ -1189,6 +1258,28 @@ function TrainingPlanningPage() {
<p style={{ fontSize: '0.82rem', color: 'var(--text2)', margin: '0 0 0.5rem' }}>
Leitung: {(unit.lead_trainer_name || '').trim() || '—'}
</p>
{(() => {
const coRaw = unit.effective_assistant_trainer_profile_ids
const co = Array.isArray(coRaw)
? coRaw.map(Number).filter((x) => Number.isFinite(x) && x >= 1)
: []
if (!co.length) return null
const src =
unit.assistant_trainer_profile_ids != null
? 'Session-Zuweisung'
: 'über Trainingsgruppe'
return (
<p
style={{
fontSize: '0.78rem',
color: 'var(--text3)',
margin: '0 0 0.5rem',
}}
>
Co-Trainer ({src}): {co.length}
</p>
)
})()}
{unit.planned_focus && (
<p
style={{
@ -1643,6 +1734,98 @@ function TrainingPlanningPage() {
/>
</div>
<div
className="card"
style={{
marginTop: '1.25rem',
marginBottom: '0.25rem',
padding: '12px 14px',
background: 'var(--surface2)',
}}
>
<h3 style={{ margin: '0 0 10px', fontSize: '1rem' }}>Trainerzuordnung (diese Einheit)</h3>
<div className="form-row">
<label className="form-label">Leitung</label>
<select
className="form-input"
value={formData.lead_trainer_profile_id}
onChange={(e) => updateFormField('lead_trainer_profile_id', e.target.value)}
disabled={!editingUnit && !formData.group_id}
>
<option value="">Standard (Haupttrainer der Gruppe)</option>
{clubDirectory.map((m) => {
const idStr = String(m.id)
return (
<option key={idStr} value={idStr}>
{(m.name || '').trim() || m.email || `Profil ${m.id}`}
</option>
)
})}
</select>
<p style={{ fontSize: '0.8rem', color: 'var(--text2)', marginTop: '0.35rem', lineHeight: 1.45 }}>
Für Vertretungen genügt in der Regel die Vereinsmitgliedschaft; Zuweisen dürfen u.a.
Haupt-/CoTrainer dieser Gruppe, der/die Ersteller:in der Einheit oder Vereinsadmins.
</p>
</div>
<div className="form-row" style={{ marginTop: '0.75rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={formData.session_assistants_inherit}
onChange={(e) =>
updateFormField('session_assistants_inherit', e.target.checked)
}
/>
<span style={{ fontSize: '0.9rem', color: 'var(--text1)' }}>
Co-Trainer wie in der Trainingsgruppe (Standard)
</span>
</label>
</div>
{!formData.session_assistants_inherit ? (
<div style={{ marginTop: '10px', maxHeight: '200px', overflowY: 'auto' }}>
{clubDirectory.map((m) => {
const mid = typeof m.id === 'number' ? m.id : parseInt(String(m.id), 10)
const labelText = `${(m.name || '').trim() || m.email || `Profil ${mid}`}`
const isOn = Number.isFinite(mid) && formData.session_assistant_profile_ids.includes(mid)
return (
<label
key={`co-${mid}`}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '0.875rem',
marginBottom: '6px',
cursor: 'pointer',
color: 'var(--text1)',
}}
>
<input
type="checkbox"
checked={isOn}
onChange={() => {
setFormData((prev) => {
const was = prev.session_assistant_profile_ids.includes(mid)
const nextIds = was
? prev.session_assistant_profile_ids.filter((x) => x !== mid)
: [...prev.session_assistant_profile_ids, mid].sort((a, b) => a - b)
return { ...prev, session_assistant_profile_ids: nextIds }
})
}}
/>
<span>{labelText}</span>
</label>
)
})}
</div>
) : null}
{!clubDirectory.length && showModal ? (
<p style={{ margin: '10px 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.4 }}>
Keine Einträge im Vereins-Mitgliederverzeichnis oder noch nicht geladen (nur für Vereinsinterne).
</p>
) : null}
</div>
<div style={{ marginTop: '2rem' }}>
<TrainingUnitSectionsEditor
heading="Abschnitte & Übungen"

View File

@ -1,6 +1,6 @@
// Shinkan Jinkendo Frontend Version
export const APP_VERSION = "0.8.36"
export const APP_VERSION = "0.8.37"
export const BUILD_DATE = "2026-05-05"
export const PAGE_VERSIONS = {