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
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:
parent
14745b347d
commit
c778d21b26
|
|
@ -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 1–4 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
|
||||
|
|
|
|||
|
|
@ -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, §17–18 · `functional/DOMAIN_MODEL.md` (Sichtbarkeit §5.5) · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR-004–008)
|
||||
|
||||
**Operative Reihenfolge & einheitliche Zugriffsschicht:** [`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`](./ACCESS_LAYER_AND_GOVERNANCE_PLAN.md) – dort sind Stufen A–F, 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-004–008: 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.1–3.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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
|||
| 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 A–C.
|
|||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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,),
|
||||
)
|
||||
|
||||
|
|
|
|||
17
backend/tests/test_training_unit_assignments.py
Normal file
17
backend/tests/test_training_unit_assignments.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 Co‑Trainer. 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 Gruppen‑Zeitraum einblenden.
|
||||
kannst du Zeiträume und Zuordnungen bearbeiten.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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-/Co‑Trainer 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"
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user