Mandantenfähigkeit V1 #10

Merged
Lars merged 17 commits from develop into main 2026-05-05 22:34:25 +02:00
46 changed files with 5121 additions and 543 deletions

View File

@ -6,6 +6,7 @@ Ausführliche fachliche Inhalte:
|----------|--------|
| [shinkan_anforderungsdokument_entwurf.md](./shinkan_anforderungsdokument_entwurf.md) | Gesamtentwurf Anforderungen |
| [DOMAIN_MODEL.md](./DOMAIN_MODEL.md) | Domänenmodell, Variantenlogik (Abschnitt 11.2) |
| [MULTI_TENANCY_RBAC_ARCHITECTURE.md](../technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md) | Zielarchitektur Mandanten/Rollen/Membership & Umsetzungsplan |
**Lieferstand & Umsetzung (Stand Code):** siehe [`../PROJECT_STATUS.md`](../PROJECT_STATUS.md) und [`../library/FEATURES_DELIVERED_2026-Q2.md`](../library/FEATURES_DELIVERED_2026-Q2.md).

View File

@ -0,0 +1,125 @@
# Einheitliche Zugriffsschicht & Governance Umsetzungsplan
**Status:** verbindliche Umsetzungsreihenfolge (nachgelagert zum Zielbild in `MULTI_TENANCY_RBAC_ARCHITECTURE.md`)
**Stand:** 2026-05-05
**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.
**Separates späteres Konzept:** durch Vereinsadmins definierbare Rollen mit feingranularen Rechten (Capability-Bundles in DB/UI); dieser Plan bereitet nur die **Capability-Idee** und **Erweiterungspunkte** vor.
---
## 1. Leitprinzipien
1. **Ein Mandant für Datenisolierung:** `club_id` ist die Grenze für „vereinsgeteilte“ Inhalte. Nur Ausnahmen: explizit **plattformweite/offizielle** Objekte und **privat**e Objekte des Erstellers.
2. **Ein Kontext pro HTTP-Request:** Mitgliedschaft und gewählter aktiver Verein werden einmal aufgelöst und validiert; Folgelogik nutzt nur noch dieses Objekt (kein verteiltes erneutes „Rates“ aus Headers).
3. **Eine fachliche Sichtbarkeits-Semantik** über alle Bibliotheks- und Planungsartefakte (Übungen, Vorlagen, Rahmenprogramme, …): gleiche Enums, gleiche Leseprüfung, angepasste Listenfilter.
4. **Sparte optional verschärfen:** `division_id` auf Objekten und später auf Rollenzuweisung ausgewertet ohne Vereinsgrenze zu sprengen.
5. **Community später additive:** neue Freigabeebene oder Flags ergänzen die bestehende Semantik, ohne `club`-Isolation zu ersetzen.
---
## 2. Architektur der Zugriffsschicht (Schichtenmodell)
| Schicht | Verantwortung |
|---------|----------------|
| **Authentifizierung** | Session / Token → `profile_id`, globale `role` (`require_auth`, bestehend). |
| **TenantContext** (neu, zentral) | Aus `profile_id` + Header `X-Active-Club-Id` (optional) + DB `profiles.active_club_id`: effektive **`effective_club_id`** nur wenn Mitgliedschaft aktiv existiert; sonch klaren Fehler (403/400 nach Konvention). Optional: Cache der Mitgliedschaftszeilen/Rollen **einmal pro Request**. |
| **Governance / Objekt contra Account** | Für jedes geschützte Objekt: `visibility`, `club_id`, `division_id` (nullable), `created_by`**eine** zentrale Entscheidung `can_read` / `can_write` (interne Module, keine Copy-Paste-Logik pro Router). |
| **Funktions-/Feature-Rechte** | „Darf dieser Nutzer im Verein X Trainergruppe anlegen?“ → Capability-Checks (`can_manage_club_org`, `can_plan_in_club`, …); später durch dynamische Rollen → gleiche Capability-Namen. |
**Ziel:** Router werden dünn: laden Daten nur noch durch Hilfen, die **TenantContext** und **Governance** bereits berücksichtigen oder explizit prüfen.
---
## 3. Scope- und Sichtbarkeitsmodell (einheitlich)
Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
**Zielbild (phasenweise DB/API-Anpassung):**
| Wert | Lesende Regel (kurz) |
|------|----------------------|
| `private` | Nur `created_by` (+ Plattform-Admin nach Policy); keine Vereinsliste ohne Ownership. |
| `club` | Nur aktive Mitglieder des Objekt-`club_id`; **Cross-Verein nie**. |
| `division` *(optional neue Stufe oder `club` + Pflicht `division_id`)* | Nur Mitglieder, die dieser Sparte zugeordnet sind (Regeln gesondert spezifizieren: Mitgliedschaft vs. Gruppe vs. Rolle `division_lead`). |
| `official` | Plattform-weit lesbar (Superadmin publiziert/pflegt); weiterhin strikt von `club`-Daten getrennt. |
| `community` *(reserviert)* | Noch nicht implementieren; Design nur additive Felder/Enum-Einträge dokumentieren, wenn erste Stories starten. |
**Trainer-Flow:** „Privat anlegen, dann im Verein teilen“ = Transition von `private` zu `club` (oder Kopie + neue Visibility Produktentscheidung; technisch muss `club_id` gesetzt und Mitgliedschaft geprüft werden).
---
## 4. Roadmap (verbindliche Reihenfolge)
### Stufe A Foundations & Audit
- **Router-Inventar:** Liste aller Endpoints mit Zugriff auf `club_id`, `visibility`, organisationalem Bezug oder Listenfiltern (Excel/Markdown-Tabelle im Repo oder unter `.claude/docs/working/`).
- **Definition of Done je Endpoint:** „Default deny“ für tenant-sensitive Listen wenn Kontext fehlt/ungültig.
- TenantContext-Spezifikation festhalten (Feldnamen, HTTP-Fehlercodes, Superadmin-Ausnahmen).
### Stufe B TenantContext (Backend zentral)
- FastAPI-Dependency z.B. `get_tenant_context(session, x_active_club_id)` → Objekt mit `profile_id`, `global_role`, `effective_club_id`, `club_memberships` (optional gekürzt).
- Alle neuen und bestehenden sicherheitsrelevanten Änderungen **nur** über diese Dependency oder darauf aufbauende Helfer.
- Abgleich mit Frontend: `X-Active-Club-Id` und Persistenz `active_club_id` (bereits vorhanden) als einzige variabler Vereinskontext-Quellen.
### Stufe C Governance vereinheitlichen
- **Eine** interne API-Stilebene (auch wenn mehrere Python-Funktionen):
`content_readable(...)`, `content_writable(...)` oder zweischichtig „Governance“ vs „Org-Rechte“.
- Module angleichen: **Übungen**, **Trainingsplanung**, **Rahmenprogramme**, **Vorlagen** gleiche Regeln für `club`/`official`/`private`; **`division`** dort einführen, wo fachlich nötig (einheitliche Filter-Chips in UI).
- Regressionstests: **zwei Vereine, zwei Nutzer**, kein Kreuzzugriff auf `club`-Objekte; Superadmin/Global-Path weiterhin getrennt testen.
### Stufe D Sparten-Durchsetzung
- `division_lead` und optional `division_id` auf Mitgliedschaft/Rolle auswerten bei Schreib-/Lesevorgängen für Objekte mit `division_id`.
- Dokumentieren: Was gilt für Objekte mit `division_id=NULL` innerhalb eines Vereins (vereinsweit vs. nur „ohne Sparte“).
### Stufe E Capabilities dokumentieren (ohne UI für Custom Roles)
- Markdown-Tabelle **Capability-Fingerprint**: Kennungen wie `content.share_club`, `planning.edit_unit`, `org.manage_members`, … mit Zuordnung zu den **heutigen** festen Vereinsrollen.
- Ziel: später `club_custom_roles` nur noch andere Kombination derselben Kennungen keine zweite Philosophie.
### Stufe F Community (eigenes Epic)
- Konzept: Freigabe **additiv** (Flag oder Enum), Moderation, Sichtbarkeit „öffentlich außerhalb meines Vereins“ ohne bestehende `club`-Isolation zu brechen.
### Zurückgestellt Vereinsabo / Limits
- Wiederöffnen wenn ACCESS_LAYER Stufe C/D stabil; dann Enforcement vor ausgewählten Writes an einen Billing-Stripe binden.
---
## 5. Drift vermeiden (Arbeitsdisziplin)
| Mechanismus | Inhalt |
|-------------|--------|
| **Cursor / IDE** | Projektregel `.cursor/rules/access-layer.mdc` (Router); Agents sollen nicht auf „nur require_auth“ ausweichen. |
| **Heuristik-Check** | `python backend/scripts/check_access_layer_hints.py`; CI optional mit `ACCESS_LAYER_STRICT=1`. Optional danach: `cd backend && pytest tests/` (nach `pip install -r requirements-dev.txt`). |
| **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. |
---
## 6. Nächste konkrete Artefakte (nach diesem Plan)
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.
**Audit-Tabelle (fortlaufend):** `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md`
---
## 7. Referenzen
- `.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md` übergeordnetes Zielbild & Begriffe.
- `backend/club_tenancy.py` bestehende Bausteine (`assert_club_member`, `exercise_visible_to_profile`, …); Ziel ist Deren schrittweise Zusammenführung unter die neue Zugriffsschicht ohne Big-Bang.
---
**Letzte Aktualisierung:** 2026-05-05

View File

@ -0,0 +1,225 @@
# Multi-Tenancy, Vereins-Membership und Rollenmodell Zielarchitektur & Umsetzungsplan
**Status:** verbindliche Zielrichtung (Architekturpapier)
**Stand:** 2026-05-05
**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**.
---
## 1. Zweck
Dieses Dokument fasst den **Soll-Zustand** für Mandantenfähigkeit (Verein = Mandant), **rollenbasierte Zugriffskontrolle auf Vereinsebene** und ein **Membership-/Limitsystem** zusammen und definiert einen **phasenweisen Umsetzungsplan**, der mit dem bestehenden Governance-Kern (`visibility`, `club_id`, `created_by`) konsistent bleibt.
---
## 2. Abgleich mit vorhandener Dokumentation
| Quelle | Inhalt relevant für Tenancy/Rollen | Konsistenz mit Zielbild |
|--------|-----------------------------------|-------------------------|
| `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 |
| §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.
---
## 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.
### 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`).
**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.
### 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“.
**Konsequenz:** Weder **Datenisolation** noch **Produktdifferenzierung** „nur Systemadmin legt Verein an“ sind umgesetzt.
### 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.
**Konsequenz:** Planung ist **gruppenzentriert**, nicht **mitgliedschaftszentriert**; Vereinsweite Aufgaben des Vereinsadmins fehlen als konsistentes Recht.
### 3.4 Governance / Sichtbarkeit (kritisch)
- Ü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).
**Konsequenz:** **Cross-Tenant-Leaks** bei als `club` markierten Bibliotheksobjekten sind möglich bzw. Leselogik ist inkonsistent zwischen Modulen.
### 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`.
### 3.6 Membership (kommerziell/limits)
- Mitai-artige Tabellen (`tiers`, `subscriptions`, `tier_limits`, …) sind **nutzerbezogen**, nicht **vereinsbezogen**.
- Kein konzipiertes **`club_subscription` / `club_plan`** im Schema.
---
## 4. Zielarchitektur
### 4.1 Begriffe
| Begriff | Definition |
|---------|------------|
| **Mandant** | Immer ein **Verein** (`clubs.id`). |
| **Systemadmin** | Global (`profiles.role` oder dediziertes Flag); darf Plattform-weite Objekte und **Vereinslifecycle** (Anlegen, Zuweisen Hauptverwalter). |
| **Vereinskontext** | Pro Session gewählter aktiver Verein (`active_club_id`), wenn der User Mitglied ist. |
| **Vereinsmitgliedschaft** | Zeile in einer Junction: User ↔ Verein ↔ **eine oder mehrere Rollen**. |
| **Effektive Berechtigung** | Funktion aus: globale Rolle, Mitgliedschaft im aktiven Verein, optional Sparte/Gruppe, Sichtbarkeit des Objekts. |
### 4.2 Rollenmodell (Schichten)
**Schicht A Plattform (global, kleine Menge)**
- `system_admin` / `superadmin` (bestehende Semantik konsolidieren und benennen).
**Schicht B Verein (pro Mitgliedschaft, mehrere Rollen möglich)**
- `club_admin` Hauptverwalter:in (ein Verein **genau eine** „primäre“ Admin-Zuweisung empfohlen, siehe 4.4).
- `division_lead` Spartenverantwortliche:r (Scope: `division_id` optional an Mitgliedschaft gebunden).
- `trainer` Trainer/Übungsleitung (Abgrenzung zu Co-Trainer siehe Gruppe).
- `content_editor` Redakteur:in / Inhaltsverantwortliche:r (fachlich wie Anforderungsdoc).
**Schicht C Abgeleitet aus Trainingsgruppe (bereits teilweise vorhanden)**
- Haupttrainer / Co-Trainer über `training_groups.trainer_id` und `co_trainer_ids` (und ggf. `lead_trainer_profile_id` auf Einheit).
**Mapping Alt → Neu:** Bestehendes `profiles.role` kann Übergangsweise als „Default-Rolle für Pilotverein“ dienen, soll aber mittelfristig **nicht** die einzige Quelle für Vereinsrechte sein.
### 4.3 Mitgliedschaft und aktiver Verein
- Neue Kernstruktur (konzeptionell):
**`club_members`** (`profile_id`, `club_id`, `status`, `created_at`, …)
**`club_member_roles`** (`club_member_id`, `role_code`, optional `division_id` für spartenbezogene Rollen).
- **Aktiver Verein:**
- Persistenz: Nutzereinstellung (`profiles.default_club_id` oder eigene Tabelle `profile_preferences`).
- Pro Request: Header **`X-Active-Club-Id`** oder Query (einheitlich dokumentieren); Server validiert Mitgliedschaft.
- **Tenant-Switch UI:** Bei Mitgliedschaft in >1 Verein Auswahl im Frontend; alle Listen/Aktionen verwenden aktiven Kontext.
### 4.4 Vereins-Lebenszyklus
- Neuer Verein: **nur Systemadmin**; Pflichtfelder: Name, initialer `club_admin` (bestehendes Profil zuweisen oder Einladungsflow später).
- Vereinsadmin verwaltet in „Mein Verein“: Sparten, Gruppen, Trainerzuordnungen, Einladungen (später), interne Sichtbarkeit **ohne** andere Vereine zu sehen.
### 4.5 Daten- und Funktionssicht
| Datenklasse | Leseregel (Ziel) |
|-------------|------------------|
| Global offiziell | Alle authentifizierten (ggf. später thematisch eingeschränkt). |
| Verein (`visibility=club`, `club_id` gesetzt) | Nur Profile mit Mitgliedschaft in **diesem** `club_id`; optional zusätzlich Sparte, wenn `division_id` am Objekt gepflegt wird. |
| Privat | Nur `created_by` (und explizite Shares später). |
| Geplante Einheiten | Wie heute über Gruppe + Trainer/Co; zusätzlich Vereinskontext zur Navigation/Audit. |
**Einheitlicher Governance-Kern** bleibt wie in CURR-005; Ergänzung: **`division_id`** auf Bibliotheksobjekten, wenn „Sparte“ technisch durchgesetzt werden soll (DOMAIN_MODEL / §17).
### 4.6 Membership-System (Vereinsabo / Limits) Konzept
Ziel: **vereinszentrierte** Vertrags- und Limitlogik, analog zur bestehenden Tier-Infrastuktur, aber an **`club_id`** gebunden.
**Empfohlene Bausteine:**
1. **`club_plans`** Produktdefinition (Name, Features, implizite Limits).
2. **`club_subscriptions`** (`club_id`, `plan_id`, Status, Laufzeit).
3. **`club_usage_counters`** oder Ableitung aus DB z.B. aktive Nutzer, aktive Trainingsgruppen (periodisch oder on-write).
4. **Enforcement-Schicht** zentrale Funktion `assert_club_limit(club_id, metric)` vor `/groups`-POST, Einladungen, etc.
**Offene Produktentscheidungen** (vor Implementierung festlegen):
- Zählen „Nutzer“ alle Mitglieder oder nur aktive Trainer?
- Soft-Limits vs. Hard-Stops; Übergang für Pilotverein.
---
## 5. Umsetzungsplan (Phasen)
### Phase 0 Foundations (kurz, risikoarm)
- Begriffe und Enums in einem Ort dokumentieren (dieses Dokument + Eintrag in `DATABASE_SCHEMA.md` nach Migration).
- Audit-Liste aller Router mit `club_id` / `visibility` / Listen-Endpunkten.
### Phase 1 Datenmodell Mitgliedschaft & Hauptverwalter
- Migration: `club_members`, `club_member_roles`; optional `clubs.primary_admin_profile_id` (oder Primär-Flag auf Mitgliedschaft).
- Backfill: bestehende Trainer aus `training_groups` → minimale Mitgliedschaft `trainer` im jeweiligen Verein (Skript/Migration mit dokumentierter Annahme CURR-008).
- **Breaking/API:** keine nur erweiterte Datenbasis.
### Phase 2 Aktiver Vereinskontext & API-Kontrakte
- Backend: Validierung `X-Active-Club-Id` gegen Mitgliedschaft; Hilfsfunktion `get_effective_club_context(session, header)`.
- `GET /api/clubs` für Nicht-Systemadmins: **nur Vereine mit Mitgliedschaft**.
- `POST /api/clubs`: nur Systemadmin; Vergabe `club_admin`-Mitgliedschaft.
- Frontend: Club-Switcher + Persistenz.
### Phase 3 Effektive Berechtigungen (RBAC)
- **Mitgliederverwaltung per API (ohne UI):** `GET/POST /api/clubs/{club_id}/members`, `GET/PUT/DELETE /api/clubs/{club_id}/members/{profile_id}` — nur Plattform-Admin oder `club_admin` im Zielverein (Stand Code **0.8.16**).
- Zentrale Modulfunktion z.B. `authorization/club_permissions.py`:
`can(club_id, profile_id, permission, division_id=None)` — optional später; aktuell `club_tenancy.can_manage_club_org` / `has_club_role`.
- Router schrittweise umbinden: Sparten/Gruppen CRUD nach Rolle `club_admin` im Kontext; Systemadmin unverändert Vollzugriff.
### Phase 4 Sichtbarkeit & Leaks schließen
- **Übungen:** `club`-Sichtbarkeit nur bei Übereinstimmung `exercise.club_id` mit Mitgliedschaft (und später `division`).
- **Trainingsplan-Vorlagen** (`training_plan_templates`) und **Rahmenprogramme** (`training_framework_programs`): gleiches Muster für Listen/GET (Stand **0.8.17**); Schreiben weiterhin nur Ersteller oder Plattform-Admin.
- Gleiches Muster für Progressionsgraphen, ggf. Medien (offen).
- Tests: zwei Vereine, zwei Nutzer, keine Kreuzzugriffe.
### Phase 5 Mitgliedschaft / Limits
- Tabellen `club_plans`, `club_subscriptions`; Integration mit Enforcement vor relevanten Writes.
- UI „Mein Verein“: Kennzahlenteaser oder Hinweise bei Limit (minimal).
### Phase 6 Verfeinerung
- Einladungsflow (E-Mail), Mehrfachrollen-UI, Audit-Log für Admin-Aktionen.
- Optionale thematische Sperren (Karate vs. Gewaltschutz) als eigene Policy-Schicht.
---
## 6. Abhängigkeiten und Risiken
- **Übergang:** Pilot mit einem Verein nutzt weiterhin einfache Defaults; Multi-Verein erfordert Pflicht **aktiver Kontext**.
- **Performance:** Mitgliedschaft und Rolle sollten **einmal pro Request** geladen und gecacht werden (Request-Scope).
- **Konsistenz mit Mitai:** Nutzer-Tiers können parallel bleiben; **vereinsbezogene** Limits sind die neue Quelle für Shinkan-spezifische Kaufmotive.
---
## 7. Nächste konkrete Artefakte
1. TenantContext-Spezifikation & Endpoint-Audit (siehe `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` §6).
2. Aktualisierung `DATABASE_SCHEMA.md` bei neuen Governance-/Scope-Feldern.
3. Sicherheits-Review der `list_*`-Endpunkte mit `club`-Visibility (fortlaufend bis Governance vereinheitlicht).
---
## 8. Verwandtes Dokument
- **`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`** verbindliche Umsetzungsstufen AF, einheitliche Zugriffsschicht, Scope-Erweiterung (`division`, später Community), Capability-Vorbereitung ohne Custom-Rollen-UI; Vereinsabo explizit zurückgestellt.
---
**Letzte Aktualisierung:** 2026-05-05

View File

@ -0,0 +1,33 @@
# Endpoint-Audit: Mandanten & Governance
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 | `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 | |
| club_join_requests | `/me/club-join-requests`, `/clubs/{id}/join-requests*` | ja | `get_tenant_context` | ja | |
| exercises | `PATCH /api/exercises/bulk-metadata` | ja | `get_tenant_context` | ja | Liste: UI-Mehrfachwahl; bis 500 IDs; nur Ersteller oder Plattform-Admin |
| exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | |
| exercise_progression_graphs | `/api/exercise-progression-graphs*` | ja | `get_tenant_context` | Liste wie Bibliothek; Schreiben Ersteller/Plattform-Admin | Kanten: Lesen wenn Graph lesbar |
| training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage Default club_id |
| training_framework_programs | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Liste + POST Default club_id |
| admin_users | `GET /api/admin/users` | Plattform | `require_auth` | Admin-Rolle | EXEMPT `check_access_layer_hints.py` |
| auth | `/api/auth/*` | nein | — | Login/Session | EXEMPT |
| catalogs | Katalog-CRUD | nein (global) | `require_auth` | Admin/Trainer je Endpoint | EXEMPT; bei späterem `club_id` nachziehen |
| skills | `/api/skills*` | nein (global) | `require_auth` | je Endpoint | EXEMPT |
| maturity_models | Admin-Matrix | nein (global) | `require_auth` | Admin | EXEMPT |
| matrix_stack_bundle | Export/Import Bundles | Plattform/Test | `require_auth` | Admin | EXEMPT |
| import_wiki / import_wiki_admin | Wiki-Import | Werkzeug | `require_auth`/Admin | Admin | EXEMPT |
**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.
---
### Hinweis `GET /training-units`
Kein impliziter Filter nach `effective_club_id` (Multi-Verein-Kalender); bei Bedarf `club_id` Query setzen.

View File

@ -55,6 +55,21 @@ return {"error": "not found"}
return {"message": "Fehler", "success": False}
```
### 1.4 Mandanten & Zugriffsschicht (Shinkan / ACCESS_LAYER)
**Verbindlicher Rahmen:** `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`
**Fortlaufendes Inventar:** `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md`
**Definition of Done für neue oder geänderte geschützte APIs**, sobald Daten **Verein**, **Sichtbarkeit** oder **mandantenbezogene Listen** betreffen:
1. Request-Kontext: `Depends(get_tenant_context)` (oder dokumentierte Ausnahme mit Kommentar `# ACCESS_LAYER exempt:` + Audit).
2. Lesen: gleiche Sichtbarkeitslogik wie vergleichbare Bibliotheks-/Planungsartefakte (`library_content_visibility_sql`, `exercise_visible_to_profile` / zentrale Helfer — nicht ad-hoc „alles aus der Tabelle“).
3. Schreiben: `assert_valid_governance_visibility` wo `visibility` / `club_id` gesetzt oder geändert wird.
4. Audit-Tabelle und bei Bedarf die EXEMPT-Liste im Script `backend/scripts/check_access_layer_hints.py` aktualisieren.
5. Optional vor Commit: `python backend/scripts/check_access_layer_hints.py` (mit `ACCESS_LAYER_STRICT=1` schlägt bei neuen Verstößen fehl).
Router ohne Vereinsbezug (z.B. globale Kataloge, Auth-Login) bleiben bewusst ohne `get_tenant_context`; sie stehen im Script auf der **EXEMPT**-Liste.
---
## 2. Versionskontrollsystem

View File

@ -4,17 +4,32 @@ Diese Regeln IMMER befolgen. Sie basieren auf Erfahrungen aus der Entwicklung.
## Backend
### 1. Auth auf jeden Endpoint
### 1. Auth und Mandantenkontext (Shinkan)
**Jeder geschützte Endpoint braucht Auth.** Sofern der Endpoint **Vereinsdaten**, **visibility/club_id** oder **mandanten-gefilterte Listen** betrifft, zusätzlich **`TenantContext`** — nicht nur `require_auth` allein.
```python
# Jeder neue Endpoint braucht Auth:
@router.get("/neuer-endpoint")
def neuer_endpoint(session: dict = Depends(require_auth)):
pid = session['profile_id']
from tenant_context import TenantContext, get_tenant_context
@router.get("/beispiel")
def beispiel(tenant: TenantContext = Depends(get_tenant_context)):
pid = tenant.profile_id
role = tenant.global_role
club_ctx = tenant.effective_club_id # kann None sein (z. B. Plattform-Admin)
```
### 2. Profile-ID aus Session nie aus Header
- **Bibliotheks-/Planungslisten:** Filter wie bestehende Module (`library_content_visibility_sql` oder gleiche Leseprüfung); keine vollständige Tabelle für normale Nutzer.
- **Schreiben:** `assert_valid_governance_visibility` aus `club_tenancy`, wenn `visibility` / `club_id` gesetzt werden.
- **Dokumentation:** Änderungen in `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md` festhalten.
- **Ausnahmen** (z.B. reiner Login, globale Kataloge): Kommentar `# ACCESS_LAYER exempt: …` und ggf. Eintrag in `backend/scripts/check_access_layer_hints.py`.
Reine Plattform-Admin-Router (ohne Vereinskontext) können bei Bedarf weiter `Depends(require_auth)` nutzen — dann im Audit als „Plattform“ kennzeichnen.
### 2. Profile-ID aus TenantContext oder Session — nie aus freiem Header
```python
pid = session['profile_id'] # ✅
pid = tenant.profile_id # ✅ bei Depends(get_tenant_context)
# oder session['profile_id'] nur wenn Endpoint ausdrücklich ohne TenantContext (Ausnahme dokumentieren)
# Nicht: request.headers.get('X-Profile-Id') ❌
```

View File

@ -0,0 +1,36 @@
---
description: Mandanten & Governance — TenantContext, Sichtbarkeit, keine Schnellpfade
globs: backend/routers/*.py
alwaysApply: false
---
# Zugriffsschicht (Shinkan)
Vor neuen oder geänderten Endpoints in `backend/routers/` kurz prüfen:
1. **Pflichtlektüre** (bei inhaltsbezogenen APIs): `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` und `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md`.
2. **Auth**: kein geschützter Endpoint ohne `Depends(require_auth)` bzw. eingebettet in `Depends(get_tenant_context)` (der holt die Session bereits).
3. **Verein / Sichtbarkeit** (`visibility`, `club_id`, Mitglieder-Inhalte):
`tenant: TenantContext = Depends(get_tenant_context)` verwenden; Profile-ID aus `tenant.profile_id`, Rolle aus `tenant.global_role`.
4. **Bibliothekslisten** (Übungen, Vorlagen, Rahmenprogramme, Progressionsgraphen, gleiche Semantik): Filter über `library_content_visibility_sql(...)` bzw. gleiche Leseregel wie bestehende Module — nicht „alle Zeilen aus SELECT“.
5. **Schreiben mit Governance**: bei `visibility`/`club_id`-Änderungen `assert_valid_governance_visibility`; bei `club` ohne `club_id` im Body → `tenant.effective_club_id` oder klare 400-Hinweise (wie bei Übungen).
6. **Ausnahmen** nur mit kurzem Kommentar im Code: `# ACCESS_LAYER exempt: …` und Eintrag im Audit oder in `backend/scripts/check_access_layer_hints.py` (EXEMPT-Liste).
7. **Nach Merge**: eine Zeile `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md` anpassen, wenn sich Tenant oder Governance ändert.
```python
# ❌ Schnellpfad: nur Session, obwohl Vereinsdaten betroffen
@router.get("/foo")
def foo(session=Depends(require_auth)):
...
# ✅ Konsistent zu clubs/exercises/planning
@router.get("/foo")
def foo(tenant: TenantContext = Depends(get_tenant_context)):
...
```

View File

@ -5,6 +5,9 @@
> VOR jeder Implementierung lesen:
> | Architektur-Regeln | `.claude/rules/ARCHITECTURE.md` |
> | Coding-Regeln | `.claude/rules/CODING_RULES.md` |
> | Zugriffsschicht (Multi-Tenancy, Governance) | `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` |
> | Endpoint-Audit (Tenant/Governance) | `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md` |
> | Cursor-Regel Zugriffsschicht | `.cursor/rules/access-layer.mdc` |
> | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` |
> | Setup-Dokument | `.claude/docs/working/SHINKAN_PROJECT_SETUP.md` |
> | Anforderungen | `.claude/docs/functional/SHINKAN_REQUIREMENTS.md` |

173
backend/club_tenancy.py Normal file
View File

@ -0,0 +1,173 @@
"""
Vereins-Mandanten: Mitgliedschaften, aktiver Vereinskontext, einfache Berechtigungen.
Siehe .claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md
"""
from typing import Any, Dict, List, Optional, Set
from fastapi import HTTPException
def is_platform_admin(role: Optional[str]) -> bool:
return (role or "").lower() in ("admin", "superadmin")
def club_ids_for_profile(cur, profile_id: int) -> Set[int]:
cur.execute(
"""
SELECT club_id FROM club_members
WHERE profile_id = %s AND status = 'active'
""",
(profile_id,),
)
return {r["club_id"] for r in cur.fetchall()}
def assert_club_member(cur, profile_id: int, club_id: int) -> None:
cur.execute(
"""
SELECT 1 FROM club_members
WHERE profile_id = %s AND club_id = %s AND status = 'active'
LIMIT 1
""",
(profile_id, club_id),
)
if not cur.fetchone():
raise HTTPException(status_code=403, detail="Keine Mitgliedschaft in diesem Verein")
def has_club_role(cur, profile_id: int, club_id: int, *role_codes: str) -> bool:
if not role_codes:
return False
cur.execute(
"""
SELECT 1
FROM club_members cm
INNER JOIN club_member_roles r ON r.club_member_id = cm.id
WHERE cm.profile_id = %s AND cm.club_id = %s AND cm.status = 'active'
AND r.role_code IN ({ph})
LIMIT 1
""".format(ph=",".join(["%s"] * len(role_codes))),
(profile_id, club_id, *role_codes),
)
return cur.fetchone() is not None
def can_manage_club_org(cur, profile_id: int, club_id: int, global_role: Optional[str]) -> bool:
"""Sparten/Gruppen/Struktur: Vereinsadmin oder Plattform-Admin."""
if is_platform_admin(global_role):
return True
return has_club_role(cur, profile_id, club_id, "club_admin")
def can_plan_in_club(cur, profile_id: int, club_id: int, global_role: Optional[str]) -> bool:
"""Trainingsgruppen anlegen / planen: Admin-Rollen im Verein oder Plattform."""
if is_platform_admin(global_role):
return True
return has_club_role(
cur, profile_id, club_id, "club_admin", "trainer", "content_editor", "division_lead"
)
def memberships_with_roles(cur, profile_id: int, active_only: bool = True) -> List[Dict[str, Any]]:
status_filter = "AND cm.status = 'active'" if active_only else ""
cur.execute(
f"""
SELECT c.id, c.name, c.abbreviation, c.status,
cm.status AS membership_status,
COALESCE(
ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL),
ARRAY[]::varchar[]
) AS roles
FROM club_members cm
INNER JOIN clubs c ON c.id = cm.club_id
LEFT JOIN club_member_roles r ON r.club_member_id = cm.id
WHERE cm.profile_id = %s {status_filter}
GROUP BY c.id, c.name, c.abbreviation, c.status, cm.status
ORDER BY c.name
""",
(profile_id,),
)
out: List[Dict[str, Any]] = []
for row in cur.fetchall():
d = dict(row)
roles = d.get("roles") or []
if hasattr(roles, "tolist"):
roles = roles.tolist()
d["roles"] = list(roles)
out.append(d)
return out
_GOVERNANCE_VISIBILITY = frozenset({"private", "club", "official"})
def assert_valid_governance_visibility(
cur,
profile_id: int,
role: Optional[str],
visibility: str,
club_id: Optional[int],
) -> None:
"""Pflicht club_id bei visibility=club; Mitgliedschaft außer Plattform-Admin; official nur Plattform-Admin."""
if visibility not in _GOVERNANCE_VISIBILITY:
raise HTTPException(status_code=400, detail="Ungültige visibility")
if visibility == "official" and not is_platform_admin(role):
raise HTTPException(
status_code=403,
detail="Nur Plattform-Admins dürfen offizielle Inhalte setzen",
)
if visibility == "club":
if club_id is None:
raise HTTPException(status_code=400, detail="club_id ist bei visibility=club erforderlich")
if is_platform_admin(role):
cur.execute("SELECT 1 FROM clubs WHERE id = %s", (club_id,))
if not cur.fetchone():
raise HTTPException(status_code=400, detail="Verein nicht gefunden")
else:
assert_club_member(cur, profile_id, club_id)
def library_content_visible_to_profile(
cur,
profile_id: int,
visibility: str,
content_club_id: Optional[int],
created_by: Optional[int],
global_role: Optional[str],
) -> bool:
"""Leserechte wie Übungen für alle Objekte mit visibility/club_id/created_by (Bibliothek & Co.)."""
return exercise_visible_to_profile(
cur, profile_id, visibility, content_club_id, created_by, global_role
)
def exercise_visible_to_profile(
cur,
profile_id: int,
visibility: str,
exercise_club_id: Optional[int],
created_by: Optional[int],
global_role: Optional[str],
) -> bool:
"""Leserechte einer Übung. Für neue Codepfade lieber `library_content_visible_to_profile` verwenden."""
if is_platform_admin(global_role):
return True
if visibility == "official":
return True
if created_by is not None and created_by == profile_id:
return True
if visibility == "private":
return False
if visibility == "club":
if exercise_club_id is None:
return False
cur.execute(
"""
SELECT 1 FROM club_members
WHERE profile_id = %s AND club_id = %s AND status = 'active'
LIMIT 1
""",
(profile_id, exercise_club_id),
)
return cur.fetchone() is not None
return False

View File

@ -32,13 +32,13 @@ def wait_for_postgres(max_retries=30):
try:
conn = get_connection()
conn.close()
print(" PostgreSQL ready")
print("[OK] PostgreSQL ready")
return True
except OperationalError:
print(f" Waiting for PostgreSQL... (attempt {i}/{max_retries})")
time.sleep(2)
print(f" PostgreSQL not ready after {max_retries} attempts")
print(f"[FAIL] PostgreSQL not ready after {max_retries} attempts")
return False
def check_table_exists(table_name="profiles"):
@ -71,10 +71,10 @@ def load_schema(schema_file="/app/schema.sql"):
conn.commit()
cur.close()
conn.close()
print(" Schema loaded from schema.sql")
print("[OK] Schema loaded from schema.sql")
return True
except Exception as e:
print(f" Error loading schema: {e}")
print(f"[FAIL] Error loading schema: {e}")
return False
def get_profile_count():
@ -146,10 +146,10 @@ def apply_migration(filepath, filename):
conn.commit()
cur.close()
conn.close()
print(f" Applied: {filename}")
print(f" [OK] Applied: {filename}")
return True
except Exception as e:
print(f" Failed to apply {filename}: {e}")
print(f" [FAIL] Failed to apply {filename}: {e}")
return False
def run_migrations(migrations_dir="/app/migrations"):
@ -158,7 +158,7 @@ def run_migrations(migrations_dir="/app/migrations"):
import re
if not os.path.exists(migrations_dir):
print(" No migrations directory found")
print("[OK] No migrations directory found")
return True
# Ensure migration tracking table exists
@ -174,7 +174,7 @@ def run_migrations(migrations_dir="/app/migrations"):
migration_files = [f for f in all_files if migration_pattern.match(os.path.basename(f))]
if not migration_files:
print(" No migration files found")
print("[OK] No migration files found")
return True
# Apply pending migrations
@ -185,7 +185,7 @@ def run_migrations(migrations_dir="/app/migrations"):
pending.append((filepath, filename))
if not pending:
print(f" All {len(applied)} migrations already applied")
print(f"[OK] All {len(applied)} migrations already applied")
return True
print(f" Found {len(pending)} pending migration(s)...")
@ -211,12 +211,12 @@ if __name__ == "__main__":
if not load_schema():
sys.exit(1)
else:
print(" Schema already exists")
print("[OK] Schema already exists")
# Run migrations
print("\nRunning database migrations...")
if not run_migrations():
print(" Migration failed")
print("[FAIL] Migration failed")
sys.exit(1)
# Check for migration
@ -232,14 +232,14 @@ if __name__ == "__main__":
from migrate_to_postgres import main as migrate
migrate()
except Exception as e:
print(f" Migration failed: {e}")
print(f"[FAIL] Migration failed: {e}")
sys.exit(1)
elif os.path.exists(sqlite_db) and profile_count > 0:
print(f" SQLite DB exists but PostgreSQL already has {profile_count} profiles")
print(f"[WARN] SQLite DB exists but PostgreSQL already has {profile_count} profiles")
print(" Skipping migration (already migrated)")
elif not os.path.exists(sqlite_db):
print(" No SQLite database found (fresh install or already migrated)")
print("[OK] No SQLite database found (fresh install or already migrated)")
else:
print(" No migration needed")
print("[OK] No migration needed")
print("\n Database initialization complete")
print("\n[OK] Database initialization complete")

View File

@ -21,20 +21,20 @@ from version import APP_VERSION, BUILD_DATE, DB_SCHEMA_VERSION, MODULE_VERSIONS
# Run database migrations before API start — halbes Schema ist schlimmer als kein Start
# Lokal ohne DB / nur Tests: SKIP_DB_MIGRATE=1
if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() in ("1", "true", "yes"):
print("⚠ SKIP_DB_MIGRATE=1 — Migrationen wurden übersprungen (nur für Entwicklung ohne DB)")
print("[SKIP_DB_MIGRATE] Migrationen uebersprungen (nur fuer Entwicklung ohne DB)")
else:
try:
import run_migrations
rc = run_migrations.main()
if rc != 0:
print(f" Datenbank-Migration fehlgeschlagen (Exit-Code {rc}). Start abgebrochen.")
print(f"[FAIL] Datenbank-Migration fehlgeschlagen (Exit-Code {rc}). Start abgebrochen.")
sys.exit(1)
print(" Database migrations completed")
print("[OK] Database migrations completed")
except SystemExit:
raise
except Exception as e:
print(f" Migration-Laufzeitfehler: {e}")
print(f"[FAIL] Migration-Laufzeitfehler: {e}")
sys.exit(1)
from routers.auth import limiter as auth_rate_limiter
@ -99,6 +99,8 @@ def health_ready():
"skill_categories",
"maturity_models",
"sessions",
"club_members",
"club_member_roles",
)
tables: dict = {}
err: Optional[str] = None
@ -152,13 +154,16 @@ def read_root():
}
# Register routers
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin
app.include_router(auth.router)
app.include_router(profiles.router)
app.include_router(exercises.router)
app.include_router(exercise_progression_graphs.router)
app.include_router(clubs.router)
app.include_router(club_memberships.router)
app.include_router(club_join_requests.router)
app.include_router(admin_users.router)
app.include_router(skills.router)
app.include_router(training_planning.router)
app.include_router(training_framework_programs.router)

View File

@ -0,0 +1,119 @@
-- Migration 039: Vereins-Mitgliedschaft, Rollen pro Verein, aktiver Vereinskontext (Multi-Tenancy Phase 1)
-- Erstellt: 2026-05-05
-- Mitgliedschaft Profil ↔ Verein
CREATE TABLE club_members (
id SERIAL PRIMARY KEY,
profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(profile_id, club_id)
);
CREATE INDEX idx_club_members_profile ON club_members(profile_id);
CREATE INDEX idx_club_members_club ON club_members(club_id);
CREATE INDEX idx_club_members_status ON club_members(status);
-- Rollen pro Mitgliedschaft (role_code: club_admin, trainer, division_lead, content_editor)
CREATE TABLE club_member_roles (
id SERIAL PRIMARY KEY,
club_member_id INT NOT NULL REFERENCES club_members(id) ON DELETE CASCADE,
role_code VARCHAR(50) NOT NULL,
division_id INT REFERENCES divisions(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE (club_member_id, role_code)
);
CREATE INDEX idx_club_member_roles_member ON club_member_roles(club_member_id);
CREATE INDEX idx_club_member_roles_code ON club_member_roles(role_code);
-- Hauptverwalter:in (fachliche Referenz; Rechte über club_member_roles.club_admin)
ALTER TABLE clubs ADD COLUMN IF NOT EXISTS primary_admin_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL;
CREATE INDEX idx_clubs_primary_admin ON clubs(primary_admin_profile_id);
-- Persistenz gewählter aktiver Verein (UI); Request-Header kann überschreiben
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS active_club_id INT REFERENCES clubs(id) ON DELETE SET NULL;
CREATE INDEX idx_profiles_active_club ON profiles(active_club_id);
-- ── Backfill: Trainer/Co-Trainer aus Trainingsgruppen → Mitgliedschaft + Rolle trainer
INSERT INTO club_members (profile_id, club_id, status)
SELECT DISTINCT t.trainer_id, t.club_id, 'active'
FROM training_groups t
WHERE t.trainer_id IS NOT NULL
ON CONFLICT (profile_id, club_id) DO NOTHING;
INSERT INTO club_members (profile_id, club_id, status)
SELECT DISTINCT elem::int, x.club_id, 'active'
FROM (
SELECT club_id, co_trainer_ids
FROM training_groups
WHERE CASE WHEN jsonb_typeof(co_trainer_ids) = 'array'
THEN jsonb_array_length(co_trainer_ids)
ELSE 0 END > 0
) x,
LATERAL jsonb_array_elements_text(x.co_trainer_ids) AS elem
ON CONFLICT (profile_id, club_id) DO NOTHING;
INSERT INTO club_member_roles (club_member_id, role_code)
SELECT cm.id, 'trainer'
FROM club_members cm
WHERE NOT EXISTS (
SELECT 1 FROM club_member_roles r
WHERE r.club_member_id = cm.id AND r.role_code = 'trainer'
);
-- Pro Verein: kleinste trainer_id einer Gruppe als primärer Admin (Pilot / CURR-008-Analog)
UPDATE clubs c
SET primary_admin_profile_id = sub.pid
FROM (
SELECT DISTINCT ON (club_id)
club_id,
trainer_id AS pid
FROM training_groups
WHERE trainer_id IS NOT NULL
ORDER BY club_id, id ASC
) sub
WHERE c.id = sub.club_id
AND c.primary_admin_profile_id IS NULL;
INSERT INTO club_member_roles (club_member_id, role_code)
SELECT cm.id, 'club_admin'
FROM club_members cm
INNER JOIN clubs cl ON cl.id = cm.club_id AND cl.primary_admin_profile_id = cm.profile_id
WHERE NOT EXISTS (
SELECT 1 FROM club_member_roles r
WHERE r.club_member_id = cm.id AND r.role_code = 'club_admin'
);
-- Globale Admins: in jedem bestehenden Verein als club_admin spiegeln (volle Plattform-Sicht)
INSERT INTO club_members (profile_id, club_id, status)
SELECT p.id, c.id, 'active'
FROM profiles p
CROSS JOIN clubs c
WHERE p.role IN ('admin', 'superadmin')
ON CONFLICT (profile_id, club_id) DO NOTHING;
INSERT INTO club_member_roles (club_member_id, role_code)
SELECT cm.id, 'club_admin'
FROM club_members cm
INNER JOIN profiles p ON p.id = cm.profile_id AND p.role IN ('admin', 'superadmin')
WHERE NOT EXISTS (
SELECT 1 FROM club_member_roles r
WHERE r.club_member_id = cm.id AND r.role_code = 'club_admin'
);
-- Default aktiver Verein: einziger Verein des Nutzers
UPDATE profiles p
SET active_club_id = sub.only_club
FROM (
SELECT profile_id, MIN(club_id) AS only_club
FROM club_members
WHERE status = 'active'
GROUP BY profile_id
HAVING COUNT(DISTINCT club_id) = 1
) sub
WHERE p.id = sub.profile_id AND (p.active_club_id IS NULL);

View File

@ -0,0 +1,29 @@
-- Migration 040: Antrag auf Vereinsbeitritt (pending → accept/reject durch Vereins-/Plattform-Admin)
CREATE TABLE IF NOT EXISTS club_membership_requests (
id SERIAL PRIMARY KEY,
profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted', 'rejected', 'withdrawn')),
message TEXT,
decided_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
decided_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_membership_requests_pending
ON club_membership_requests (profile_id, club_id)
WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_club_membership_requests_club_status
ON club_membership_requests (club_id, status);
CREATE INDEX IF NOT EXISTS idx_club_membership_requests_profile
ON club_membership_requests (profile_id);
DROP TRIGGER IF EXISTS club_membership_requests_update ON club_membership_requests;
CREATE TRIGGER club_membership_requests_update
BEFORE UPDATE ON club_membership_requests
FOR EACH ROW EXECUTE FUNCTION update_timestamp();

View File

@ -0,0 +1,20 @@
-- Migration 041: Super-Admin für bestehende Installationen
-- Bisheriger Bootstrap (registration_role) setzte nur 'admin'. Viele Endpunkte
-- (z. B. Verein löschen, Super-Rolle vergeben) verlangen 'superadmin'.
-- Wenn noch kein Super-Admin existiert: den ältesten Nutzer mit role = admin hochstufen.
UPDATE profiles p
SET role = 'superadmin', updated_at = NOW()
FROM (
SELECT id
FROM profiles
WHERE lower(trim(COALESCE(role, ''))) = 'admin'
ORDER BY id ASC
LIMIT 1
) sub
WHERE p.id = sub.id
AND NOT EXISTS (
SELECT 1
FROM profiles
WHERE lower(trim(COALESCE(role, ''))) = 'superadmin'
);

View File

@ -19,6 +19,7 @@ class RegisterRequest(BaseModel):
email: EmailStr
password: str
name: Optional[str] = None
requested_club_id: Optional[int] = Field(default=None, ge=1)
class PasswordResetRequest(BaseModel):
email: str
@ -34,6 +35,12 @@ class ProfileCreate(BaseModel):
class ProfileUpdate(BaseModel):
name: Optional[str] = None
email: Optional[str] = None
active_club_id: Optional[int] = None
role: Optional[str] = Field(
default=None,
description="Portal-Rolle: user, trainer, admin, superadmin (nur Plattform-Admin)",
)
tier: Optional[str] = Field(default=None, max_length=50)
class ProfileResponse(BaseModel):
id: int

3
backend/pytest.ini Normal file
View File

@ -0,0 +1,3 @@
[pytest]
testpaths = tests
pythonpath = .

View File

@ -0,0 +1,3 @@
# Entwicklung / CI — zusätzlich zu backend/requirements.txt installieren:
# pip install -r backend/requirements.txt -r backend/requirements-dev.txt
pytest>=8.0,<9

View File

@ -0,0 +1,41 @@
"""
Plattform-Admin: Übersicht aller Nutzer inkl. Vereinsmitgliedschaften (ohne Passwort-Hashes).
"""
from typing import Any, Dict, List
from fastapi import APIRouter, Depends, HTTPException
from auth import require_auth
from club_tenancy import is_platform_admin, memberships_with_roles
from db import get_db, get_cursor, r2d
router = APIRouter(prefix="/api/admin", tags=["admin_users"])
_SAFE_PROFILE_COLS = """
id, name, email, role, tier, email_verified, active_club_id,
created_at, updated_at, auth_type
"""
@router.get("/users")
def list_platform_users(session: dict = Depends(require_auth)):
"""Alle Profile mit Vereinen/Rollen — nur Portal-Admin (admin oder superadmin)."""
role = (session.get("role") or "").lower()
if not is_platform_admin(role):
raise HTTPException(status_code=403, detail="Nur Portal-Administratoren")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
f"""
SELECT {_SAFE_PROFILE_COLS.strip()}
FROM profiles
ORDER BY COALESCE(lower(trim(email)), ''), id
"""
)
rows: List[Dict[str, Any]] = []
for r in cur.fetchall():
d = r2d(r)
d["clubs"] = memberships_with_roles(cur, int(d["id"]), active_only=False)
rows.append(d)
return rows

View File

@ -26,7 +26,7 @@ limiter = Limiter(key_func=get_remote_address)
@router.post("/login")
@limiter.limit("5/minute")
@limiter.limit("30/minute")
async def login(req: LoginRequest, request: Request):
"""Login with email + password."""
with get_db() as conn:
@ -78,8 +78,8 @@ def get_me(session: dict=Depends(require_auth)):
"""Get current user info."""
pid = session['profile_id']
# Import here to avoid circular dependency
from routers.profiles import get_profile
return get_profile(pid, session)
from routers.profiles import profile_document
return profile_document(pid)
@router.get("/status")
@ -187,9 +187,10 @@ def verification_link(token: str) -> str:
def registration_role(cur, email_lower: str) -> str:
"""
bootstrap: erste Registrierung in leerer DB admin,
bootstrap: erste Registrierung in leerer DB superadmin,
oder E-Mail ADMIN_BOOTSTRAP_EMAILS (kommasepariert, Groß/Klein egal).
superadmin deckt alle Portal-Rechte ab (inkl. löschen / andere Super-Admins).
Um alle Self-Regs als Trainer zu haben: AUTO_ADMIN_FIRST_USER=false und keine ADMIN_BOOTSTRAP_EMAILS.
"""
bootstrap = {
@ -198,7 +199,7 @@ def registration_role(cur, email_lower: str) -> str:
if x.strip()
}
if email_lower in bootstrap:
return "admin"
return "superadmin"
if os.getenv("AUTO_ADMIN_FIRST_USER", "true").strip().lower() not in ("1", "true", "yes"):
return "trainer"
cur.execute("SELECT COUNT(*) AS n FROM profiles")
@ -207,7 +208,7 @@ def registration_role(cur, email_lower: str) -> str:
n = int(row["n"]) if row is not None else 0
except (KeyError, TypeError, ValueError):
n = 0
return "admin" if n == 0 else "trainer"
return "superadmin" if n == 0 else "trainer"
# ── Helper: Send Email ────────────────────────────────────────────────────────
@ -289,7 +290,7 @@ async def register(req: RegisterRequest, request: Request):
verification_token = secrets.token_urlsafe(32)
verification_expires = datetime.now(timezone.utc) + timedelta(hours=24)
# Rolle: erster Nutzer oder ADMIN_BOOTSTRAP_EMAILS → admin
# Rolle: erster Nutzer oder ADMIN_BOOTSTRAP_EMAILS → superadmin
role = registration_role(cur, email)
# Create profile (inactive until verified) — profiles.id ist SERIAL (INT), keine String-IDs einfügen.
@ -302,7 +303,33 @@ async def register(req: RegisterRequest, request: Request):
email_verified, verification_token, verification_expires,
trial_ends_at, created_at
) VALUES (%s, %s, %s, 'email', %s, 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP)
RETURNING id
""", (name, email, pin_hash, role, verification_token, verification_expires, trial_ends))
new_profile_id = cur.fetchone()["id"]
req_club = req.requested_club_id
if req_club is not None:
cur.execute(
"SELECT id FROM clubs WHERE id = %s AND status = 'active'",
(int(req_club),),
)
if cur.fetchone():
cur.execute(
"""
SELECT id FROM club_membership_requests
WHERE profile_id = %s AND club_id = %s AND status = 'pending'
LIMIT 1
""",
(new_profile_id, int(req_club)),
)
if not cur.fetchone():
cur.execute(
"""
INSERT INTO club_membership_requests (profile_id, club_id, status, message)
VALUES (%s, %s, 'pending', NULL)
""",
(new_profile_id, int(req_club)),
)
verify_url = verification_link(verification_token)

View File

@ -0,0 +1,281 @@
"""
Anträge auf Vereinsbeitritt: Nutzer stellt Antrag, Vereins-/Plattform-Admin nimmt an oder lehnt ab.
"""
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from club_tenancy import can_manage_club_org
from db import get_db, get_cursor, r2d
from tenant_context import TenantContext, get_tenant_context
router = APIRouter(prefix="/api", tags=["club_join_requests"])
_ALLOWED_MEMBER_ROLES = frozenset({"club_admin", "trainer", "division_lead", "content_editor"})
def _normalize_roles(raw: List[str]) -> List[str]:
out: List[str] = []
seen = set()
for r in raw:
if not isinstance(r, str):
raise HTTPException(status_code=400, detail="Rollen müssen Strings sein")
code = r.strip().lower()
if not code or code not in _ALLOWED_MEMBER_ROLES:
raise HTTPException(status_code=400, detail=f"Unbekannte Rolle: {code}")
if code not in seen:
seen.add(code)
out.append(code)
return out
def _club_active(cur, club_id: int) -> bool:
cur.execute("SELECT 1 FROM clubs WHERE id = %s AND status = 'active'", (club_id,))
return cur.fetchone() is not None
def _assert_manage_club(cur, tenant: TenantContext, club_id: int) -> None:
pid = tenant.profile_id
role = tenant.global_role
if not can_manage_club_org(cur, pid, club_id, role):
raise HTTPException(status_code=403, detail="Keine Berechtigung für Mitglieder-Verwaltung in diesem Verein")
def _is_active_member(cur, profile_id: int, club_id: int) -> bool:
cur.execute(
"""
SELECT 1 FROM club_members
WHERE profile_id = %s AND club_id = %s AND status = 'active'
LIMIT 1
""",
(profile_id, club_id),
)
return cur.fetchone() is not None
def _upsert_active_member_with_roles(cur, club_id: int, profile_id: int, roles: List[str]) -> None:
roles_n = _normalize_roles(roles)
if not roles_n:
raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben")
cur.execute(
"""
INSERT INTO club_members (profile_id, club_id, status)
VALUES (%s, %s, 'active')
ON CONFLICT (profile_id, club_id)
DO UPDATE SET status = 'active', updated_at = NOW()
RETURNING id
""",
(profile_id, club_id),
)
cm_id = cur.fetchone()["id"]
cur.execute("DELETE FROM club_member_roles WHERE club_member_id = %s", (cm_id,))
for rc in roles_n:
cur.execute(
"""
INSERT INTO club_member_roles (club_member_id, role_code)
VALUES (%s, %s)
ON CONFLICT (club_member_id, role_code) DO NOTHING
""",
(cm_id, rc),
)
class JoinRequestCreate(BaseModel):
club_id: int = Field(..., ge=1)
message: Optional[str] = Field(None, max_length=2000)
class JoinRequestAccept(BaseModel):
roles: List[str] = Field(default_factory=lambda: ["trainer"])
def _response_one(cur, req_id: int, viewer_profile_id: int) -> Dict[str, Any]:
cur.execute(
"""
SELECT r.*, c.name AS club_name, c.abbreviation AS club_abbreviation
FROM club_membership_requests r
INNER JOIN clubs c ON c.id = r.club_id
WHERE r.id = %s AND r.profile_id = %s
""",
(req_id, viewer_profile_id),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Antrag nicht gefunden")
return r2d(row)
@router.get("/me/club-join-requests")
def get_my_join_requests(tenant: TenantContext = Depends(get_tenant_context)):
pid = tenant.profile_id
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT r.*, c.name AS club_name, c.abbreviation AS club_abbreviation
FROM club_membership_requests r
INNER JOIN clubs c ON c.id = r.club_id
WHERE r.profile_id = %s
ORDER BY r.created_at DESC
LIMIT 100
""",
(pid,),
)
return [r2d(r) for r in cur.fetchall()]
@router.post("/me/club-join-requests", status_code=201)
def create_my_join_request(body: JoinRequestCreate, tenant: TenantContext = Depends(get_tenant_context)):
"""Antrag stellen (nicht möglich wenn bereits aktives Mitglied)."""
pid = tenant.profile_id
msg = (body.message or "").strip() or None
cid = body.club_id
with get_db() as conn:
cur = get_cursor(conn)
if not _club_active(cur, cid):
raise HTTPException(status_code=404, detail="Verein nicht gefunden oder nicht aktiv")
if _is_active_member(cur, pid, cid):
raise HTTPException(status_code=400, detail="Du bist bereits Mitglied in diesem Verein")
cur.execute(
"""
SELECT id FROM club_membership_requests
WHERE profile_id = %s AND club_id = %s AND status = 'pending'
LIMIT 1
""",
(pid, cid),
)
if cur.fetchone():
raise HTTPException(status_code=409, detail="Für diesen Verein liegt bereits ein offener Antrag vor")
cur.execute(
"""
INSERT INTO club_membership_requests (profile_id, club_id, status, message)
VALUES (%s, %s, 'pending', %s)
RETURNING id
""",
(pid, cid, msg),
)
rid = cur.fetchone()["id"]
conn.commit()
with get_db() as conn:
cur = get_cursor(conn)
return _response_one(cur, rid, pid)
@router.delete("/me/club-join-requests/{request_id}")
def withdraw_my_join_request(request_id: int, tenant: TenantContext = Depends(get_tenant_context)):
pid = tenant.profile_id
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
UPDATE club_membership_requests
SET status = 'withdrawn', updated_at = NOW()
WHERE id = %s AND profile_id = %s AND status = 'pending'
RETURNING id
""",
(request_id, pid),
)
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Offener Antrag nicht gefunden")
conn.commit()
return {"ok": True}
@router.get("/clubs/{club_id}/join-requests")
def list_club_join_requests(club_id: int, tenant: TenantContext = Depends(get_tenant_context)):
"""Offene Anträge für einen Verein (Vereins-/Plattform-Admin)."""
with get_db() as conn:
cur = get_cursor(conn)
_assert_manage_club(cur, tenant, club_id)
cur.execute(
"""
SELECT r.*, p.email AS applicant_email, p.name AS applicant_name
FROM club_membership_requests r
INNER JOIN profiles p ON p.id = r.profile_id
WHERE r.club_id = %s AND r.status = 'pending'
ORDER BY r.created_at ASC
""",
(club_id,),
)
return [r2d(r) for r in cur.fetchall()]
@router.post("/clubs/{club_id}/join-requests/{request_id}/accept")
def accept_club_join_request(
club_id: int,
request_id: int,
body: JoinRequestAccept,
tenant: TenantContext = Depends(get_tenant_context),
):
admin_pid = tenant.profile_id
roles = _normalize_roles(body.roles)
with get_db() as conn:
cur = get_cursor(conn)
_assert_manage_club(cur, tenant, club_id)
cur.execute(
"""
SELECT id, profile_id, status FROM club_membership_requests
WHERE id = %s AND club_id = %s
""",
(request_id, club_id),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Antrag nicht gefunden")
if row["status"] != "pending":
raise HTTPException(status_code=400, detail="Antrag ist nicht mehr offen")
applicant_id = row["profile_id"]
cur.execute(
"""
UPDATE club_membership_requests
SET status = 'accepted',
decided_by_profile_id = %s,
decided_at = NOW(),
updated_at = NOW()
WHERE id = %s AND club_id = %s AND status = 'pending'
RETURNING id
""",
(admin_pid, request_id, club_id),
)
if not cur.fetchone():
raise HTTPException(status_code=409, detail="Antrag konnte nicht angenommen werden")
_upsert_active_member_with_roles(cur, club_id, applicant_id, roles)
conn.commit()
return {"ok": True, "profile_id": applicant_id, "club_id": club_id}
@router.post("/clubs/{club_id}/join-requests/{request_id}/reject")
def reject_club_join_request(club_id: int, request_id: int, tenant: TenantContext = Depends(get_tenant_context)):
admin_pid = tenant.profile_id
with get_db() as conn:
cur = get_cursor(conn)
_assert_manage_club(cur, tenant, club_id)
cur.execute(
"""
UPDATE club_membership_requests
SET status = 'rejected',
decided_by_profile_id = %s,
decided_at = NOW(),
updated_at = NOW()
WHERE id = %s AND club_id = %s AND status = 'pending'
RETURNING id
""",
(admin_pid, request_id, club_id),
)
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Offener Antrag nicht gefunden")
conn.commit()
return {"ok": True}

View File

@ -0,0 +1,268 @@
"""
Vereins-Mitgliedschaften und Rollen (ohne UI nutzbar für Admin/Automatisierung).
Berechtigung: Plattform-Admin (admin/superadmin) oder Vereinsadmin (club_admin) im Zielverein.
"""
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from club_tenancy import can_manage_club_org
from db import get_db, get_cursor, r2d
from tenant_context import TenantContext, get_tenant_context
router = APIRouter(prefix="/api", tags=["club_memberships"])
_ALLOWED_ROLES = frozenset({"club_admin", "trainer", "division_lead", "content_editor"})
_ALLOWED_STATUS = frozenset({"active", "inactive"})
def _normalize_roles(raw: List[str]) -> List[str]:
out: List[str] = []
seen = set()
for r in raw:
if not isinstance(r, str):
raise HTTPException(status_code=400, detail="Rollen müssen Strings sein")
code = r.strip().lower()
if not code:
continue
if code not in _ALLOWED_ROLES:
raise HTTPException(
status_code=400,
detail=f"Unbekannte Rolle: {code}. Erlaubt: {', '.join(sorted(_ALLOWED_ROLES))}",
)
if code not in seen:
seen.add(code)
out.append(code)
return out
def _assert_manage(cur, tenant: TenantContext, club_id: int) -> None:
pid = tenant.profile_id
role = tenant.global_role
if not can_manage_club_org(cur, pid, club_id, role):
raise HTTPException(status_code=403, detail="Keine Berechtigung zur Mitglieder-Verwaltung in diesem Verein")
def _club_exists(cur, club_id: int) -> bool:
cur.execute("SELECT 1 FROM clubs WHERE id = %s", (club_id,))
return cur.fetchone() is not None
class ClubMemberUpsert(BaseModel):
profile_id: int = Field(..., ge=1)
roles: List[str] = Field(default_factory=list, description="Mindestens eine Vereinsrolle")
class ClubMemberPatch(BaseModel):
roles: Optional[List[str]] = None
status: Optional[str] = Field(None, description="active oder inactive")
@router.get("/clubs/{club_id}/members")
def list_club_members(
club_id: int,
include_inactive: bool = Query(False),
tenant: TenantContext = Depends(get_tenant_context),
):
"""Alle Mitglieder eines Vereins mit Rollen (nur Vereins-/Plattform-Admin)."""
with get_db() as conn:
cur = get_cursor(conn)
if not _club_exists(cur, club_id):
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
_assert_manage(cur, tenant, club_id)
status_clause = "" if include_inactive else "AND cm.status = 'active'"
cur.execute(
f"""
SELECT cm.id AS membership_id, cm.profile_id, cm.status, cm.created_at, cm.updated_at,
p.email, p.name,
COALESCE(
ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL),
ARRAY[]::varchar[]
) AS roles
FROM club_members cm
INNER JOIN profiles p ON p.id = cm.profile_id
LEFT JOIN club_member_roles r ON r.club_member_id = cm.id
WHERE cm.club_id = %s {status_clause}
GROUP BY cm.id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name
ORDER BY p.name NULLS LAST, p.email
""",
(club_id,),
)
rows = []
for row in cur.fetchall():
d = r2d(row)
roles = d.get("roles") or []
if hasattr(roles, "tolist"):
roles = roles.tolist()
d["roles"] = list(roles)
rows.append(d)
return rows
@router.post("/clubs/{club_id}/members", status_code=201)
def upsert_club_member(
club_id: int,
body: ClubMemberUpsert,
tenant: TenantContext = Depends(get_tenant_context),
):
"""Mitglied anlegen oder aktivieren; Rollen werden vollständig ersetzt."""
roles = _normalize_roles(body.roles)
if not roles:
raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben")
with get_db() as conn:
cur = get_cursor(conn)
if not _club_exists(cur, club_id):
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
_assert_manage(cur, tenant, club_id)
cur.execute("SELECT id FROM profiles WHERE id = %s", (body.profile_id,))
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
cur.execute(
"""
INSERT INTO club_members (profile_id, club_id, status)
VALUES (%s, %s, 'active')
ON CONFLICT (profile_id, club_id)
DO UPDATE SET status = 'active', updated_at = NOW()
RETURNING id
""",
(body.profile_id, club_id),
)
cm_id = cur.fetchone()["id"]
cur.execute("DELETE FROM club_member_roles WHERE club_member_id = %s", (cm_id,))
for rc in roles:
cur.execute(
"""
INSERT INTO club_member_roles (club_member_id, role_code)
VALUES (%s, %s)
ON CONFLICT (club_member_id, role_code) DO NOTHING
""",
(cm_id, rc),
)
conn.commit()
return _one_member(cur, club_id, body.profile_id)
def _one_member(cur, club_id: int, profile_id: int) -> Dict[str, Any]:
cur.execute(
"""
SELECT cm.id AS membership_id, cm.profile_id, cm.status, cm.created_at, cm.updated_at,
p.email, p.name,
COALESCE(
ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL),
ARRAY[]::varchar[]
) AS roles
FROM club_members cm
INNER JOIN profiles p ON p.id = cm.profile_id
LEFT JOIN club_member_roles r ON r.club_member_id = cm.id
WHERE cm.club_id = %s AND cm.profile_id = %s
GROUP BY cm.id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name
""",
(club_id, profile_id),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Mitgliedschaft nicht gefunden")
d = r2d(row)
roles = d.get("roles") or []
if hasattr(roles, "tolist"):
roles = roles.tolist()
d["roles"] = list(roles)
return d
@router.get("/clubs/{club_id}/members/{profile_id}")
def get_club_member(
club_id: int,
profile_id: int,
tenant: TenantContext = Depends(get_tenant_context),
):
with get_db() as conn:
cur = get_cursor(conn)
if not _club_exists(cur, club_id):
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
_assert_manage(cur, tenant, club_id)
return _one_member(cur, club_id, profile_id)
@router.put("/clubs/{club_id}/members/{profile_id}")
def update_club_member(
club_id: int,
profile_id: int,
body: ClubMemberPatch,
tenant: TenantContext = Depends(get_tenant_context),
):
"""Rollen ersetzen und/oder Status setzen."""
with get_db() as conn:
cur = get_cursor(conn)
if not _club_exists(cur, club_id):
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
_assert_manage(cur, tenant, club_id)
cur.execute(
"SELECT id FROM club_members WHERE club_id = %s AND profile_id = %s",
(club_id, profile_id),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Mitgliedschaft nicht gefunden")
cm_id = row["id"]
if body.roles is None and body.status is None:
return _one_member(cur, club_id, profile_id)
if body.status is not None:
st = body.status.strip().lower()
if st not in _ALLOWED_STATUS:
raise HTTPException(status_code=400, detail="status muss active oder inactive sein")
cur.execute(
"UPDATE club_members SET status = %s, updated_at = NOW() WHERE id = %s",
(st, cm_id),
)
if body.roles is not None:
roles = _normalize_roles(body.roles)
if not roles:
raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben")
cur.execute("DELETE FROM club_member_roles WHERE club_member_id = %s", (cm_id,))
for rc in roles:
cur.execute(
"""
INSERT INTO club_member_roles (club_member_id, role_code)
VALUES (%s, %s)
ON CONFLICT (club_member_id, role_code) DO NOTHING
""",
(cm_id, rc),
)
conn.commit()
return _one_member(cur, club_id, profile_id)
@router.delete("/clubs/{club_id}/members/{profile_id}")
def delete_club_member(
club_id: int,
profile_id: int,
tenant: TenantContext = Depends(get_tenant_context),
):
"""Mitgliedschaft löschen (Rollen per CASCADE)."""
with get_db() as conn:
cur = get_cursor(conn)
if not _club_exists(cur, club_id):
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
_assert_manage(cur, tenant, club_id)
cur.execute(
"DELETE FROM club_members WHERE club_id = %s AND profile_id = %s RETURNING id",
(club_id, profile_id),
)
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Mitgliedschaft nicht gefunden")
conn.commit()
return {"ok": True}

View File

@ -3,11 +3,18 @@ Club & Organization Management Endpoints for Shinkan Jinkendo
Handles CRUD operations for clubs, divisions, and training groups.
"""
from typing import Optional
from typing import Any, List, Optional
from fastapi import APIRouter, HTTPException, Depends, Query
from db import get_db, get_cursor, r2d
from auth import require_auth
from tenant_context import TenantContext, get_tenant_context
from club_tenancy import (
assert_club_member,
can_manage_club_org,
can_plan_in_club,
club_ids_for_profile,
is_platform_admin,
)
router = APIRouter(prefix="/api", tags=["clubs"])
@ -16,24 +23,33 @@ router = APIRouter(prefix="/api", tags=["clubs"])
@router.get("/clubs")
def list_clubs(
status: Optional[str] = Query(default=None),
session=Depends(require_auth)
tenant: TenantContext = Depends(get_tenant_context),
):
"""
List all clubs (public for authenticated users).
Filters:
- status: active, inactive
Vereine: für normale Nutzer nur Mitgliedschaft-Vereine; Plattform-Admins sehen alle.
"""
role = tenant.global_role
profile_id = tenant.profile_id
with get_db() as conn:
cur = get_cursor(conn)
query = "SELECT * FROM clubs"
params = []
params: List[Any] = []
conds = []
if not is_platform_admin(role):
cids = club_ids_for_profile(cur, profile_id)
if not cids:
return []
conds.append("id IN (" + ",".join(["%s"] * len(cids)) + ")")
params.extend(sorted(cids))
if status:
query += " WHERE status = %s"
conds.append("status = %s")
params.append(status)
if conds:
query += " WHERE " + " AND ".join(conds)
query += " ORDER BY name"
cur.execute(query, params)
@ -41,12 +57,58 @@ def list_clubs(
return [r2d(r) for r in rows]
# ── Get Club ──────────────────────────────────────────────────────────
@router.get("/clubs/{club_id}")
def get_club(club_id: int, session=Depends(require_auth)):
"""Get club by ID with divisions and groups."""
# ── Öffentliches Vereinsverzeichnis (Registrierung / Antrag ohne Mitgliedschaft) ──
@router.get("/clubs/public-directory")
def public_club_directory():
"""Aktive Vereine zur Auswahl bei Registrierung oder Beitrittsantrag (nur id, name, Kürzel)."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, name, abbreviation
FROM clubs
WHERE status = 'active'
ORDER BY name
"""
)
return [r2d(r) for r in cur.fetchall()]
# ── Aktive Mitglieder für Trainer-/Co-Trainer-Auswahl (jeder Vereinsmitglied) ──
@router.get("/clubs/{club_id}/members/directory")
def club_members_directory(club_id: int, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
if not is_platform_admin(role):
assert_club_member(cur, profile_id, club_id)
cur.execute("SELECT 1 FROM clubs WHERE id = %s", (club_id,))
if not cur.fetchone():
raise HTTPException(404, "Verein nicht gefunden")
cur.execute(
"""
SELECT p.id, p.name, p.email
FROM club_members cm
INNER JOIN profiles p ON p.id = cm.profile_id
WHERE cm.club_id = %s AND cm.status = 'active'
ORDER BY COALESCE(p.name, ''), p.email
""",
(club_id,),
)
return [r2d(r) for r in cur.fetchall()]
# ── Get Club ──────────────────────────────────────────────────────────
@router.get("/clubs/{club_id}")
def get_club(club_id: int, tenant: TenantContext = Depends(get_tenant_context)):
"""Get club by ID with divisions and groups nur Mitglied oder Plattform-Admin."""
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
if not is_platform_admin(role):
assert_club_member(cur, profile_id, club_id)
# Get club
cur.execute("SELECT * FROM clubs WHERE id = %s", (club_id,))
@ -58,66 +120,108 @@ def get_club(club_id: int, session=Depends(require_auth)):
club = r2d(club)
# Get divisions
cur.execute("""
cur.execute(
"""
SELECT * FROM divisions
WHERE club_id = %s
ORDER BY name
""", (club_id,))
club['divisions'] = [r2d(r) for r in cur.fetchall()]
""",
(club_id,),
)
club["divisions"] = [r2d(r) for r in cur.fetchall()]
# Get training groups
cur.execute("""
cur.execute(
"""
SELECT g.*,
p.name as trainer_name
FROM training_groups g
LEFT JOIN profiles p ON g.trainer_id = p.id
WHERE g.club_id = %s
ORDER BY g.weekday, g.time_start
""", (club_id,))
club['training_groups'] = [r2d(r) for r in cur.fetchall()]
""",
(club_id,),
)
club["training_groups"] = [r2d(r) for r in cur.fetchall()]
return club
# ── Create Club ───────────────────────────────────────────────────────
@router.post("/clubs")
def create_club(data: dict, session=Depends(require_auth)):
"""Create new club (Admin oder Trainer — MVP ohne separates Vereins-Onboarding)."""
role = session.get('role')
if role not in ['admin', 'superadmin', 'trainer', 'user']:
raise HTTPException(403, "Keine Berechtigung, Vereine anzulegen")
def create_club(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
"""Neuen Verein anlegen nur Plattform-Admin; Pflicht: primary_admin_profile_id (Hauptverwalter:in)."""
role = tenant.global_role
if not is_platform_admin(role):
raise HTTPException(403, "Nur Plattform-Administratoren dürfen neue Vereine anlegen")
name = data.get('name')
name = data.get("name")
primary_admin_profile_id = data.get("primary_admin_profile_id")
if not name:
raise HTTPException(400, "Name ist Pflichtfeld")
if not primary_admin_profile_id:
raise HTTPException(400, "primary_admin_profile_id ist Pflichtfeld (Hauptverwalter:in)")
try:
aid = int(primary_admin_profile_id)
except (TypeError, ValueError):
raise HTTPException(400, "primary_admin_profile_id ungültig")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT id FROM profiles WHERE id = %s", (aid,))
if not cur.fetchone():
raise HTTPException(404, "Profil für Hauptverwalter nicht gefunden")
cur.execute("""
INSERT INTO clubs (name, abbreviation, description, status)
VALUES (%s, %s, %s, %s)
cur.execute(
"""
INSERT INTO clubs (name, abbreviation, description, status, primary_admin_profile_id)
VALUES (%s, %s, %s, %s, %s)
RETURNING id
""", (
name,
data.get('abbreviation'),
data.get('description'),
data.get('status', 'active')
))
""",
(
name,
data.get("abbreviation"),
data.get("description"),
data.get("status", "active"),
aid,
),
)
club_id = cur.fetchone()["id"]
cur.execute(
"""
INSERT INTO club_members (profile_id, club_id, status)
VALUES (%s, %s, 'active')
ON CONFLICT (profile_id, club_id)
DO UPDATE SET status = 'active', updated_at = NOW()
RETURNING id
""",
(aid, club_id),
)
cm_id = cur.fetchone()["id"]
for rc in ("club_admin", "trainer"):
cur.execute(
"""
INSERT INTO club_member_roles (club_member_id, role_code)
VALUES (%s, %s)
ON CONFLICT (club_member_id, role_code) DO NOTHING
""",
(cm_id, rc),
)
club_id = cur.fetchone()['id']
conn.commit()
return get_club(club_id, session)
return get_club(club_id, tenant)
# ── Update Club ───────────────────────────────────────────────────────
@router.put("/clubs/{club_id}")
def update_club(club_id: int, data: dict, session=Depends(require_auth)):
"""Update club (admin only)."""
role = session.get('role')
if role not in ['admin', 'superadmin']:
raise HTTPException(403, "Nur Admins dürfen Vereine bearbeiten")
def update_club(club_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)):
"""Verein bearbeiten Plattform-Admin oder Vereinsadmin."""
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
@ -127,33 +231,46 @@ def update_club(club_id: int, data: dict, session=Depends(require_auth)):
if not cur.fetchone():
raise HTTPException(404, "Verein nicht gefunden")
if not can_manage_club_org(cur, profile_id, club_id, role):
raise HTTPException(403, "Keine Berechtigung für diesen Verein")
# Nur Plattform-Admin darf primary_admin_profile_id ändern
if "primary_admin_profile_id" in data and data["primary_admin_profile_id"] is not None:
if not is_platform_admin(role):
raise HTTPException(403, "Nur Plattform-Admins dürfen den Hauptverwalter ändern")
# Update
cur.execute("""
cur.execute(
"""
UPDATE clubs SET
name = %s,
abbreviation = %s,
description = %s,
status = %s,
name = COALESCE(%s, name),
abbreviation = COALESCE(%s, abbreviation),
description = COALESCE(%s, description),
status = COALESCE(%s, status),
primary_admin_profile_id = COALESCE(%s, primary_admin_profile_id),
updated_at = NOW()
WHERE id = %s
""", (
data.get('name'),
data.get('abbreviation'),
data.get('description'),
data.get('status'),
club_id
))
""",
(
data.get("name"),
data.get("abbreviation"),
data.get("description"),
data.get("status"),
data.get("primary_admin_profile_id"),
club_id,
),
)
conn.commit()
return get_club(club_id, session)
return get_club(club_id, tenant)
# ── Delete Club ───────────────────────────────────────────────────────
@router.delete("/clubs/{club_id}")
def delete_club(club_id: int, session=Depends(require_auth)):
def delete_club(club_id: int, tenant: TenantContext = Depends(get_tenant_context)):
"""Delete club (superadmin only)."""
role = session.get('role')
role = tenant.global_role
if role != 'superadmin':
raise HTTPException(403, "Nur Superadmins dürfen Vereine löschen")
@ -176,22 +293,41 @@ def delete_club(club_id: int, session=Depends(require_auth)):
@router.get("/divisions")
def list_divisions(
club_id: Optional[int] = Query(default=None),
session=Depends(require_auth)
tenant: TenantContext = Depends(get_tenant_context),
):
"""List divisions (optional filter by club)."""
"""Sparten ohne Admin-Rechte nur in eigenen Vereinen sichtbar."""
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
mine = club_ids_for_profile(cur, profile_id)
if not is_platform_admin(role) and not mine:
return []
query = """
SELECT d.*, c.name as club_name
FROM divisions d
LEFT JOIN clubs c ON d.club_id = c.id
"""
where = []
params = []
if club_id:
query += " WHERE d.club_id = %s"
if club_id is not None:
where.append("d.club_id = %s")
params.append(club_id)
if not is_platform_admin(role) and club_id not in mine:
return []
if not is_platform_admin(role):
where.append(
"d.club_id IN (" + ",".join(["%s"] * len(mine)) + ")"
)
params.extend(sorted(mine))
if where:
query += " WHERE " + " AND ".join(where)
query += " ORDER BY d.name"
@ -202,14 +338,13 @@ def list_divisions(
# ── Create Division ───────────────────────────────────────────────────
@router.post("/divisions")
def create_division(data: dict, session=Depends(require_auth)):
"""Create new division (admin only)."""
role = session.get('role')
if role not in ['admin', 'superadmin']:
raise HTTPException(403, "Nur Admins dürfen Sparten erstellen")
def create_division(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
"""Create new division Vereinsadmin / Plattform-Admin."""
profile_id = tenant.profile_id
role = tenant.global_role
club_id = data.get('club_id')
name = data.get('name')
club_id = data.get("club_id")
name = data.get("name")
if not club_id or not name:
raise HTTPException(400, "club_id und name sind Pflichtfelder")
@ -217,93 +352,109 @@ def create_division(data: dict, session=Depends(require_auth)):
with get_db() as conn:
cur = get_cursor(conn)
if not can_manage_club_org(cur, profile_id, int(club_id), role):
raise HTTPException(403, "Keine Berechtigung, Sparten in diesem Verein anzulegen")
# Check club exists
cur.execute("SELECT id FROM clubs WHERE id = %s", (club_id,))
if not cur.fetchone():
raise HTTPException(404, "Verein nicht gefunden")
# Insert
cur.execute("""
cur.execute(
"""
INSERT INTO divisions (club_id, name, focus_area)
VALUES (%s, %s, %s)
RETURNING id
""", (
club_id,
name,
data.get('focus_area')
))
""",
(
club_id,
name,
data.get("focus_area"),
),
)
division_id = cur.fetchone()['id']
division_id = cur.fetchone()["id"]
conn.commit()
# Return created division
cur.execute("""
cur.execute(
"""
SELECT d.*, c.name as club_name
FROM divisions d
LEFT JOIN clubs c ON d.club_id = c.id
WHERE d.id = %s
""", (division_id,))
""",
(division_id,),
)
return r2d(cur.fetchone())
# ── Update Division ───────────────────────────────────────────────────
@router.put("/divisions/{division_id}")
def update_division(division_id: int, data: dict, session=Depends(require_auth)):
"""Update division (admin only)."""
role = session.get('role')
if role not in ['admin', 'superadmin']:
raise HTTPException(403, "Nur Admins dürfen Sparten bearbeiten")
def update_division(division_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)):
"""Update division Vereinsadmin / Plattform-Admin."""
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
# Check existence
cur.execute("SELECT id FROM divisions WHERE id = %s", (division_id,))
if not cur.fetchone():
cur.execute("SELECT id, club_id FROM divisions WHERE id = %s", (division_id,))
div = cur.fetchone()
if not div:
raise HTTPException(404, "Sparte nicht gefunden")
# Update
cur.execute("""
if not can_manage_club_org(cur, profile_id, div["club_id"], role):
raise HTTPException(403, "Keine Berechtigung")
cur.execute(
"""
UPDATE divisions SET
name = %s,
focus_area = %s,
updated_at = NOW()
WHERE id = %s
""", (
data.get('name'),
data.get('focus_area'),
division_id
))
""",
(
data.get("name"),
data.get("focus_area"),
division_id,
),
)
conn.commit()
# Return updated division
cur.execute("""
cur.execute(
"""
SELECT d.*, c.name as club_name
FROM divisions d
LEFT JOIN clubs c ON d.club_id = c.id
WHERE d.id = %s
""", (division_id,))
""",
(division_id,),
)
return r2d(cur.fetchone())
# ── Delete Division ───────────────────────────────────────────────────
@router.delete("/divisions/{division_id}")
def delete_division(division_id: int, session=Depends(require_auth)):
"""Delete division (admin only)."""
role = session.get('role')
if role not in ['admin', 'superadmin']:
raise HTTPException(403, "Nur Admins dürfen Sparten löschen")
def delete_division(division_id: int, tenant: TenantContext = Depends(get_tenant_context)):
"""Delete division Vereinsadmin / Plattform-Admin."""
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
# Check existence
cur.execute("SELECT id FROM divisions WHERE id = %s", (division_id,))
if not cur.fetchone():
cur.execute("SELECT id, club_id FROM divisions WHERE id = %s", (division_id,))
div = cur.fetchone()
if not div:
raise HTTPException(404, "Sparte nicht gefunden")
# Delete
if not can_manage_club_org(cur, profile_id, div["club_id"], role):
raise HTTPException(403, "Keine Berechtigung")
cur.execute("DELETE FROM divisions WHERE id = %s", (division_id,))
conn.commit()
@ -316,19 +467,21 @@ def list_training_groups(
club_id: Optional[int] = Query(default=None),
division_id: Optional[int] = Query(default=None),
status: Optional[str] = Query(default=None),
session=Depends(require_auth)
tenant: TenantContext = Depends(get_tenant_context),
):
"""
List training groups with optional filters.
Filters:
- club_id: Filter by club
- division_id: Filter by division
- status: active, inactive
Trainingsgruppen ohne Plattform-Admin nur in eigenen Vereinen.
"""
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
mine = club_ids_for_profile(cur, profile_id)
if not is_platform_admin(role) and not mine:
return []
query = """
SELECT g.*,
c.name as club_name,
@ -343,11 +496,13 @@ def list_training_groups(
where = []
params = []
if club_id:
if club_id is not None:
where.append("g.club_id = %s")
params.append(club_id)
if not is_platform_admin(role) and club_id not in mine:
return []
if division_id:
if division_id is not None:
where.append("g.division_id = %s")
params.append(division_id)
@ -355,6 +510,10 @@ def list_training_groups(
where.append("g.status = %s")
params.append(status)
if not is_platform_admin(role):
where.append("g.club_id IN (" + ",".join(["%s"] * len(mine)) + ")")
params.extend(sorted(mine))
if where:
query += " WHERE " + " AND ".join(where)
@ -367,12 +526,15 @@ def list_training_groups(
# ── Get Training Group ────────────────────────────────────────────────
@router.get("/groups/{group_id}")
def get_training_group(group_id: int, session=Depends(require_auth)):
"""Get training group by ID."""
def get_training_group(group_id: int, tenant: TenantContext = Depends(get_tenant_context)):
"""Trainingsgruppe nur mit Vereinszugriff."""
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
cur.execute(
"""
SELECT g.*,
c.name as club_name,
d.name as division_name,
@ -382,45 +544,55 @@ def get_training_group(group_id: int, session=Depends(require_auth)):
LEFT JOIN divisions d ON g.division_id = d.id
LEFT JOIN profiles p ON g.trainer_id = p.id
WHERE g.id = %s
""", (group_id,))
""",
(group_id,),
)
group = cur.fetchone()
if not group:
raise HTTPException(404, "Trainingsgruppe nicht gefunden")
cid = group["club_id"]
if not is_platform_admin(role):
assert_club_member(cur, profile_id, cid)
return r2d(group)
# ── Create Training Group ─────────────────────────────────────────────
@router.post("/groups")
def create_training_group(data: dict, session=Depends(require_auth)):
"""Create new training group (admin, trainer oder normaler Nutzer mit Vereinsbezug)."""
profile_id = session["profile_id"]
role = session.get('role')
if role not in ['admin', 'superadmin', 'trainer', 'user']:
def create_training_group(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
"""Trainingsgruppe anlegen Mitglied mit Planungs-/Admin-Rolle im Verein."""
profile_id = tenant.profile_id
role = tenant.global_role
if role not in ("admin", "superadmin", "trainer", "user"):
raise HTTPException(403, "Keine Berechtigung, Trainingsgruppen anzulegen")
club_id = data.get('club_id')
name = data.get('name')
club_id = data.get("club_id")
name = data.get("name")
if not club_id or not name:
raise HTTPException(400, "club_id und name sind Pflichtfelder")
trainer_id = data.get('trainer_id')
trainer_id = data.get("trainer_id")
if trainer_id in (None, "", 0) and role in ("trainer", "user"):
trainer_id = profile_id
with get_db() as conn:
cur = get_cursor(conn)
if not can_plan_in_club(cur, profile_id, int(club_id), role):
raise HTTPException(403, "Keine Berechtigung für diesen Verein")
# Check club exists
cur.execute("SELECT id FROM clubs WHERE id = %s", (club_id,))
if not cur.fetchone():
raise HTTPException(404, "Verein nicht gefunden")
# Insert
cur.execute("""
cur.execute(
"""
INSERT INTO training_groups (
club_id, division_id, name, focus, level, age_group,
weekday, time_start, time_end, location,
@ -430,50 +602,62 @@ def create_training_group(data: dict, session=Depends(require_auth)):
%s, %s, %s, %s,
%s, %s, %s
) RETURNING id
""", (
club_id,
data.get('division_id'),
name,
data.get('focus'),
data.get('level'),
data.get('age_group'),
data.get('weekday'),
data.get('time_start'),
data.get('time_end'),
data.get('location'),
trainer_id,
data.get('co_trainer_ids'),
data.get('status', 'active')
))
""",
(
club_id,
data.get("division_id"),
name,
data.get("focus"),
data.get("level"),
data.get("age_group"),
data.get("weekday"),
data.get("time_start"),
data.get("time_end"),
data.get("location"),
trainer_id,
data.get("co_trainer_ids"),
data.get("status", "active"),
),
)
group_id = cur.fetchone()['id']
group_id = cur.fetchone()["id"]
conn.commit()
return get_training_group(group_id, session)
return get_training_group(group_id, tenant)
# ── Update Training Group ─────────────────────────────────────────────
@router.put("/groups/{group_id}")
def update_training_group(group_id: int, data: dict, session=Depends(require_auth)):
"""Update training group (admin or assigned trainer)."""
profile_id = session['profile_id']
role = session.get('role')
def update_training_group(group_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)):
"""Update training group Vereinsadmin, Plattform-Admin oder zugewiesene Trainer."""
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
# Check existence and ownership
cur.execute("SELECT trainer_id FROM training_groups WHERE id = %s", (group_id,))
cur.execute(
"SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s",
(group_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(404, "Trainingsgruppe nicht gefunden")
# Only admin or assigned trainer can update
if role not in ['admin', 'superadmin'] and row['trainer_id'] != profile_id:
co_trainers = row["co_trainer_ids"] or []
club_id = row["club_id"]
allowed = role in ("admin", "superadmin")
if not allowed:
allowed = can_manage_club_org(cur, profile_id, club_id, role)
if not allowed:
allowed = row["trainer_id"] == profile_id or profile_id in co_trainers
if not allowed:
raise HTTPException(403, "Keine Berechtigung")
# Update
cur.execute("""
cur.execute(
"""
UPDATE training_groups SET
name = %s,
division_id = %s,
@ -489,44 +673,47 @@ def update_training_group(group_id: int, data: dict, session=Depends(require_aut
status = %s,
updated_at = NOW()
WHERE id = %s
""", (
data.get('name'),
data.get('division_id'),
data.get('focus'),
data.get('level'),
data.get('age_group'),
data.get('weekday'),
data.get('time_start'),
data.get('time_end'),
data.get('location'),
data.get('trainer_id'),
data.get('co_trainer_ids'),
data.get('status'),
group_id
))
""",
(
data.get("name"),
data.get("division_id"),
data.get("focus"),
data.get("level"),
data.get("age_group"),
data.get("weekday"),
data.get("time_start"),
data.get("time_end"),
data.get("location"),
data.get("trainer_id"),
data.get("co_trainer_ids"),
data.get("status"),
group_id,
),
)
conn.commit()
return get_training_group(group_id, session)
return get_training_group(group_id, tenant)
# ── Delete Training Group ─────────────────────────────────────────────
@router.delete("/groups/{group_id}")
def delete_training_group(group_id: int, session=Depends(require_auth)):
"""Delete training group (admin only)."""
role = session.get('role')
if role not in ['admin', 'superadmin']:
raise HTTPException(403, "Nur Admins dürfen Gruppen löschen")
def delete_training_group(group_id: int, tenant: TenantContext = Depends(get_tenant_context)):
"""Delete training group Vereinsadmin oder Plattform-Admin."""
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
# Check existence
cur.execute("SELECT id FROM training_groups WHERE id = %s", (group_id,))
if not cur.fetchone():
cur.execute("SELECT id, club_id FROM training_groups WHERE id = %s", (group_id,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Trainingsgruppe nicht gefunden")
# Delete
if not can_manage_club_org(cur, profile_id, row["club_id"], role):
raise HTTPException(403, "Keine Berechtigung")
cur.execute("DELETE FROM training_groups WHERE id = %s", (group_id,))
conn.commit()

View File

@ -9,8 +9,13 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field, model_validator
from psycopg2 import IntegrityError
from auth import require_auth
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,
is_platform_admin,
library_content_visible_to_profile,
)
from routers.training_planning import _has_planning_role
@ -82,7 +87,7 @@ _EDGE_SELECT = """
"""
def _graph_access(cur, graph_id: int, profile_id: int, role: str) -> dict:
def _graph_row(cur, graph_id: int) -> dict:
cur.execute(
"SELECT * FROM exercise_progression_graphs WHERE id = %s",
(graph_id,),
@ -90,11 +95,40 @@ def _graph_access(cur, graph_id: int, profile_id: int, role: str) -> dict:
r = cur.fetchone()
if not r:
raise HTTPException(status_code=404, detail="Progressionsgraph nicht gefunden")
row = r2d(r)
if role in ("admin", "superadmin"):
return row
if row.get("created_by") != profile_id:
return r2d(r)
def _assert_graph_readable(cur, row: dict, profile_id: int, role: str) -> None:
vis = (row.get("visibility") or "private").strip().lower()
cid = row.get("club_id")
if cid is not None:
cid = int(cid)
cr = row.get("created_by")
if cr is not None:
cr = int(cr)
if not library_content_visible_to_profile(cur, profile_id, vis, cid, cr, role):
raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Progressionsgraph")
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")
def _require_graph_read(cur, graph_id: int, profile_id: int, role: str) -> dict:
row = _graph_row(cur, graph_id)
_assert_graph_readable(cur, row, profile_id, role)
return row
def _require_graph_write(cur, graph_id: int, profile_id: int, role: str) -> dict:
row = _graph_row(cur, graph_id)
_assert_graph_writable(cur, row, profile_id, role)
return row
@ -167,12 +201,12 @@ def _insert_edge_row(
@router.get("/exercise-progression-graphs")
def list_progression_graphs(session: dict = Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def list_progression_graphs(tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
if role in ("admin", "superadmin"):
if is_platform_admin(role):
cur.execute(
"""
SELECT g.*,
@ -182,15 +216,21 @@ def list_progression_graphs(session: dict = Depends(require_auth)):
"""
)
else:
vis_sql, vis_params = library_content_visibility_sql(
alias="g",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
cur.execute(
"""
f"""
SELECT g.*,
(SELECT COUNT(*) FROM exercise_progression_edges e WHERE e.graph_id = g.id) AS edges_count
FROM exercise_progression_graphs g
WHERE g.created_by = %s
WHERE ({vis_sql})
ORDER BY g.updated_at DESC NULLS LAST, g.name
""",
(profile_id,),
vis_params,
)
return [r2d(r) for r in cur.fetchall()]
@ -199,13 +239,13 @@ def list_progression_graphs(session: dict = Depends(require_auth)):
def get_progression_graph(
graph_id: int,
include_edges: bool = Query(default=False),
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = session["profile_id"]
role = session.get("role")
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
row = _graph_access(cur, graph_id, profile_id, role)
row = _require_graph_read(cur, graph_id, profile_id, role)
if include_edges:
cur.execute(
_EDGE_SELECT + " WHERE e.graph_id = %s ORDER BY e.id",
@ -218,10 +258,10 @@ def get_progression_graph(
@router.post("/exercise-progression-graphs", status_code=201)
def create_progression_graph(
body: ProgressionGraphCreate,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = session["profile_id"]
role = session.get("role")
profile_id = tenant.profile_id
role = tenant.global_role
if not _has_planning_role(role):
raise HTTPException(status_code=403, detail="Keine Berechtigung zum Anlegen von Progressionsgraphen")
@ -229,55 +269,105 @@ def create_progression_graph(
if not name:
raise HTTPException(status_code=400, detail="name ist Pflicht")
vis = (body.visibility or "private").strip().lower()
cid = body.club_id
if vis == "club":
if cid is None:
cid = tenant.effective_club_id
if cid is None:
raise HTTPException(
status_code=400,
detail="Vereins-Graph: club_id angeben oder aktiven Verein wählen (X-Active-Club-Id).",
)
gov_club = cid if vis == "club" else None
with get_db() as conn:
cur = get_cursor(conn)
assert_valid_governance_visibility(cur, profile_id, role, vis, gov_club)
cur.execute(
"""
INSERT INTO exercise_progression_graphs (name, description, visibility, club_id, created_by)
VALUES (%s, %s, %s, %s, %s)
RETURNING id
""",
(name, body.description, body.visibility, body.club_id, profile_id),
(name, body.description, vis, cid if vis == "club" else None, profile_id),
)
gid = cur.fetchone()["id"]
conn.commit()
return get_progression_graph(gid, include_edges=False, session=session)
return get_progression_graph(gid, include_edges=False, tenant=tenant)
@router.put("/exercise-progression-graphs/{graph_id}")
def update_progression_graph(
graph_id: int,
body: ProgressionGraphUpdate,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = session["profile_id"]
role = session.get("role")
data = body.model_dump(exclude_unset=True)
if not data:
profile_id = tenant.profile_id
role = tenant.global_role
original = body.model_dump(exclude_unset=True)
if not original:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
with get_db() as conn:
cur = get_cursor(conn)
_graph_access(cur, graph_id, profile_id, role)
row = _require_graph_write(cur, graph_id, profile_id, role)
ex_vis = (row.get("visibility") or "private").strip().lower()
ex_cid_raw = row.get("club_id")
ex_cid = int(ex_cid_raw) if ex_cid_raw is not None else None
next_vis = ex_vis
if "visibility" in original and original["visibility"] is not None:
v_raw = str(original["visibility"]).strip().lower()
if v_raw:
next_vis = v_raw
next_club = ex_cid
if "club_id" in original:
raw_c = original["club_id"]
if raw_c in (None, "", []):
next_club = None
else:
next_club = int(raw_c)
if next_vis == "club":
if next_club is None:
next_club = tenant.effective_club_id
if next_club is None:
raise HTTPException(
status_code=400,
detail="Vereins-Graph: club_id angeben oder aktiven Verein wählen (X-Active-Club-Id).",
)
gov_club = next_club if next_vis == "club" else None
assert_valid_governance_visibility(cur, profile_id, role, next_vis, gov_club)
fields: List[str] = []
params: List[Any] = []
if "name" in data:
n = (data["name"] or "").strip()
if "name" in original:
n = (original["name"] or "").strip()
if not n:
raise HTTPException(status_code=400, detail="name ist Pflicht")
fields.append("name = %s")
params.append(n)
if "description" in data:
if "description" in original:
fields.append("description = %s")
params.append(data["description"])
if "visibility" in data:
params.append(original["description"])
vis_changed = next_vis != ex_vis
if "visibility" in original or vis_changed:
fields.append("visibility = %s")
params.append(data["visibility"])
if "club_id" in data:
params.append(next_vis)
if "club_id" in original or vis_changed:
fields.append("club_id = %s")
params.append(data["club_id"])
params.append(next_club if next_vis == "club" else None)
if not fields:
return get_progression_graph(graph_id, include_edges=False, tenant=tenant)
fields.append("updated_at = NOW()")
params.append(graph_id)
@ -287,16 +377,16 @@ def update_progression_graph(
)
conn.commit()
return get_progression_graph(graph_id, include_edges=False, session=session)
return get_progression_graph(graph_id, include_edges=False, tenant=tenant)
@router.delete("/exercise-progression-graphs/{graph_id}")
def delete_progression_graph(graph_id: int, session: dict = Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def delete_progression_graph(graph_id: int, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
_graph_access(cur, graph_id, profile_id, role)
_require_graph_write(cur, graph_id, profile_id, role)
cur.execute("DELETE FROM exercise_progression_graphs WHERE id = %s", (graph_id,))
conn.commit()
return {"ok": True}
@ -307,13 +397,13 @@ def list_progression_edges(
graph_id: int,
from_exercise_id: Optional[int] = Query(default=None),
to_exercise_id: Optional[int] = Query(default=None),
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = session["profile_id"]
role = session.get("role")
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
_graph_access(cur, graph_id, profile_id, role)
_require_graph_read(cur, graph_id, profile_id, role)
q = _EDGE_SELECT + " WHERE e.graph_id = %s"
params: List[Any] = [graph_id]
if from_exercise_id is not None:
@ -331,14 +421,14 @@ def list_progression_edges(
def create_progression_edge(
graph_id: int,
body: ProgressionEdgeCreate,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = session["profile_id"]
role = session.get("role")
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
_graph_access(cur, graph_id, profile_id, role)
_require_graph_write(cur, graph_id, profile_id, role)
_assert_exercises_exist(cur, body.from_exercise_id, body.to_exercise_id)
fv = body.from_exercise_variant_id
tv = body.to_exercise_variant_id
@ -372,11 +462,11 @@ def create_progression_edge(
def create_progression_sequence(
graph_id: int,
body: ProgressionSequenceCreate,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
"""Legt n1 Nachfolger-Kanten (next_exercise) für eine geordnete Schrittliste an."""
profile_id = session["profile_id"]
role = session.get("role")
profile_id = tenant.profile_id
role = tenant.global_role
steps = body.steps
n_seg = len(steps) - 1
seg_notes = body.segment_notes
@ -384,7 +474,7 @@ def create_progression_sequence(
created: List[dict] = []
with get_db() as conn:
cur = get_cursor(conn)
_graph_access(cur, graph_id, profile_id, role)
_require_graph_write(cur, graph_id, profile_id, role)
ex_ids = [s.exercise_id for s in steps]
_assert_exercises_exist(cur, *ex_ids)
@ -425,17 +515,17 @@ def update_progression_edge(
graph_id: int,
edge_id: int,
body: ProgressionEdgeUpdate,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = session["profile_id"]
role = session.get("role")
profile_id = tenant.profile_id
role = tenant.global_role
data = body.model_dump(exclude_unset=True)
if not data:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
with get_db() as conn:
cur = get_cursor(conn)
_graph_access(cur, graph_id, profile_id, role)
_require_graph_write(cur, graph_id, profile_id, role)
cur.execute(
"SELECT id FROM exercise_progression_edges WHERE id = %s AND graph_id = %s",
(edge_id, graph_id),
@ -459,13 +549,13 @@ def update_progression_edge(
def delete_progression_edge(
graph_id: int,
edge_id: int,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = session["profile_id"]
role = session.get("role")
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
_graph_access(cur, graph_id, profile_id, role)
_require_graph_write(cur, graph_id, profile_id, role)
cur.execute(
"""
DELETE FROM exercise_progression_edges
@ -484,17 +574,17 @@ def delete_progression_edge(
def delete_progression_edges_batch(
graph_id: int,
body: EdgeIdsBatch,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
"""Löscht mehrere Kanten (z. B. eine zusammenhängende Kette in einem Schritt)."""
profile_id = session["profile_id"]
role = session.get("role")
profile_id = tenant.profile_id
role = tenant.global_role
ids = body.edge_ids
clean_ids = list(dict.fromkeys(ids))
with get_db() as conn:
cur = get_cursor(conn)
_graph_access(cur, graph_id, profile_id, role)
_require_graph_write(cur, graph_id, profile_id, role)
cur.execute(
f"""
DELETE FROM exercise_progression_edges

View File

@ -9,13 +9,18 @@ import json
import logging
import os
from pathlib import Path
from typing import Optional
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File, Form
from pydantic import BaseModel, Field, model_validator
from db import get_db, get_cursor, r2d
from auth import require_auth
from club_tenancy import (
assert_valid_governance_visibility,
is_platform_admin,
library_content_visible_to_profile,
)
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
logger = logging.getLogger(__name__)
@ -80,8 +85,8 @@ ALLOWED_UPLOAD_MIMES = frozenset(
)
def _upload_limit_bytes(session: dict) -> int:
role = session.get("role") or ""
def _upload_limit_bytes(tenant: TenantContext) -> int:
role = tenant.global_role or ""
if role in ("admin", "superadmin"):
return MAX_UPLOAD_BYTES_ADMIN
return MAX_UPLOAD_BYTES_USER
@ -208,6 +213,25 @@ class ExerciseVariantsReorder(BaseModel):
variant_ids: list[int]
_VALID_EXERCISE_STATUS_BULK = frozenset({"draft", "in_review", "approved", "archived"})
_MAX_BULK_METADATA_IDS = 500
class ExerciseBulkMetadataPatch(BaseModel):
"""Massenänderung von Sichtbarkeit und/oder Status (z. B. Private → Verein)."""
exercise_ids: list[int] = Field(..., min_length=1, max_length=_MAX_BULK_METADATA_IDS)
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
status: Optional[str] = None
club_id: Optional[int] = Field(default=None, ge=1)
@model_validator(mode="after")
def at_least_visibility_or_status(self):
if self.visibility is None and self.status is None:
raise ValueError("Mindestens eines der Felder visibility oder status angeben")
return self
# ============================================================================
# Helper Functions
# ============================================================================
@ -547,6 +571,127 @@ def _merge_str_any(multi: list[str], single: Optional[str]) -> list[str]:
return out
@router.patch("/exercises/bulk-metadata")
def bulk_patch_exercises_metadata(
body: ExerciseBulkMetadataPatch,
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Ändert Sichtbarkeit und/oder Status für viele Übungen auf einmal.
Berechtigt: Ersteller der jeweiligen Übung oder Plattform-Admin (admin/superadmin).
Governance wie bei Einzel-PUT (official nur Plattform-Admin; club mit Mitgliedschaft bzw. Admin).
"""
profile_id = tenant.profile_id
role = tenant.global_role
unique_ids = sorted({int(x) for x in body.exercise_ids if x is not None and int(x) > 0})
if not unique_ids:
raise HTTPException(status_code=400, detail="Keine gültigen Übungs-IDs")
if len(unique_ids) > _MAX_BULK_METADATA_IDS:
raise HTTPException(
status_code=400,
detail=f"Maximal {_MAX_BULK_METADATA_IDS} Übungen pro Anfrage",
)
status_val: Optional[str] = None
if body.status is not None:
st = str(body.status).strip().lower()
if st not in _VALID_EXERCISE_STATUS_BULK:
raise HTTPException(status_code=400, detail="Ungültiger Status")
status_val = st
patch_visibility = body.visibility is not None
patch_status = status_val is not None
updated: List[int] = []
failed: List[Dict[str, Any]] = []
def _fail_msg(he: HTTPException) -> str:
d = he.detail
return d if isinstance(d, str) else str(d)
with get_db() as conn:
cur = get_cursor(conn)
for ex_id in unique_ids:
cur.execute(
"SELECT id, created_by, visibility, club_id FROM exercises WHERE id = %s",
(ex_id,),
)
row = cur.fetchone()
if not row:
failed.append({"id": ex_id, "detail": "Übung nicht gefunden"})
continue
rowd = r2d(row)
owner = rowd.get("created_by")
if owner is not None:
owner = int(owner)
if owner != profile_id and not is_platform_admin(role):
failed.append(
{
"id": ex_id,
"detail": "Keine Berechtigung (nur Ersteller oder Plattform-Admin)",
}
)
continue
ex_vis = (rowd.get("visibility") or "private").strip().lower()
ex_cid_raw = rowd.get("club_id")
ex_cid = int(ex_cid_raw) if ex_cid_raw is not None else None
next_vis = ex_vis
if patch_visibility:
next_vis = str(body.visibility).strip().lower()
next_club = ex_cid
if patch_visibility and body.club_id is not None:
next_club = int(body.club_id)
if patch_visibility:
if next_vis == "club":
if next_club is None:
next_club = tenant.effective_club_id
if next_club is None:
failed.append(
{
"id": ex_id,
"detail": "Vereins-Übung: club_id angeben oder aktiven Verein wählen (X-Active-Club-Id).",
}
)
continue
gov_club = next_club if next_vis == "club" else None
try:
assert_valid_governance_visibility(cur, profile_id, role, next_vis, gov_club)
except HTTPException as he:
failed.append({"id": ex_id, "detail": _fail_msg(he)})
continue
sets: List[str] = []
vals: List[Any] = []
if patch_visibility:
sets.extend(["visibility = %s", "club_id = %s"])
cid_out = next_club if next_vis == "club" else None
vals.extend([next_vis, cid_out])
if patch_status:
sets.append("status = %s")
vals.append(status_val)
sets.append("updated_at = NOW()")
vals.append(ex_id)
cur.execute(
f"UPDATE exercises SET {', '.join(sets)} WHERE id = %s",
tuple(vals),
)
updated.append(ex_id)
conn.commit()
return {
"updated": updated,
"failed": failed,
"updated_count": len(updated),
"failed_count": len(failed),
}
@router.get("/exercises")
def list_exercises(
focus_area_ids: list[int] = Query(default=[], description="ODER: mind. einer dieser Fokusbereiche"),
@ -576,14 +721,14 @@ def list_exercises(
default=False,
description="Wenn true: Feld variants[] pro Übung (id, variant_name, sequence_order) für Planung/UI",
),
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Liste aller Übungen mit Filtern.
Lightweight Response (ohne M:N Details, nur IDs und Namen).
Optional include_variants für Variantenauswahl in der Trainingsplanung.
"""
profile_id = session["profile_id"]
profile_id = tenant.profile_id
with get_db() as conn:
cur = get_cursor(conn)
@ -592,9 +737,16 @@ def list_exercises(
where = ["1=1"]
params = []
# Visibility Filter (private nur für Owner)
where.append("(e.visibility = 'official' OR e.visibility = 'club' OR e.created_by = %s)")
params.append(profile_id)
role = tenant.global_role
if not is_platform_admin(role):
vis_sql, vis_params = library_content_visibility_sql(
alias="e",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
where.append(vis_sql)
params.extend(vis_params)
vis_list = _merge_str_any(visibility_any, visibility)
if vis_list:
@ -743,23 +895,29 @@ def list_exercises(
@router.get("/exercises/{exercise_id}")
def get_exercise(
exercise_id: int,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Exercise Detail mit allen M:N Relations (vollständig enriched).
"""
profile_id = session["profile_id"]
profile_id = tenant.profile_id
with get_db() as conn:
cur = get_cursor(conn)
exercise = enrich_exercise_detail(exercise_id, cur)
if not exercise:
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
if not exercise:
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
# Permission Check (private nur für Owner)
if exercise["visibility"] == "private" and exercise["created_by"] != profile_id:
raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Übung")
if not library_content_visible_to_profile(
cur,
profile_id,
exercise["visibility"],
exercise.get("club_id"),
exercise.get("created_by"),
tenant.global_role,
):
raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Übung")
return exercise
@ -767,12 +925,12 @@ def get_exercise(
@router.post("/exercises", status_code=201)
def create_exercise(
body: ExerciseCreate,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Erstellt eine neue Übung mit allen M:N Relations.
"""
profile_id = session["profile_id"]
profile_id = tenant.profile_id
# Validierung
if body.status not in ("draft", "in_review", "approved", "archived"):
@ -780,8 +938,15 @@ def create_exercise(
if body.visibility not in ("private", "club", "official"):
raise HTTPException(status_code=400, detail="Ungültige Visibility")
club_id = body.club_id
if body.visibility == "club" and club_id is None:
club_id = tenant.effective_club_id
with get_db() as conn:
cur = get_cursor(conn)
assert_valid_governance_visibility(
cur, profile_id, tenant.global_role, body.visibility, club_id
)
# Equipment als JSONB
equipment_json = json.dumps(body.equipment) if body.equipment else None
@ -800,7 +965,7 @@ def create_exercise(
body.duration_min, body.duration_max,
body.group_size_min, body.group_size_max,
equipment_json,
body.visibility, body.status, profile_id, body.club_id,
body.visibility, body.status, profile_id, club_id,
)
)
row = cur.fetchone()
@ -821,34 +986,70 @@ def create_exercise(
def update_exercise(
exercise_id: int,
body: ExerciseUpdate,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Aktualisiert eine Übung (Partial Update).
Nur Owner darf editieren.
"""
profile_id = session["profile_id"]
profile_id = tenant.profile_id
with get_db() as conn:
cur = get_cursor(conn)
# Existiert die Übung?
cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,))
cur.execute(
"SELECT created_by, visibility, club_id FROM exercises WHERE id = %s",
(exercise_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
# Permission Check
if _row_created_by(row) != profile_id:
raise HTTPException(status_code=403, detail="Nur der Ersteller darf editieren")
# UPDATE (nur gesetzte Felder)
fields = []
params = []
ex_vis = (row.get("visibility") or "private").strip().lower()
ex_cid = row.get("club_id")
if ex_cid is not None:
ex_cid = int(ex_cid)
data = body.dict(exclude_unset=True)
# Basis-Felder
next_vis = ex_vis
if "visibility" in data and data["visibility"] is not None:
v_raw = str(data["visibility"]).strip().lower()
if v_raw:
next_vis = v_raw
next_club = ex_cid
if "club_id" in data:
raw_c = data["club_id"]
if raw_c in (None, "", []):
next_club = None
else:
next_club = int(raw_c)
if next_vis == "club":
if next_club is None:
next_club = tenant.effective_club_id
if next_club is None:
raise HTTPException(
status_code=400,
detail="Vereins-Übung: club_id angeben oder aktiven Verein wählen (X-Active-Club-Id).",
)
data["club_id"] = next_club
if next_vis != ex_vis:
data["visibility"] = next_vis
gov_club = next_club if next_vis == "club" else None
assert_valid_governance_visibility(
cur, profile_id, tenant.global_role, next_vis, gov_club
)
fields = []
params = []
for field in ["title", "summary", "goal", "execution", "preparation", "trainer_notes",
"duration_min", "duration_max", "group_size_min", "group_size_max",
"visibility", "status", "club_id"]:
@ -856,12 +1057,10 @@ def update_exercise(
fields.append(f"{field} = %s")
params.append(data[field])
# Equipment (JSONB)
if "equipment" in data:
fields.append("equipment = %s")
params.append(json.dumps(data["equipment"]) if data["equipment"] else None)
# UPDATE ausführen (wenn Basis-Felder geändert wurden)
if fields:
fields.append("updated_at = NOW()")
params.append(exercise_id)
@ -869,10 +1068,8 @@ def update_exercise(
cur.execute(query, params)
conn.commit()
# M:N Relations aktualisieren (wenn angegeben)
assign_exercise_relations(cur, conn, exercise_id, data)
# Vollständiges Objekt zurückgeben
exercise = enrich_exercise_detail(exercise_id, cur)
return exercise
@ -881,14 +1078,14 @@ def update_exercise(
@router.delete("/exercises/{exercise_id}")
def delete_exercise(
exercise_id: int,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Löscht eine Übung.
Nur Owner oder Admin darf löschen.
"""
profile_id = session["profile_id"]
role = session.get("role")
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
@ -900,7 +1097,7 @@ def delete_exercise(
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
# Permission Check
if _row_created_by(row) != profile_id and role not in ("admin", "superadmin"):
if _row_created_by(row) != profile_id and not is_platform_admin(role):
raise HTTPException(status_code=403, detail="Nur Ersteller oder Admin darf löschen")
# Prüfen ob Übung in Block-Items verwendet wird
@ -930,9 +1127,9 @@ def delete_exercise(
def reorder_exercise_variants(
exercise_id: int,
body: ExerciseVariantsReorder,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = session["profile_id"]
profile_id = tenant.profile_id
if len(body.variant_ids) != len(set(body.variant_ids)):
raise HTTPException(status_code=400, detail="variant_ids dürfen keine Duplikate enthalten")
@ -967,9 +1164,9 @@ def reorder_exercise_variants(
def create_exercise_variant(
exercise_id: int,
body: ExerciseVariantCreate,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = session["profile_id"]
profile_id = tenant.profile_id
with get_db() as conn:
cur = get_cursor(conn)
@ -1023,9 +1220,9 @@ def update_exercise_variant(
exercise_id: int,
variant_id: int,
body: ExerciseVariantUpdate,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = session["profile_id"]
profile_id = tenant.profile_id
data = body.dict(exclude_unset=True)
if not data:
@ -1096,12 +1293,15 @@ def update_exercise_variant(
conn.commit()
return row
@router.delete("/exercises/{exercise_id}/variants/{variant_id}")
def delete_exercise_variant(
exercise_id: int,
variant_id: int,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = session["profile_id"]
profile_id = tenant.profile_id
with get_db() as conn:
cur = get_cursor(conn)
@ -1145,7 +1345,7 @@ def _fetch_media_row(cur, exercise_id: int, media_id: int) -> Optional[dict]:
@router.post("/exercises/{exercise_id}/media", status_code=201)
async def upload_exercise_media(
exercise_id: int,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
file: Optional[UploadFile] = File(None),
embed_url: Optional[str] = Form(None),
media_type: str = Form(...),
@ -1154,7 +1354,7 @@ async def upload_exercise_media(
context: str = Form("ablauf"),
is_primary: bool = Form(False),
):
profile_id = session["profile_id"]
profile_id = tenant.profile_id
if media_type not in ("image", "video", "document", "sketch"):
raise HTTPException(status_code=400, detail="Ungültiger media_type")
if context not in ("ablauf", "detail", "trainer_hint"):
@ -1212,7 +1412,7 @@ async def upload_exercise_media(
)
else:
raw = await file.read()
max_upload = _upload_limit_bytes(session)
max_upload = _upload_limit_bytes(tenant)
if len(raw) > max_upload:
raise HTTPException(
status_code=413,
@ -1267,9 +1467,9 @@ async def upload_exercise_media(
def reorder_exercise_media(
exercise_id: int,
body: ExerciseMediaReorder,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = session["profile_id"]
profile_id = tenant.profile_id
ids = body.media_ids
with get_db() as conn:
cur = get_cursor(conn)
@ -1298,9 +1498,9 @@ def update_exercise_media(
exercise_id: int,
media_id: int,
body: ExerciseMediaUpdate,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = session["profile_id"]
profile_id = tenant.profile_id
data = body.dict(exclude_unset=True)
if not data:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
@ -1340,9 +1540,9 @@ def update_exercise_media(
def delete_exercise_media(
exercise_id: int,
media_id: int,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = session["profile_id"]
profile_id = tenant.profile_id
with get_db() as conn:
cur = get_cursor(conn)
_assert_can_edit_exercise(cur, exercise_id, profile_id)

View File

@ -11,10 +11,14 @@ from fastapi import APIRouter, HTTPException, Header, Depends
from db import get_db, get_cursor, r2d
from auth import require_auth
from club_tenancy import assert_club_member, memberships_with_roles, is_platform_admin
from tenant_context import resolve_tenant_context, TenantContext, get_tenant_context
from models import ProfileCreate, ProfileUpdate
router = APIRouter(prefix="/api", tags=["profiles"])
_ALLOWED_PORTAL_ROLES = frozenset({"user", "trainer", "admin", "superadmin"})
# ── Helper ────────────────────────────────────────────────────────────────────
def get_pid(x_profile_id: Optional[str] = Header(default=None)) -> str:
@ -31,27 +35,54 @@ def get_pid(x_profile_id: Optional[str] = Header(default=None)) -> str:
# ── Current User Profile ──────────────────────────────────────────────────────
@router.get("/profiles/me")
def get_current_profile(session=Depends(require_auth)):
"""Get current user's profile (for auth check on refresh)."""
profile_id = session['profile_id']
def get_current_profile(
session=Depends(require_auth),
x_active_club_id: Optional[str] = Header(default=None, alias="X-Active-Club-Id"),
):
"""Profil inkl. Vereinsmitgliedschaften; effective_club_id = aufgelöster Request-Kontext (Header vor Profilfeld)."""
profile_id = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM profiles WHERE id=%s", (profile_id,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Profil nicht gefunden")
return r2d(row)
data = r2d(row)
data.pop("pin_hash", None)
clubs = memberships_with_roles(cur, profile_id)
data["clubs"] = clubs
ac_raw = data.get("active_club_id")
stored_ac = int(ac_raw) if ac_raw is not None and ac_raw != "" else None
tenant = resolve_tenant_context(
cur,
profile_id=int(profile_id),
global_role=session.get("role") or "",
header_raw=x_active_club_id,
memberships=clubs,
stored_active_club_id=stored_ac,
invalid_header_policy="ignore",
)
data["effective_club_id"] = tenant.effective_club_id
return data
# ── Admin Profile Management ──────────────────────────────────────────────────
@router.get("/profiles")
def list_profiles(session=Depends(require_auth)):
"""List all profiles (admin)."""
"""Liste aller Profile (nur Plattform-Admin)."""
role = (session.get("role") or "").lower()
if not is_platform_admin(role):
raise HTTPException(status_code=403, detail="Nur Plattform-Administratoren dürfen alle Profile einsehen")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM profiles ORDER BY created")
rows = cur.fetchall()
return [r2d(r) for r in rows]
out = []
for r in rows:
d = r2d(r)
d.pop("pin_hash", None)
out.append(d)
return out
@router.post("/profiles")
@ -69,24 +100,29 @@ def create_profile(p: ProfileCreate, session=Depends(require_auth)):
return r2d(cur.fetchone())
@router.get("/profiles/{pid}")
def get_profile(pid: str, session=Depends(require_auth)):
"""Get profile by ID."""
def profile_document(pid: str) -> dict:
"""Profil ohne PIN — für Routen und interne Aufrufe (z. B. /auth/me)."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
row = cur.fetchone()
if not row: raise HTTPException(404, "Profil nicht gefunden")
return r2d(row)
if not row:
raise HTTPException(404, "Profil nicht gefunden")
d = r2d(row)
d.pop("pin_hash", None)
return d
@router.put("/profiles/{pid}")
def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)):
"""Update profile — nur eigenes Profil oder Admin."""
sess_pid = session.get('profile_id')
role = (session.get('role') or '').lower()
if str(sess_pid) != str(pid) and role not in ('admin', 'superadmin'):
raise HTTPException(403, 'Keine Berechtigung für dieses Profil')
@router.get("/profiles/{pid}")
def get_profile(pid: str, _session=Depends(require_auth)):
"""Get profile by ID."""
return profile_document(pid)
def _run_profile_update(pid: str, p: ProfileUpdate, tenant: TenantContext) -> dict:
"""Gemeinsame PUT-Logik für /profiles/{id} und Legacy /profile."""
sess_pid = tenant.profile_id
role = tenant.global_role
if str(sess_pid) != str(pid) and role not in ("admin", "superadmin"):
raise HTTPException(403, "Keine Berechtigung für dieses Profil")
with get_db() as conn:
cur = get_cursor(conn)
@ -100,6 +136,51 @@ def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)):
patch = p.model_dump(exclude_unset=True)
data = {}
if "role" in patch or "tier" in patch:
if not is_platform_admin(role):
raise HTTPException(
status_code=403,
detail="Nur Portal-Admins dürfen Rolle oder Tier ändern",
)
if "role" in patch:
new_role = (patch["role"] or "").strip().lower()
if new_role not in _ALLOWED_PORTAL_ROLES:
raise HTTPException(
status_code=400,
detail=f"Ungültige Portal-Rolle. Erlaubt: {', '.join(sorted(_ALLOWED_PORTAL_ROLES))}",
)
if new_role == "superadmin" and role != "superadmin":
raise HTTPException(
status_code=403,
detail="Nur Super-Admins dürfen die Rolle Super-Admin vergeben",
)
old_r = (rowd.get("role") or "user").strip().lower()
cur.execute(
"""
SELECT COUNT(*)::int AS c FROM profiles
WHERE lower(trim(role)) IN ('admin','superadmin')
"""
)
admin_cnt = int(cur.fetchone()["c"])
if old_r in ("admin", "superadmin") and new_role not in ("admin", "superadmin"):
if admin_cnt <= 1:
raise HTTPException(
status_code=400,
detail="Der letzte Portal-Administrator kann nicht zurückgestuft werden",
)
data["role"] = new_role
del patch["role"]
if "tier" in patch:
tv = patch["tier"]
if tv is None:
data["tier"] = "free"
else:
ts = str(tv).strip()
data["tier"] = (ts or "free")[:50]
del patch["tier"]
if "email" in patch:
ev = patch["email"]
if ev is None or (isinstance(ev, str) and ev.strip() == ""):
@ -127,23 +208,43 @@ def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)):
data["verification_token"] = None
data["verification_expires"] = None
if "active_club_id" in patch:
ac = patch["active_club_id"]
if ac is None:
data["active_club_id"] = None
else:
try:
cid = int(ac)
except (TypeError, ValueError):
raise HTTPException(400, "active_club_id ungültig")
assert_club_member(cur, int(pid), cid)
data["active_club_id"] = cid
nullable_keys = {"goal_weight", "goal_bf_pct", "dob"}
for k, v in patch.items():
if k == "email":
continue
if k == "active_club_id":
continue
if v is None and k in nullable_keys:
data[k] = None
elif v is not None:
data[k] = v
if not data:
return get_profile(pid, session)
return profile_document(pid)
data["updated_at"] = datetime.now()
cols = ", ".join(f"{k}=%s" for k in data)
vals = list(data.values()) + [pid]
cur.execute(f"UPDATE profiles SET {cols} WHERE id=%s", vals)
return get_profile(pid, session)
return profile_document(pid)
@router.put("/profiles/{pid}")
def update_profile(pid: str, p: ProfileUpdate, tenant: TenantContext = Depends(get_tenant_context)):
"""Update profile — nur eigenes Profil oder Admin; TenantContext validiert X-Active-Club-Id."""
return _run_profile_update(pid, p, tenant)
@router.delete("/profiles/{pid}")
@ -165,11 +266,15 @@ def delete_profile(pid: str, session=Depends(require_auth)):
def get_active_profile(x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth)):
"""Legacy endpoint returns active profile."""
pid = get_pid(x_profile_id)
return get_profile(pid, session)
return profile_document(pid)
@router.put("/profile")
def update_active_profile(p: ProfileUpdate, x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth)):
def update_active_profile(
p: ProfileUpdate,
x_profile_id: Optional[str] = Header(default=None),
tenant: TenantContext = Depends(get_tenant_context),
):
"""Update current user's profile."""
pid = get_pid(x_profile_id)
return update_profile(pid, p, session)
return _run_profile_update(pid, p, tenant)

View File

@ -3,13 +3,17 @@ 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.
AuthZ wie Planungs-Vorlagen: Admin sieht alle, sonst nur eigene Artefakte; Schreibzugriff mit Planungsrolle.
Lesen wie Übungen (official / private / club); Schreiben nur Ersteller oder Plattform-Admin.
"""
from typing import Any, Dict, List, Optional, Sequence
from fastapi import APIRouter, Depends, HTTPException
from auth import require_auth
from club_tenancy import (
assert_valid_governance_visibility,
is_platform_admin,
library_content_visible_to_profile,
)
from db import get_db, get_cursor, r2d
from routers.training_planning import (
@ -21,23 +25,57 @@ from routers.training_planning import (
_validate_variant_for_exercise,
)
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
router = APIRouter(prefix="/api", tags=["training_framework_programs"])
_VALID_VISIBILITY = frozenset({"private", "club", "official"})
def _framework_access(cur, framework_id: int, profile_id: int, role: str) -> Dict[str, Any]:
def _fetch_framework_row(cur, framework_id: int) -> Dict[str, Any]:
cur.execute("SELECT * FROM training_framework_programs WHERE id = %s", (framework_id,))
r = cur.fetchone()
if not r:
raise HTTPException(status_code=404, detail="Trainingsrahmen nicht gefunden")
row = r2d(r)
if role in ("admin", "superadmin"):
return row
return r2d(r)
def _framework_assert_readable(
cur, row: Dict[str, Any], profile_id: int, role: Optional[str]
) -> None:
if is_platform_admin(role):
return
if not library_content_visible_to_profile(
cur,
profile_id,
row.get("visibility") or "private",
row.get("club_id"),
row.get("created_by"),
role,
):
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)
return row
def _response_framework_detail(framework_id: int, profile_id: int, role: str) -> Dict[str, Any]:
"""Einzelabruf nach Schreiboperation (ohne FastAPI-Depends-Schleife)."""
with get_db() as conn:
cur = get_cursor(conn)
row = _framework_access(cur, framework_id, profile_id, role)
return _hydrate_framework(cur, row)
def _training_type_ids(cur, framework_id: int) -> List[int]:
cur.execute(
"""
@ -279,9 +317,9 @@ def _insert_slots_and_blueprints(
@router.get("/training-framework-programs")
def list_training_framework_programs(session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def list_training_framework_programs(tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
base_sel = """
@ -312,30 +350,39 @@ def list_training_framework_programs(session=Depends(require_auth)):
LEFT JOIN focus_areas fa ON fa.id = fp.focus_area_id
LEFT JOIN style_directions sd ON sd.id = fp.style_direction_id
"""
if role in ("admin", "superadmin"):
if is_platform_admin(role):
cur.execute(base_sel + " ORDER BY fp.updated_at DESC NULLS LAST, fp.title")
else:
vis_clause, vis_params = library_content_visibility_sql(
alias="fp",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
cur.execute(
base_sel + " WHERE fp.created_by = %s ORDER BY fp.updated_at DESC NULLS LAST, fp.title",
(profile_id,),
base_sel
+ f""" WHERE ({vis_clause})
ORDER BY fp.updated_at DESC NULLS LAST, fp.title""",
vis_params,
)
return [r2d(r) for r in cur.fetchall()]
@router.get("/training-framework-programs/{framework_id}")
def get_training_framework_program(framework_id: int, session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
with get_db() as conn:
cur = get_cursor(conn)
row = _framework_access(cur, framework_id, profile_id, role)
return _hydrate_framework(cur, row)
def get_training_framework_program(
framework_id: int, tenant: TenantContext = Depends(get_tenant_context)
):
profile_id = tenant.profile_id
role = tenant.global_role
return _response_framework_detail(framework_id, profile_id, role)
@router.post("/training-framework-programs")
def create_training_framework_program(data: dict, session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def create_training_framework_program(
data: dict, tenant: TenantContext = Depends(get_tenant_context)
):
profile_id = tenant.profile_id
role = tenant.global_role
if not _has_planning_role(role):
raise HTTPException(status_code=403, detail="Nur Planungsberechtigte dürfen Rahmenprogramme anlegen")
@ -346,6 +393,11 @@ def create_training_framework_program(data: dict, session=Depends(require_auth))
vis = data.get("visibility") or "private"
vis = _assert_visibility(vis)
club_id = data.get("club_id")
if club_id in ("", []):
club_id = None
if vis == "club" and club_id is None:
club_id = tenant.effective_club_id
goals_in = data.get("goals")
slots_in = data.get("slots")
if not isinstance(goals_in, list) or not goals_in:
@ -358,6 +410,7 @@ def create_training_framework_program(data: dict, session=Depends(require_auth))
with get_db() as conn:
cur = get_cursor(conn)
assert_valid_governance_visibility(cur, profile_id, role, vis, club_id)
cur.execute(
"""
INSERT INTO training_framework_programs (
@ -387,19 +440,36 @@ def create_training_framework_program(data: dict, session=Depends(require_auth))
_replace_target_groups(cur, fid, tg_ids)
conn.commit()
return get_training_framework_program(fid, session)
return _response_framework_detail(fid, profile_id, role)
@router.put("/training-framework-programs/{framework_id}")
def update_training_framework_program(framework_id: int, data: dict, session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def update_training_framework_program(
framework_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)
):
profile_id = tenant.profile_id
role = tenant.global_role
if not _has_planning_role(role):
raise HTTPException(status_code=403, detail="Keine Berechtigung")
with get_db() as conn:
cur = get_cursor(conn)
_framework_access(cur, framework_id, profile_id, role)
row_prev = _fetch_framework_row(cur, framework_id)
_framework_assert_writable(cur, row_prev, profile_id, role)
merged_vis = row_prev.get("visibility") or "private"
merged_club = row_prev.get("club_id")
if "visibility" in data:
v_m = _assert_visibility(data.get("visibility"))
if v_m is None:
raise HTTPException(status_code=400, detail="visibility fehlt")
merged_vis = v_m
if "club_id" in data:
merged_club = data.get("club_id")
if merged_club in ("", []):
merged_club = None
if "visibility" in data or "club_id" in data:
assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club)
header_fields = []
header_params: List[Any] = []
@ -422,14 +492,11 @@ def update_training_framework_program(framework_id: int, data: dict, session=Dep
header_params.append(data.get("planned_period_end"))
if "visibility" in data:
v = _assert_visibility(data.get("visibility"))
if v is None:
raise HTTPException(status_code=400, detail="visibility fehlt")
header_fields.append("visibility = %s")
header_params.append(v)
header_params.append(merged_vis)
if "club_id" in data:
header_fields.append("club_id = %s")
header_params.append(data.get("club_id"))
header_params.append(merged_club)
if "focus_area_id" in data:
fidv = data.get("focus_area_id")
@ -489,16 +556,19 @@ def update_training_framework_program(framework_id: int, data: dict, session=Dep
conn.commit()
return get_training_framework_program(framework_id, session)
return _response_framework_detail(framework_id, profile_id, role)
@router.delete("/training-framework-programs/{framework_id}")
def delete_training_framework_program(framework_id: int, session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def delete_training_framework_program(
framework_id: int, tenant: TenantContext = Depends(get_tenant_context)
):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
_framework_access(cur, framework_id, profile_id, role)
row_fw = _fetch_framework_row(cur, framework_id)
_framework_assert_writable(cur, row_fw, profile_id, role)
cur.execute(
"DELETE FROM training_framework_programs WHERE id = %s",
(framework_id,),

View File

@ -2,14 +2,19 @@
Training Planning Trainingseinheiten, strukturierter Ablauf (Sektionen + Übungen/Notizen)
und wiederverwendbare Trainingsvorlagen (Sektions-Gliederung).
Governance (Vorlagen-rechte über Vereine/offiziell) kann später nachgezogen werden.
Governance: Sichtbarkeit wie Übungen (private / club / official); Schreiben nur Ersteller oder Plattform-Admin.
"""
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from db import get_db, get_cursor, r2d
from auth import require_auth
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
from club_tenancy import (
assert_valid_governance_visibility,
is_platform_admin,
library_content_visible_to_profile,
)
router = APIRouter(prefix="/api", tags=["training_planning"])
@ -515,23 +520,39 @@ def _instantiate_from_template(cur, unit_id: int, template_id: int):
)
def _template_access(cur, tid: int, profile_id: int, role: str) -> Dict[str, Any]:
cur.execute(
"""
SELECT *
FROM training_plan_templates
WHERE id = %s
""",
(tid,),
)
def _fetch_training_plan_template_row(cur, tid: int) -> Dict[str, Any]:
cur.execute("SELECT * FROM training_plan_templates WHERE id = %s", (tid,))
r = cur.fetchone()
if not r:
raise HTTPException(status_code=404, detail="Trainingsvorlage nicht gefunden")
row = r2d(r)
if role in ["admin", "superadmin"]:
return row
if row["created_by"] != profile_id:
return r2d(r)
def _template_assert_readable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None:
if is_platform_admin(role):
return
if not library_content_visible_to_profile(
cur,
profile_id,
row.get("visibility") or "club",
row.get("club_id"),
row.get("created_by"),
role,
):
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)
_template_assert_readable(cur, row, profile_id, role)
return row
@ -539,12 +560,12 @@ def _template_access(cur, tid: int, profile_id: int, role: str) -> Dict[str, Any
@router.get("/training-plan-templates")
def list_training_plan_templates(session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def list_training_plan_templates(tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
if role in ["admin", "superadmin"]:
if is_platform_admin(role):
cur.execute(
"""
SELECT t.*,
@ -555,24 +576,30 @@ def list_training_plan_templates(session=Depends(require_auth)):
"""
)
else:
vis_clause, vis_params = library_content_visibility_sql(
alias="t",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
cur.execute(
"""
f"""
SELECT t.*,
(SELECT COUNT(*) FROM training_plan_template_sections s WHERE s.template_id = t.id)
AS sections_count
FROM training_plan_templates t
WHERE t.created_by = %s
WHERE ({vis_clause})
ORDER BY t.updated_at DESC NULLS LAST, t.name
""",
(profile_id,),
vis_params,
)
return [r2d(r) for r in cur.fetchall()]
@router.get("/training-plan-templates/{template_id}")
def get_training_plan_template(template_id: int, session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def get_training_plan_template(template_id: int, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
row = _template_access(cur, template_id, profile_id, role)
@ -590,26 +617,33 @@ def get_training_plan_template(template_id: int, session=Depends(require_auth)):
@router.post("/training-plan-templates")
def create_training_plan_template(data: dict, session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def create_training_plan_template(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
role = tenant.global_role
if not _has_planning_role(role):
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Vorlagen anlegen")
name = (data.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name ist Pflicht")
vis_raw = data.get("visibility")
visibility = (vis_raw if isinstance(vis_raw, str) else "club").strip() or "club"
club_id = data.get("club_id")
if club_id in ("", []):
club_id = None
if visibility == "club" and club_id is None:
club_id = tenant.effective_club_id
sections_in = data.get("sections") or []
with get_db() as conn:
cur = get_cursor(conn)
assert_valid_governance_visibility(cur, profile_id, role, visibility, club_id)
cur.execute(
"""
INSERT INTO training_plan_templates (club_id, created_by, name, description)
VALUES (%s, %s, %s, %s)
INSERT INTO training_plan_templates (club_id, created_by, name, description, visibility)
VALUES (%s, %s, %s, %s, %s)
RETURNING id
""",
(club_id, profile_id, name, data.get("description")),
(club_id, profile_id, name, data.get("description"), visibility),
)
tid = cur.fetchone()["id"]
for si, sec in enumerate(sections_in):
@ -625,16 +659,30 @@ def create_training_plan_template(data: dict, session=Depends(require_auth)):
(tid, order_ix, title, sec.get("guidance_text")),
)
conn.commit()
return get_training_plan_template(tid, session)
return get_training_plan_template(tid, tenant)
@router.put("/training-plan-templates/{template_id}")
def update_training_plan_template(template_id: int, data: dict, session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def update_training_plan_template(template_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
_template_access(cur, template_id, profile_id, role)
row_prev = _fetch_training_plan_template_row(cur, template_id)
_template_assert_writable(cur, row_prev, profile_id, role)
merged_vis = row_prev.get("visibility") or "club"
merged_club = row_prev.get("club_id")
if "visibility" in data:
v_in = data.get("visibility")
if not isinstance(v_in, str) or v_in not in ("private", "club", "official"):
raise HTTPException(status_code=400, detail="visibility ungültig")
merged_vis = v_in
if "club_id" in data:
merged_club = data.get("club_id")
if merged_club in ("", []):
merged_club = None
if "visibility" in data or "club_id" in data:
assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club)
fields = []
params: List[Any] = []
if "name" in data:
@ -649,7 +697,10 @@ def update_training_plan_template(template_id: int, data: dict, session=Depends(
params.append(data.get("description"))
if "club_id" in data:
fields.append("club_id = %s")
params.append(data.get("club_id"))
params.append(merged_club)
if "visibility" in data:
fields.append("visibility = %s")
params.append(merged_vis)
fields.append("updated_at = NOW()")
params.append(template_id)
cur.execute(
@ -677,16 +728,17 @@ def update_training_plan_template(template_id: int, data: dict, session=Depends(
(template_id, order_ix, title, sec.get("guidance_text")),
)
conn.commit()
return get_training_plan_template(template_id, session)
return get_training_plan_template(template_id, tenant)
@router.delete("/training-plan-templates/{template_id}")
def delete_training_plan_template(template_id: int, session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def delete_training_plan_template(template_id: int, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
_template_access(cur, template_id, profile_id, role)
row_del = _fetch_training_plan_template_row(cur, template_id)
_template_assert_writable(cur, row_del, profile_id, role)
cur.execute("DELETE FROM training_plan_templates WHERE id = %s", (template_id,))
conn.commit()
return {"ok": True}
@ -705,10 +757,10 @@ def list_training_units(
assigned_to_me: bool = Query(default=False),
sort: str = Query(default="desc"),
limit: Optional[int] = Query(default=None),
session=Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = session["profile_id"]
role = session.get("role")
profile_id = tenant.profile_id
role = tenant.global_role
gid = _optional_positive_int(group_id, "group_id") if group_id else None
cid = _optional_positive_int(club_id, "club_id") if club_id else None
@ -818,9 +870,9 @@ def list_training_units(
@router.get("/training-units/{unit_id}")
def get_training_unit(unit_id: int, session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def get_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
@ -882,9 +934,9 @@ def get_training_unit(unit_id: int, session=Depends(require_auth)):
@router.post("/training-units")
def create_training_unit(data: dict, session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
role = tenant.global_role
group_id = data.get("group_id")
planned_date = data.get("planned_date")
@ -941,13 +993,13 @@ def create_training_unit(data: dict, session=Depends(require_auth)):
conn.commit()
return get_training_unit(unit_id, session)
return get_training_unit(unit_id, tenant)
@router.put("/training-units/{unit_id}")
def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
@ -1088,13 +1140,13 @@ def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth)
conn.commit()
return get_training_unit(unit_id, session)
return get_training_unit(unit_id, tenant)
@router.delete("/training-units/{unit_id}")
def delete_training_unit(unit_id: int, session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def delete_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
@ -1124,10 +1176,10 @@ def delete_training_unit(unit_id: int, session=Depends(require_auth)):
@router.post("/training-units/from-framework-slot")
def create_training_unit_from_framework_slot(data: dict, session=Depends(require_auth)):
def create_training_unit_from_framework_slot(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
"""Geplante Einheit aus Rahmen-Slot-Blueprint kopieren (Lineage über origin_framework_slot_id)."""
profile_id = session["profile_id"]
role = session.get("role")
profile_id = tenant.profile_id
role = tenant.global_role
if not _has_planning_role(role):
raise HTTPException(status_code=403, detail="Nur Planungsberechtigte dürfen Trainingseinheiten erstellen")
@ -1188,12 +1240,12 @@ def create_training_unit_from_framework_slot(data: dict, session=Depends(require
conn.commit()
return get_training_unit(new_id, session)
return get_training_unit(new_id, tenant)
@router.post("/training-units/quick-create")
def quick_create_training_unit(data: dict, session=Depends(require_auth)):
profile_id = session["profile_id"]
def quick_create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
group_id = data.get("group_id")
planned_date = data.get("planned_date")
@ -1219,7 +1271,7 @@ def quick_create_training_unit(data: dict, session=Depends(require_auth)):
if not group:
raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden")
role = session.get("role")
role = tenant.global_role
co_trainers = group["co_trainer_ids"] or []
if not _has_planning_role(role):
@ -1261,5 +1313,5 @@ def quick_create_training_unit(data: dict, session=Depends(require_auth)):
conn.commit()
return get_training_unit(unit_id, session)
return get_training_unit(unit_id, tenant)

View File

@ -49,14 +49,14 @@ def get_db_connection():
password=p["password"],
)
conn.autocommit = False
print(f" Connected to database: {p['dbname']}")
print(f"[OK] Connected to database: {p['dbname']}")
return conn
except psycopg2.OperationalError:
if i < max_retries - 1:
print(f"Waiting for database... ({i+1}/{max_retries})")
time.sleep(2)
else:
print(f" Failed to connect to database after {max_retries} attempts")
print(f"[FAIL] Failed to connect to database after {max_retries} attempts")
raise
@ -72,7 +72,7 @@ def init_migrations_table(conn):
"""
)
conn.commit()
print(" schema_migrations initialisiert")
print("[OK] schema_migrations initialisiert")
_LEADING_DIGITS = re.compile(r"^(\d+)")
@ -190,7 +190,7 @@ def run_migration(conn, migration_name: str, filepath: str) -> bool:
if shutil.which("psql"):
ok, diag = _run_file_with_psql(filepath)
if not ok:
print(f" psql fehlgeschlagen:\n{diag or '(kein Output)'}")
print(f" [FAIL] psql fehlgeschlagen:\n{diag or '(kein Output)'}")
conn.rollback()
return False
detail_suffix = "(psql -1)"
@ -199,7 +199,7 @@ def run_migration(conn, migration_name: str, filepath: str) -> bool:
with open(filepath, "r", encoding="utf-8") as fh:
body = fh.read()
except OSError as e:
print(f" kann Datei nicht lesen: {e}")
print(f" [FAIL] kann Datei nicht lesen: {e}")
conn.rollback()
return False
@ -207,7 +207,7 @@ def run_migration(conn, migration_name: str, filepath: str) -> bool:
with conn.cursor() as cur:
if not statements:
print(
f" keine ausführbaren Statements (leer?) — "
f" [WARN] keine ausführbaren Statements (leer?) — "
f"Eintrag trotzdem: {migration_name}"
)
else:
@ -217,12 +217,12 @@ def run_migration(conn, migration_name: str, filepath: str) -> bool:
_record_migration(conn, migration_name)
conn.commit()
print(f" {migration_name} erfolgreich {detail_suffix}")
print(f" [OK] {migration_name} erfolgreich {detail_suffix}")
return True
except Exception as e:
conn.rollback()
print(f" {migration_name}: {e}")
print(f" [FAIL] {migration_name}: {e}")
return False
@ -243,7 +243,7 @@ def main():
pending = get_pending(conn, migrations_dir)
if not pending:
print(" Keine ausstehenden Migrationen — Schema aktuell.")
print("[OK] Keine ausstehenden Migrationen — Schema aktuell.")
conn.close()
return 0
@ -262,17 +262,17 @@ def main():
print("\n" + "=" * 60)
if failed:
print(f" Abbruch nach: {failed}")
print(f"[FAIL] Abbruch nach: {failed}")
print(" (Bereits erfolgreiche Dateien dieser Session sind committed.)")
print("=" * 60)
return 1
print(f" {len(pending)} Migration(s) angewendet — Schema aktuell.")
print(f"[OK] {len(pending)} Migration(s) angewendet — Schema aktuell.")
print("=" * 60)
return 0
except Exception as e:
print(f"\n Fehler: {e}")
print(f"\n[FAIL] Fehler: {e}")
return 1

View File

@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""
Heuristik-Check: Router mit FastAPI-Routen und Auth sollten bei mandantenrelevanten APIs
get_tenant_context nutzen siehe ACCESS_LAYER_AND_GOVERNANCE_PLAN.md.
Lauf aus Repo-Root:
python backend/scripts/check_access_layer_hints.py
Mit Fehler-Exit bei Verstößen (z. B. CI):
set ACCESS_LAYER_STRICT=1 # Windows: set ACCESS_LAYER_STRICT=1
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
# Router, die bewusst keinen TenantContext verwenden (global / Auth-only / Admin-Tools).
# Neuen Eintrag nur mit kurzer Begründung im Commit/Audit ergänzen.
EXEMPT_ROUTERS: frozenset[str] = frozenset(
{
"auth.py",
"admin_users.py",
"catalogs.py",
"skills.py",
"maturity_models.py",
"matrix_stack_bundle.py",
"import_wiki.py",
"import_wiki_admin.py",
}
)
def main() -> int:
strict = os.environ.get("ACCESS_LAYER_STRICT", "").strip().lower() in ("1", "true", "yes")
root = Path(__file__).resolve().parents[1]
routers = root / "routers"
if not routers.is_dir():
print("check_access_layer_hints: routers/ nicht gefunden", file=sys.stderr)
return 1
issues: list[str] = []
for path in sorted(routers.glob("*.py")):
name = path.name
text = path.read_text(encoding="utf-8")
if "@router." not in text:
continue
if name in EXEMPT_ROUTERS:
continue
if "get_tenant_context" in text:
continue
if "require_auth" not in text:
continue
issues.append(
f" {path.relative_to(root.parent)}: Routen + require_auth, "
f"aber kein get_tenant_context — TenantContext ergänzen oder in EXEMPT_ROUTERS eintragen "
f"(mit Begründung / Audit)."
)
if not issues:
print("check_access_layer_hints: OK (keine auffälligen Router außerhalb EXEMPT).")
return 0
print("check_access_layer_hints: mögliche ACCESS_LAYER-Abweichungen:\n", file=sys.stderr)
for line in issues:
print(line, file=sys.stderr)
print(
"\nHinweis: Heuristik — false positives möglich. "
"Bei echter Ausnahme Datei zu EXEMPT_ROUTERS hinzufügen.",
file=sys.stderr,
)
return 1 if strict else 0
if __name__ == "__main__":
raise SystemExit(main())

206
backend/tenant_context.py Normal file
View File

@ -0,0 +1,206 @@
"""
Request-weiter Mandanten-Kontext (ACCESS_LAYER_AND_GOVERNANCE_PLAN.md, Stufe B).
Zielt auf einheitliche Auflösung aus Session + Header X-Active-Club-Id + Profilfeld active_club_id.
Router können Depends(get_tenant_context) nutzen oder resolve_tenant_context mit bereits geladenen Mitgliedschaften (ein DB-Block).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from fastapi import Depends, Header, HTTPException
from auth import require_auth
from club_tenancy import is_platform_admin, memberships_with_roles
from db import get_db, get_cursor
def _club_exists(cur, club_id: int) -> bool:
cur.execute("SELECT 1 FROM clubs WHERE id = %s", (club_id,))
return cur.fetchone() is not None
def parse_active_club_header(raw: Optional[str]) -> Optional[int]:
"""Parst X-Active-Club-Id; leer → None. Ungültig → HTTP 400."""
if raw is None:
return None
s = str(raw).strip()
if not s:
return None
try:
v = int(s)
except ValueError:
raise HTTPException(status_code=400, detail="X-Active-Club-Id ungültig")
if v < 1:
raise HTTPException(status_code=400, detail="X-Active-Club-Id ungültig")
return v
def library_content_visibility_sql(
*,
alias: str,
profile_id: int,
role: str,
effective_club_id: Optional[int],
) -> tuple[str, List[Any]]:
"""
WHERE-Baustein für Bibliothekslisten (Übungen, Vorlagen, Rahmenprogramme):
official, eigene private, club nur im aktiven Vereinskontext (effective_club_id).
Plattform-Admin: keine Einschränkung (TRUE).
Ohne effective_club_id: kein club-Zweig (nur official + private).
"""
if is_platform_admin(role):
return "TRUE", []
parts: List[str] = [
f"{alias}.visibility = 'official'",
f"({alias}.visibility = 'private' AND {alias}.created_by = %s)",
]
params: List[Any] = [profile_id]
if effective_club_id is not None:
parts.append(
f"""(
{alias}.visibility = 'club'
AND {alias}.club_id IS NOT NULL
AND {alias}.club_id = %s
AND EXISTS (
SELECT 1 FROM club_members cm
WHERE cm.profile_id = %s AND cm.club_id = {alias}.club_id AND cm.status = 'active'
)
)"""
)
params.extend([effective_club_id, profile_id])
return "(" + " OR ".join(parts) + ")", params
@dataclass
class TenantContext:
profile_id: int
global_role: str
# Header > gespeichertes Profil > Fallback; Plattform-Admin ohne Header oft None
effective_club_id: Optional[int]
club_ids: frozenset[int]
memberships: List[Dict[str, Any]]
def resolve_tenant_context(
cur,
*,
profile_id: int,
global_role: str,
header_raw: Optional[str],
memberships: Optional[List[Dict[str, Any]]] = None,
stored_active_club_id: Optional[int] = None,
invalid_header_policy: str = "reject",
) -> TenantContext:
"""
Mitgliedschaften: wenn nicht übergeben, wird aus der DB geladen (aktive Mitgliedschaften).
Auflösung effective_club_id:
- Plattform-Admin: Header setzt beliebigen existierenden Verein; ohne Header None.
- Sonst: gültiger Header zwingend Mitgliedschaft bei ``reject`` sonst 403, bei ``ignore`` wie ohne Header.
Ohne gültigen Header: gespeichertes active_club_id wenn Mitglied; sonst einziger Verein;
bei mehreren ohne gültige Vorgabe min(club_ids) (Fallback).
"""
role_lc = (global_role or "").lower()
header_cid = parse_active_club_header(header_raw)
if memberships is None:
memberships = memberships_with_roles(cur, profile_id, active_only=True)
club_ids = frozenset(int(r["id"]) for r in memberships if r.get("id") is not None)
if is_platform_admin(role_lc):
if header_cid is not None:
if not _club_exists(cur, header_cid):
raise HTTPException(status_code=400, detail="Verein nicht gefunden")
effective = header_cid
else:
effective = None
return TenantContext(
profile_id=profile_id,
global_role=role_lc,
effective_club_id=effective,
club_ids=club_ids,
memberships=memberships,
)
chosen_header = header_cid
if chosen_header is not None and chosen_header not in club_ids:
if invalid_header_policy == "reject":
raise HTTPException(
status_code=403,
detail="Keine Mitgliedschaft im gewählten Verein",
)
chosen_header = None
if chosen_header is not None:
effective = chosen_header
elif stored_active_club_id is not None and stored_active_club_id in club_ids:
effective = stored_active_club_id
elif len(club_ids) == 1:
effective = next(iter(club_ids))
elif len(club_ids) == 0:
effective = None
else:
effective = min(club_ids)
return TenantContext(
profile_id=profile_id,
global_role=role_lc,
effective_club_id=effective,
club_ids=club_ids,
memberships=memberships,
)
def get_tenant_context(
session: dict = Depends(require_auth),
x_active_club_id: Optional[str] = Header(default=None, alias="X-Active-Club-Id"),
) -> TenantContext:
"""FastAPI-Dependency: öffnet eine DB-Verbindung und liefert TenantContext."""
pid = int(session["profile_id"])
role = session.get("role") or ""
stored: Optional[int] = None
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT active_club_id FROM profiles WHERE id = %s", (pid,))
row = cur.fetchone()
if row is not None:
ac = row.get("active_club_id")
if ac is not None:
stored = int(ac)
return resolve_tenant_context(
cur,
profile_id=pid,
global_role=role,
header_raw=x_active_club_id,
memberships=None,
stored_active_club_id=stored,
)
def tenant_context_from_session_only(
cur,
session: dict,
header_raw: Optional[str],
*,
memberships: Optional[List[Dict[str, Any]]] = None,
stored_active_club_id: Optional[int] = None,
invalid_header_policy: str = "reject",
) -> TenantContext:
"""Variante ohne FastAPI (Tests / gemeinsamer Cursor mit anderen Queries)."""
pid = int(session["profile_id"])
role = session.get("role") or ""
return resolve_tenant_context(
cur,
profile_id=pid,
global_role=role,
header_raw=header_raw,
memberships=memberships,
stored_active_club_id=stored_active_club_id,
invalid_header_policy=invalid_header_policy,
)

View File

@ -0,0 +1,76 @@
"""Unit tests ohne Datenbank für die Zugriffsschicht (Visibility-SQL, Header-Parsing)."""
import pytest
from fastapi import HTTPException
from tenant_context import library_content_visibility_sql, parse_active_club_header
def test_library_visibility_sql_platform_admin_no_filter():
sql, params = library_content_visibility_sql(
alias="e",
profile_id=1,
role="admin",
effective_club_id=None,
)
assert sql == "TRUE"
assert params == []
def test_library_visibility_sql_superadmin():
sql, params = library_content_visibility_sql(
alias="fp",
profile_id=2,
role="superadmin",
effective_club_id=100,
)
assert sql == "TRUE"
assert params == []
def test_library_visibility_sql_trainer_without_active_club_no_shared_club_branch():
sql, params = library_content_visibility_sql(
alias="g",
profile_id=42,
role="trainer",
effective_club_id=None,
)
assert "official" in sql
assert "private" in sql
assert "visibility = 'club'" not in sql
assert params == [42]
def test_library_visibility_sql_user_with_active_club_includes_club_branch():
sql, params = library_content_visibility_sql(
alias="t",
profile_id=7,
role="user",
effective_club_id=99,
)
assert "visibility = 'club'" in sql
assert "club_members" in sql
assert params[0] == 7 # private branch created_by
assert 99 in params
assert params.count(7) >= 2 # private + EXISTS membership
def test_parse_active_club_header_none_and_empty():
assert parse_active_club_header(None) is None
assert parse_active_club_header("") is None
assert parse_active_club_header(" ") is None
def test_parse_active_club_header_valid():
assert parse_active_club_header("12") == 12
def test_parse_active_club_header_invalid():
with pytest.raises(HTTPException) as exc:
parse_active_club_header("not-int")
assert exc.value.status_code == 400
def test_parse_active_club_header_non_positive():
with pytest.raises(HTTPException) as exc:
parse_active_club_header("0")
assert exc.value.status_code == 400

View File

@ -1,20 +1,24 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.11"
APP_VERSION = "0.8.29"
BUILD_DATE = "2026-05-05"
DB_SCHEMA_VERSION = "20260505038"
DB_SCHEMA_VERSION = "20260505041"
MODULE_VERSIONS = {
"auth": "1.0.0",
"profiles": "1.0.0",
"clubs": "0.1.0",
"auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute)
"profiles": "1.4.1", # PUT /profiles*, Legacy /profile: Depends(get_tenant_context); profile_document für internes Laden
"tenant_context": "1.0.3", # pytest: backend/tests/test_access_layer.py (Visibility-SQL, Header)
"clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext
"club_memberships": "1.0.1", # Depends(get_tenant_context)
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
"admin_users": "1.0.0", # GET /api/admin/users
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
"exercises": "2.3.0", # Progressionsgraph: Sequenz-API, Varianten-Knoten, UX Ketten (Migration 034)
"exercises": "2.7.0", # PATCH /exercises/bulk-metadata — Massenänderung Sichtbarkeit/Status
"training_units": "0.1.0",
"training_programs": "0.1.0",
"planning": "0.6.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
"import_wiki": "1.0.0",
"admin": "1.0.0",
"membership": "1.0.0",
@ -23,6 +27,150 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.29",
"date": "2026-05-05",
"changes": [
"Übungen: PATCH /api/exercises/bulk-metadata (bis 500 IDs) — Sichtbarkeit und/oder Status; Ersteller oder Plattform-Admin; Governance wie Einzel-PUT",
"Übungsliste: Mehrfachauswahl, Alle auf dieser Seite, Dialog Massenänderung (Verein/offiziell nur Admins)",
],
},
{
"version": "0.8.28",
"date": "2026-05-05",
"changes": [
"ACCESS_LAYER: Übungen-, Trainingsplanung-, Rahmenprogramme-Detail nutzen library_content_visible_to_profile (einheitliche Leseprüfung)",
"pytest: backend/requirements-dev.txt, pytest.ini, backend/tests/test_access_layer.py — ohne DB (library_content_visibility_sql, parse_active_club_header)",
],
},
{
"version": "0.8.27",
"date": "2026-05-05",
"changes": [
"ACCESS_LAYER Governance-Disziplin: .cursor/rules/access-layer.mdc; ARCHITECTURE 1.4 + CODING_RULES Tenant-Pfad; CLAUDE Pflichtlektüre Zugriffsschicht",
"backend/scripts/check_access_layer_hints.py — Router ohne get_tenant_context außerhalb EXEMPT melden (optional ACCESS_LAYER_STRICT=1)",
"club_tenancy.library_content_visible_to_profile; Audit-Tabelle um globale Router (EXEMPT) ergänzt",
],
},
{
"version": "0.8.26",
"date": "2026-05-05",
"changes": [
"ACCESS_LAYER: clubs-, club_memberships-, club_join_requests-Router nutzen Depends(get_tenant_context) statt nur require_auth",
"profiles: PUT /profiles/{id} und /profile mit TenantContext; profile_document für /auth/me und intern",
"exercise_progression_graphs: Liste/Detail nach library_content_visibility_sql; Leserechte Vereins-Graphs; POST/PUT mit assert_valid_governance_visibility und club_id wie Übungen",
],
},
{
"version": "0.8.25",
"date": "2026-05-05",
"changes": [
"Übungen PUT: bei visibility club wird club_id aus aktivem Verein oder Body gesetzt (verhindert club ohne club_id für Vereinsnutzer)",
"club_tenancy: governance visibility club für Plattform-Admins ohne Vereinsmitgliedschaft (nur Existenz clubs.id)",
"Login POST /api/auth/login: Rate-Limit 30/minute pro IP (vorher 5/minute)",
],
},
{
"version": "0.8.24",
"date": "2026-05-05",
"changes": [
"Übungen-Router: get/update/delete, Varianten, Medien — Depends(get_tenant_context); Upload-Limits via TenantContext; fehlenden DELETE decorator variants gefixt",
],
},
{
"version": "0.8.23",
"date": "2026-05-05",
"changes": [
"ACCESS_LAYER: library_content_visibility_sql + TenantContext an Übungen-Liste, Rahmenprogramme, Trainingsplanung",
"POST Übung/Vorlage/Rahmenprogramm: visibility club ohne club_id → effective_club_id; POST Übung mit assert_valid_governance_visibility",
],
},
{
"version": "0.8.22",
"date": "2026-05-05",
"changes": [
"ACCESS_LAYER Stufe B: Modul tenant_context (resolve_tenant_context, Depends(get_tenant_context)); GET /profiles/me liefert effective_club_id; veralteter X-Active-Club-Id wird dort verworfen (ignore), strikt auf anderen Endpoints via Depends",
"Arbeitspapier ACCESS_LAYER_ENDPOINT_AUDIT.md für Router-Inventar",
],
},
{
"version": "0.8.21",
"date": "2026-05-05",
"changes": [
"FastAPI: Router club_memberships und club_join_requests registriert (GET /api/clubs/{id}/members, join-requests u. a.) — behoben 404 auf Vereinsseite Tab Mitglieder",
],
},
{
"version": "0.8.20",
"date": "2026-05-05",
"changes": [
"Migration 041: wenn noch kein superadmin existiert, werden ältestes Profil mit role admin zu superadmin hochgestuft",
"Registrierung: erster Nutzer und ADMIN_BOOTSTRAP_EMAILS erhalten superadmin (vorher admin)",
],
},
{
"version": "0.8.19",
"date": "2026-05-05",
"changes": [
"Portal-Admin: GET /api/admin/users (alle Nutzer + Vereine); PUT /profiles/{id} mit role/tier (Super-Admin nur durch Super-Admin); Mitgliedschaft inaktiv in Übersicht",
"GUI Admin → Nutzer: Portal-Rolle/Tier, Verein zuweisen, Vereinsrollen bearbeiten",
],
},
{
"version": "0.8.18",
"date": "2026-05-05",
"changes": [
"DB 040 club_membership_requests; API Antrag/Abrufen/annehmen/ablehnen; öffentliches Vereinsverzeichnis; Mitglieder-Directory für Trainerwahl",
"GUI: Vereinsverwaltung Tab Mitglieder & Anträge; Registrierung/Einstellungen Vereinsantrag; Gruppenformular Haupt- und Co-Trainer",
"GET /profiles nur noch für Plattform-Admins",
],
},
{
"version": "0.8.17",
"date": "2026-05-05",
"changes": [
"Multi-Tenancy Phase 4: training_plan_templates + training_framework_programs Listen und Lesen nach visibility/club wie Übungen; Schreiben nur Ersteller oder Plattform-Admin; club_tenancy.assert_valid_governance_visibility",
],
},
{
"version": "0.8.16",
"date": "2026-05-05",
"changes": [
"API Vereinsmitglieder: GET/POST/GET-one/PUT/DELETE /api/clubs/{id}/members (Plattform- oder Vereinsadmin); Frontend api.js Hooks",
],
},
{
"version": "0.8.15",
"date": "2026-05-05",
"changes": [
"DB 039 Fix: Co-Trainer-Backfill über Subquery + CASE (kein jsonb_array_length/jsonb_array_elements auf Nicht-Arrays durch Planner/LATERAL-Reihenfolge)",
],
},
{
"version": "0.8.14",
"date": "2026-05-05",
"changes": [
"DB 039 Fix: Co-Trainer-Backfill nur wenn co_trainer_ids ein JSON-Array ist (vermeidet jsonb_array_length auf Nicht-Array)",
],
},
{
"version": "0.8.13",
"date": "2026-05-05",
"changes": [
"Fix: Startup unter Windows (cp1252) — Emoji/Sonderzeichen in print durch ASCII ([OK]/[FAIL]/[WARN]) ersetzt (main, run_migrations, db_init)",
],
},
{
"version": "0.8.12",
"date": "2026-05-05",
"changes": [
"DB 039: club_members, club_member_roles, clubs.primary_admin_profile_id, profiles.active_club_id + Backfill",
"Multi-Tenancy: Vereinslisten gefiltert, Vereinsanlage nur Plattform-Admin mit primary_admin_profile_id",
"Übungen: visibility club nur für Mitglieder des zugeordneten Vereins",
"GET /api/profiles/me: clubs[], ohne pin_hash; active_club_id setzen via PUT",
"Frontend: X-Active-Club-Id, Vereins-Umschalter, Vereinsseiten angepasst",
],
},
{
"version": "0.8.11",
"date": "2026-05-05",

View File

@ -30,6 +30,8 @@ import AdminHierarchyPage from './pages/AdminHierarchyPage'
import AdminMaturityModelsPage from './pages/AdminMaturityModelsPage'
import TrainerContextsPage from './pages/TrainerContextsPage'
import MediaWikiImportPage from './pages/MediaWikiImportPage'
import AdminUsersPage from './pages/AdminUsersPage'
import ActiveClubSwitcher from './components/ActiveClubSwitcher'
import './app.css'
// Bottom Navigation (Mobile)
@ -98,8 +100,11 @@ function ProtectedLayout() {
<DesktopSidebar isAdmin={isAdmin} user={user} onLogout={handleLogout} />
<div className="app-shell">
<div className="app-shell__column">
<div className="app-header app-header--mobile">
<div className="app-logo">🥋 Shinkan</div>
<div className="app-header app-header--mobile app-header--mobile-stack">
<div className="app-header-mobile__top">
<div className="app-logo">🥋 Shinkan</div>
</div>
<ActiveClubSwitcher variant="mobile" />
</div>
<div className="app-main">
<Outlet />
@ -166,6 +171,7 @@ function AppRoutes() {
<Route path="planning/run/:unitId" element={<TrainingUnitRunPage />} />
<Route path="planning" element={<TrainingPlanningPage />} />
<Route path="admin" element={<Navigate to="/admin/hierarchy" replace />} />
<Route path="admin/users" element={<AdminUsersPage />} />
<Route path="admin/hierarchy" element={<AdminHierarchyPage />} />
<Route path="admin/maturity-models" element={<AdminMaturityModelsPage />} />
<Route path="admin/catalogs" element={<AdminCatalogsPage />} />

View File

@ -69,8 +69,42 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
padding-right: max(16px, env(safe-area-inset-right, 0px));
}
}
.app-header--mobile-stack {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
padding-bottom: 10px;
}
.app-header-mobile__top {
display: flex;
align-items: center;
min-height: var(--header-h);
}
.app-logo { font-size: 18px; font-weight: 700; color: var(--accent); letter-spacing: -0.02em; }
.active-club-switch {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.active-club-switch__label {
font-size: 0.75rem;
font-weight: 500;
color: var(--text2);
}
.active-club-switch__select {
width: 100%;
font-size: 0.875rem;
padding: 0.4rem 0.5rem;
}
.active-club-switch--sidebar {
width: 100%;
padding-bottom: 4px;
}
.active-club-switch--mobile {
width: 100%;
}
/* === Seiten-Inhalt: volle Breite der Spalte, kein künstlicher Max-Wert auf großen Screens === */
.app-page {
width: 100%;
@ -2198,7 +2232,14 @@ a.analysis-split__nav-item {
.desktop-sidebar__footer {
border-top: 1px solid var(--border);
padding: 16px 12px 0;
padding: 16px 12px 16px;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.desktop-sidebar__footer-row {
display: flex;
align-items: center;
gap: 8px;

View File

@ -0,0 +1,44 @@
import { useAuth } from '../context/AuthContext'
/**
* Zeigt einen Vereins-Umschalter, wenn der Nutzer mehreren Vereinen zugeordnet ist.
* Steuert den Mandanten-Kontext (Header X-Active-Club-Id + Profilfeld active_club_id).
*/
export default function ActiveClubSwitcher({ variant = 'sidebar' }) {
const { user, setActiveClub } = useAuth()
const clubs = user?.clubs || []
if (clubs.length <= 1) return null
const selectClubId =
user?.active_club_id != null && clubs.some((c) => c.id === user.active_club_id)
? user.active_club_id
: clubs[0]?.id
const isMobile = variant === 'mobile'
return (
<label
className={
'active-club-switch' +
(isMobile ? ' active-club-switch--mobile' : ' active-club-switch--sidebar')
}
>
<span className="active-club-switch__label">Aktiver Verein</span>
<select
className="form-input active-club-switch__select"
aria-label="Aktiven Verein wählen"
value={selectClubId ?? ''}
onChange={(e) => {
const v = e.target.value
if (v) setActiveClub(Number(v))
}}
>
{clubs.map((c) => (
<option key={c.id} value={c.id}>
{c.name || `Verein #${c.id}`}
</option>
))}
</select>
</label>
)
}

View File

@ -1,5 +1,5 @@
import { NavLink, useLocation } from 'react-router-dom'
import { TreePine, FolderTree, Download, Grid3x3 } from 'lucide-react'
import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react'
/**
* Admin-Seiten-Navigation (horizontal)
@ -10,6 +10,7 @@ export default function AdminPageNav() {
const pages = [
{ to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine },
{ to: '/admin/users', label: 'Nutzer', icon: Users },
{ to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download }

View File

@ -1,6 +1,7 @@
import { NavLink, useLocation } from 'react-router-dom'
import { LogOut } from 'lucide-react'
import { getMainNavItems } from '../config/appNav'
import ActiveClubSwitcher from './ActiveClubSwitcher'
function sidebarLinkActive(pathname, item, routerIsActive) {
if (item.to.startsWith('/admin')) return pathname.startsWith('/admin')
@ -46,40 +47,43 @@ export default function DesktopSidebar({
</nav>
<div className="desktop-sidebar__footer">
<div className="desktop-sidebar__user">
<div
style={{
width: 32,
height: 32,
borderRadius: '50%',
background: 'var(--accent)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontWeight: 600,
fontSize: '14px'
}}
<ActiveClubSwitcher variant="sidebar" />
<div className="desktop-sidebar__footer-row">
<div className="desktop-sidebar__user">
<div
style={{
width: 32,
height: 32,
borderRadius: '50%',
background: 'var(--accent)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontWeight: 600,
fontSize: '14px'
}}
>
{(user?.name || user?.email || '?').trim().slice(0, 1).toUpperCase()}
</div>
<div className="desktop-sidebar__user-text">
<span className="desktop-sidebar__user-name">
{user?.name || user?.email || 'User'}
</span>
{tier ? (
<span className="desktop-sidebar__user-tier">{tier}</span>
) : null}
</div>
</div>
<button
type="button"
className="desktop-sidebar__logout"
onClick={onLogout}
title="Abmelden"
>
{(user?.name || user?.email || '?').trim().slice(0, 1).toUpperCase()}
</div>
<div className="desktop-sidebar__user-text">
<span className="desktop-sidebar__user-name">
{user?.name || user?.email || 'User'}
</span>
{tier ? (
<span className="desktop-sidebar__user-tier">{tier}</span>
) : null}
</div>
<LogOut size={18} />
</button>
</div>
<button
type="button"
className="desktop-sidebar__logout"
onClick={onLogout}
title="Abmelden"
>
<LogOut size={18} />
</button>
</div>
</aside>
)

View File

@ -4,7 +4,13 @@ import { useAuth } from '../context/AuthContext'
function Navigation() {
const location = useLocation()
const navigate = useNavigate()
const { user, logout } = useAuth()
const { user, logout, setActiveClub } = useAuth()
const clubs = user?.clubs || []
const selectClubId =
user?.active_club_id != null && clubs.some((c) => c.id === user.active_club_id)
? user.active_club_id
: clubs[0]?.id
const handleLogout = async () => {
await logout()
@ -84,6 +90,25 @@ function Navigation() {
Profil
</Link>
{(clubs?.length ?? 0) > 1 && (
<label style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', fontSize: '0.8125rem', color: 'var(--text2)' }}>
<span>Verein</span>
<select
className="form-input"
style={{ padding: '0.35rem 0.5rem', minWidth: '9rem', fontSize: '0.8125rem' }}
value={selectClubId ?? ''}
onChange={(e) => {
const v = e.target.value
if (v) setActiveClub(Number(v))
}}
>
{clubs.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</label>
)}
{/* User Menu */}
<div style={{
borderLeft: '1px solid var(--border)',

View File

@ -1,11 +1,40 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
import api from '../utils/api'
import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'
import api, { ACTIVE_CLUB_STORAGE_KEY } from '../utils/api'
const AuthContext = createContext(null)
function syncStoredActiveClub(profile) {
const clubs = profile?.clubs || []
const ids = new Set(clubs.map((c) => String(c.id)))
const eff = profile?.effective_club_id
if (eff != null && eff !== '' && ids.has(String(eff))) {
localStorage.setItem(ACTIVE_CLUB_STORAGE_KEY, String(eff))
return
}
const stored = localStorage.getItem(ACTIVE_CLUB_STORAGE_KEY)
if (stored && ids.has(stored)) return
const ac =
profile?.active_club_id != null && profile.active_club_id !== ''
? String(profile.active_club_id)
: ''
if (ac && ids.has(ac)) {
localStorage.setItem(ACTIVE_CLUB_STORAGE_KEY, ac)
return
}
if (clubs.length >= 1) {
localStorage.setItem(ACTIVE_CLUB_STORAGE_KEY, String(clubs[0].id))
}
}
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const userRef = useRef(null)
useEffect(() => {
userRef.current = user
}, [user])
const checkAuth = useCallback(async () => {
const token = localStorage.getItem('authToken')
@ -16,6 +45,7 @@ export function AuthProvider({ children }) {
try {
const profile = await api.getCurrentProfile()
syncStoredActiveClub(profile)
setUser(profile)
} catch (err) {
console.error('Auth check failed:', err)
@ -29,9 +59,23 @@ export function AuthProvider({ children }) {
checkAuth()
}, [checkAuth])
const setActiveClub = useCallback(async (clubId) => {
const cid = Number(clubId)
const uid = userRef.current?.id
if (!Number.isFinite(cid) || cid < 1 || !uid) return
localStorage.setItem(ACTIVE_CLUB_STORAGE_KEY, String(cid))
setUser((prev) => (prev?.id ? { ...prev, active_club_id: cid } : prev))
try {
await api.updateProfile(uid, { active_club_id: cid })
} catch (e) {
console.error(e)
}
}, [])
/** Fallback, falls ohne checkAuth gesetzt wird (Legacy / Token-Injektion) */
const login = (payload) => {
if (payload?.profile != null) {
syncStoredActiveClub(payload.profile)
setUser(payload.profile)
return
}
@ -52,6 +96,7 @@ export function AuthProvider({ children }) {
const logout = () => {
setUser(null)
localStorage.removeItem('authToken')
localStorage.removeItem(ACTIVE_CLUB_STORAGE_KEY)
}
const value = {
@ -60,7 +105,8 @@ export function AuthProvider({ children }) {
loading,
login,
logout,
checkAuth
checkAuth,
setActiveClub,
}
return (

View File

@ -10,6 +10,12 @@ function AccountSettingsPage() {
const [name, setName] = useState('')
const [savingProfile, setSavingProfile] = useState(false)
const [publicClubsDir, setPublicClubsDir] = useState([])
const [myJoinRequests, setMyJoinRequests] = useState([])
const [joinClubId, setJoinClubId] = useState('')
const [joinMessage, setJoinMessage] = useState('')
const [joinBusy, setJoinBusy] = useState(false)
const [newPw1, setNewPw1] = useState('')
const [newPw2, setNewPw2] = useState('')
const [savingPw, setSavingPw] = useState(false)
@ -22,6 +28,32 @@ function AccountSettingsPage() {
setName(typeof user?.name === 'string' ? user.name : '')
}, [user])
const refreshJoinRequests = () => {
api.getMyClubJoinRequests().then(setMyJoinRequests).catch(() => {})
}
useEffect(() => {
if (!user?.id) return
api.listPublicClubsDirectory().then(setPublicClubsDir).catch(() => {})
refreshJoinRequests()
}, [user?.id])
const memberClubIds = new Set((user?.clubs || []).map((c) => c.id))
const pendingClubIds = new Set(
myJoinRequests.filter((r) => r.status === 'pending').map((r) => r.club_id)
)
const joinClubChoices = publicClubsDir.filter(
(c) => !memberClubIds.has(c.id) && !pendingClubIds.has(c.id)
)
const joinStatusLabel = (s) =>
({
pending: 'ausstehend',
accepted: 'angenommen',
rejected: 'abgelehnt',
withdrawn: 'zurückgezogen',
})[s] || s
/** API: boolean true / Legacy: fehlt oder false → als „nicht verifiziert“ behandeln */
const emailExplicitlyVerified =
user?.email_verified === true ||
@ -214,9 +246,133 @@ function AccountSettingsPage() {
<span style={{ textTransform: 'uppercase', letterSpacing: '0.03em', fontWeight: 600 }}>
{user?.tier || 'free'}
</span>
<strong style={{ color: 'var(--text2)' }}>Vereine</strong>
<span style={{ lineHeight: 1.45 }}>
{user?.clubs?.length ? (
<>
{user.clubs.map((c) => (
<div key={c.id}>
<strong style={{ color: 'var(--text1)' }}>{c.name}</strong>
{': '}
{(c.roles || []).length ? (c.roles || []).join(', ') : '—'}
</div>
))}
</>
) : (
'—'
)}
</span>
</div>
</div>
<div className="card" style={{ marginBottom: '1rem' }}>
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Vereinsbeitritt</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
Beantrage die Mitgliedschaft in einem Verein. Vereinsadministratoren können den Antrag unter
Vereinsverwaltung Mitglieder annehmen oder ablehnen.
</p>
{myJoinRequests.length > 0 && (
<div style={{ marginBottom: '1rem' }}>
<strong style={{ fontSize: '0.9rem' }}>Meine Anträge</strong>
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.25rem', color: 'var(--text2)', fontSize: '0.9rem' }}>
{myJoinRequests.map((r) => (
<li key={r.id} style={{ marginBottom: '0.35rem' }}>
{r.club_name || `Verein #${r.club_id}`} {joinStatusLabel(r.status)}
{r.status === 'pending' ? (
<>
{' '}
<button
type="button"
style={{
marginLeft: '0.35rem',
fontSize: '0.75rem',
padding: '0.15rem 0.45rem',
borderRadius: '6px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
cursor: 'pointer',
}}
onClick={async () => {
if (!confirm('Antrag wirklich zurückziehen?')) return
try {
await api.withdrawClubJoinRequest(r.id)
refreshJoinRequests()
} catch (err) {
showErr(err.message || 'Zurückziehen fehlgeschlagen.')
}
}}
>
zurückziehen
</button>
</>
) : null}
</li>
))}
</ul>
</div>
)}
<form
onSubmit={async (e) => {
e.preventDefault()
if (!joinClubId) {
showErr('Bitte einen Verein auswählen.')
return
}
setJoinBusy(true)
try {
await api.createClubJoinRequest({
club_id: parseInt(joinClubId, 10),
message: (joinMessage || '').trim() || undefined,
})
setJoinMessage('')
setJoinClubId('')
refreshJoinRequests()
await checkAuth()
showOk('Antrag gesendet.')
} catch (err) {
showErr(err.message || 'Antrag fehlgeschlagen.')
} finally {
setJoinBusy(false)
}
}}
>
<label className="form-label" htmlFor="join-club-select">
Verein auswählen
</label>
<select
id="join-club-select"
className="form-input"
value={joinClubId}
onChange={(e) => setJoinClubId(e.target.value)}
>
<option value=""></option>
{joinClubChoices.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name}
{c.abbreviation ? ` (${c.abbreviation})` : ''}
</option>
))}
</select>
<label className="form-label" htmlFor="join-msg" style={{ marginTop: '0.75rem' }}>
Nachricht (optional)
</label>
<textarea
id="join-msg"
className="form-input"
rows={2}
value={joinMessage}
onChange={(e) => setJoinMessage(e.target.value)}
placeholder="z. B. Trainingsgruppe oder Kontakt zum Verein"
/>
<button type="submit" className="btn btn-primary" disabled={joinBusy} style={{ marginTop: '0.85rem' }}>
{joinBusy ? 'Senden…' : 'Beitritt beantragen'}
</button>
</form>
</div>
<div className="card">
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Passwort ändern</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>

View File

@ -0,0 +1,448 @@
import { useEffect, useState } from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
import AdminPageNav from '../components/AdminPageNav'
const CLUB_ROLE_OPTIONS = [
{ code: 'club_admin', label: 'Vereinsadmin' },
{ code: 'trainer', label: 'Trainer' },
{ code: 'division_lead', label: 'Spartenleitung' },
{ code: 'content_editor', label: 'Inhalte bearbeiten' },
]
const TIER_OPTIONS = ['free', 'premium', 'pro', 'enterprise']
const ROLE_LABEL = {
user: 'Nutzer',
trainer: 'Trainer',
admin: 'Portal-Admin',
superadmin: 'Super-Admin',
}
function AdminUsersPage() {
const { user } = useAuth()
const isSuper = user?.role === 'superadmin'
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const portalRoleChoices = isSuper
? ['user', 'trainer', 'admin', 'superadmin']
: ['user', 'trainer', 'admin']
const [users, setUsers] = useState([])
const [clubs, setClubs] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [portalDraft, setPortalDraft] = useState({})
const [assignModal, setAssignModal] = useState(null)
const [assignRoles, setAssignRoles] = useState(['trainer'])
const [clubEditModal, setClubEditModal] = useState(null)
const load = async () => {
setError('')
try {
const [u, c] = await Promise.all([api.listAdminUsers(), api.listClubs()])
setUsers(u)
setClubs(c)
const d = {}
for (const row of u) {
d[row.id] = {
role: (row.role || 'user').toLowerCase(),
tier: row.tier || 'free',
}
}
setPortalDraft(d)
} catch (e) {
setError(e.message || String(e))
} finally {
setLoading(false)
}
}
useEffect(() => {
if (!isPlatformAdmin) return
load()
}, [isPlatformAdmin])
if (!isPlatformAdmin) {
return <Navigate to="/" replace />
}
const savePortal = async (profileId) => {
const dr = portalDraft[profileId]
if (!dr) return
try {
await api.updateProfile(profileId, { role: dr.role, tier: dr.tier })
await load()
} catch (e) {
alert(e.message || String(e))
}
}
const submitAssignClub = async () => {
if (!assignModal) return
const clubId = assignModal.clubId
const profileId = assignModal.profileId
if (!clubId || !assignRoles.length) {
alert('Verein und mindestens eine Rolle wählen.')
return
}
try {
await api.addClubMember(clubId, { profile_id: profileId, roles: assignRoles })
setAssignModal(null)
setAssignRoles(['trainer'])
await load()
} catch (e) {
alert(e.message || String(e))
}
}
const saveClubMembership = async () => {
if (!clubEditModal) return
const { clubId, profileId, roles, status } = clubEditModal
try {
await api.updateClubMember(clubId, profileId, { roles, status })
setClubEditModal(null)
await load()
} catch (e) {
alert(e.message || String(e))
}
}
const removeClubMembership = async () => {
if (!clubEditModal) return
if (!confirm('Mitgliedschaft in diesem Verein wirklich entfernen?')) return
try {
await api.removeClubMember(clubEditModal.clubId, clubEditModal.profileId)
setClubEditModal(null)
await load()
} catch (e) {
alert(e.message || String(e))
}
}
return (
<div className="app-page">
<AdminPageNav />
<h1 style={{ marginTop: 0 }}>Portal-Nutzer &amp; Vereine</h1>
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '1.25rem' }}>
Alle Konten mit Vereinszuordnungen. Hier kannst du die <strong>Portal-Rolle</strong> (Zugriff auf
Admin-Funktionen) und das <strong>Tier</strong> setzen sowie Nutzer explizit einem Verein mit Rollen
zuordnen.
</p>
{loading ? (
<p style={{ color: 'var(--text2)' }}>Laden</p>
) : error ? (
<div className="card" style={{ borderColor: 'var(--danger)', color: 'var(--danger)' }}>
{error}
</div>
) : (
<div style={{ display: 'grid', gap: '1rem' }}>
{users.map((row) => {
const tierValue = portalDraft[row.id]?.tier ?? row.tier ?? 'free'
const tierChoices = [...TIER_OPTIONS]
if (tierValue && !tierChoices.includes(tierValue)) tierChoices.unshift(tierValue)
return (
<div key={row.id} className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: '0.75rem' }}>
<div>
<strong style={{ fontSize: '1.05rem' }}>
{row.name || '—'} <span style={{ color: 'var(--text2)', fontWeight: 400 }}>#{row.id}</span>
</strong>
<div style={{ fontSize: '0.875rem', color: 'var(--text2)' }}>{row.email || '—'}</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginTop: '0.35rem' }}>
Verifiziert: {row.email_verified ? 'ja' : 'nein'}
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'flex-end' }}>
<div>
<label className="form-label" style={{ fontSize: '0.75rem' }}>
Portal-Rolle
</label>
<select
className="form-input"
style={{ minWidth: '140px' }}
value={(portalDraft[row.id]?.role || row.role || 'user').toLowerCase()}
onChange={(e) =>
setPortalDraft((prev) => ({
...prev,
[row.id]: { ...prev[row.id], role: e.target.value, tier: prev[row.id]?.tier ?? row.tier },
}))
}
>
{portalRoleChoices.map((r) => (
<option key={r} value={r}>
{ROLE_LABEL[r] || r}
</option>
))}
</select>
</div>
<div>
<label className="form-label" style={{ fontSize: '0.75rem' }}>
Tier
</label>
<select
className="form-input"
style={{ minWidth: '120px' }}
value={tierValue}
onChange={(e) =>
setPortalDraft((prev) => ({
...prev,
[row.id]: {
...prev[row.id],
tier: e.target.value,
role: prev[row.id]?.role ?? row.role,
},
}))
}
>
{tierChoices.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<button type="button" className="btn btn-secondary" onClick={() => savePortal(row.id)}>
Portal speichern
</button>
<button
type="button"
className="btn btn-primary"
disabled={!clubs.length}
title={!clubs.length ? 'Zuerst einen Verein anlegen' : undefined}
onClick={() => {
if (!clubs.length) return
setAssignRoles(['trainer'])
setAssignModal({
profileId: row.id,
profileLabel: row.name || row.email || `#${row.id}`,
clubId: clubs[0]?.id ?? '',
})
}}
>
Verein zuweisen
</button>
</div>
</div>
<div style={{ marginTop: '1rem', paddingTop: '0.75rem', borderTop: '1px solid var(--border)' }}>
<strong style={{ fontSize: '0.85rem' }}>Vereinsmitgliedschaften</strong>
{!row.clubs?.length ? (
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', margin: '0.35rem 0 0' }}>
Keine Zuordnung.
</p>
) : (
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem', fontSize: '0.9rem' }}>
{row.clubs.map((c) => (
<li key={c.id} style={{ marginBottom: '0.35rem' }}>
<strong>{c.name}</strong>
{c.abbreviation ? ` (${c.abbreviation})` : ''} {' '}
{(c.roles || []).join(', ') || '—'}
{c.membership_status === 'inactive' ? (
<span style={{ color: 'var(--text3)', fontSize: '0.8rem' }}> (inaktiv)</span>
) : null}{' '}
<button
type="button"
style={{
marginLeft: '0.35rem',
fontSize: '0.75rem',
padding: '0.12rem 0.45rem',
borderRadius: '6px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
cursor: 'pointer',
}}
onClick={() =>
setClubEditModal({
clubId: c.id,
clubName: c.name,
profileId: row.id,
profileLabel: row.name || row.email,
roles: [...(c.roles || [])],
status: (c.membership_status || 'active').toLowerCase(),
})
}
>
bearbeiten
</button>
</li>
))}
</ul>
)}
</div>
</div>
)
})}
</div>
)}
{assignModal && (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1200,
padding: '1rem',
}}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '1.5rem',
maxWidth: '440px',
width: '100%',
}}
>
<h2 style={{ marginTop: 0 }}>Verein zuweisen</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{assignModal.profileLabel}</p>
<div className="form-row">
<label className="form-label">Verein</label>
<select
className="form-input"
value={assignModal.clubId === '' ? '' : String(assignModal.clubId)}
onChange={(e) =>
setAssignModal((prev) =>
prev ? { ...prev, clubId: e.target.value ? parseInt(e.target.value, 10) : '' } : prev
)
}
>
{clubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name}
</option>
))}
</select>
</div>
<div className="form-row">
<span className="form-label">Rollen im Verein</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
{CLUB_ROLE_OPTIONS.map((opt) => (
<label key={opt.code} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<input
type="checkbox"
checked={assignRoles.includes(opt.code)}
onChange={() => {
setAssignRoles((prev) => {
const s = new Set(prev)
if (s.has(opt.code)) s.delete(opt.code)
else s.add(opt.code)
const out = Array.from(s)
return out.length ? out : ['trainer']
})
}}
/>
{opt.label}
</label>
))}
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button type="button" className="btn btn-primary" style={{ flex: 1 }} onClick={submitAssignClub}>
Zuweisen
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => setAssignModal(null)}
>
Abbrechen
</button>
</div>
</div>
</div>
)}
{clubEditModal && (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1200,
padding: '1rem',
}}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '1.5rem',
maxWidth: '440px',
width: '100%',
}}
>
<h2 style={{ marginTop: 0 }}>Vereinsmitgliedschaft</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>
{clubEditModal.profileLabel} {clubEditModal.clubName}
</p>
<div className="form-row">
<label className="form-label">Status</label>
<select
className="form-input"
value={clubEditModal.status}
onChange={(e) =>
setClubEditModal((prev) => (prev ? { ...prev, status: e.target.value } : prev))
}
>
<option value="active">aktiv</option>
<option value="inactive">inaktiv</option>
</select>
</div>
<div className="form-row">
<span className="form-label">Rollen</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
{CLUB_ROLE_OPTIONS.map((opt) => (
<label key={opt.code} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<input
type="checkbox"
checked={clubEditModal.roles.includes(opt.code)}
onChange={() => {
setClubEditModal((prev) => {
if (!prev) return prev
const s = new Set(prev.roles)
if (s.has(opt.code)) s.delete(opt.code)
else s.add(opt.code)
let roles = Array.from(s)
if (!roles.length) roles = ['trainer']
return { ...prev, roles }
})
}}
/>
{opt.label}
</label>
))}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '1rem' }}>
<button type="button" className="btn btn-primary" onClick={saveClubMembership}>
Speichern
</button>
<button
type="button"
className="btn"
style={{ background: 'var(--danger)', color: '#fff', border: 'none' }}
onClick={removeClubMembership}
>
Aus Verein entfernen
</button>
<button type="button" className="btn btn-secondary" onClick={() => setClubEditModal(null)}>
Schließen
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default AdminUsersPage

View File

@ -2,6 +2,13 @@ import React, { useState, useEffect } from 'react'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
const CLUB_ROLE_OPTIONS = [
{ code: 'club_admin', label: 'Vereinsadmin' },
{ code: 'trainer', label: 'Trainer' },
{ code: 'division_lead', label: 'Spartenleitung' },
{ code: 'content_editor', label: 'Inhalte bearbeiten' },
]
function ClubsPage() {
const { user } = useAuth()
const [activeTab, setActiveTab] = useState('clubs')
@ -13,13 +20,42 @@ function ClubsPage() {
const [editing, setEditing] = useState(null)
const [modalType, setModalType] = useState('club')
const [membersAdminClubId, setMembersAdminClubId] = useState(null)
const [clubMembersAdmin, setClubMembersAdmin] = useState([])
const [joinRequestsAdmin, setJoinRequestsAdmin] = useState([])
const [membersAdminLoading, setMembersAdminLoading] = useState(false)
const [groupMemberDirectory, setGroupMemberDirectory] = useState([])
const [showAddMemberModal, setShowAddMemberModal] = useState(false)
const [profilesAdminList, setProfilesAdminList] = useState([])
const [addMemberForm, setAddMemberForm] = useState({ profile_id: '', roles: ['trainer'] })
const [editMemberModal, setEditMemberModal] = useState(null)
const [acceptJoinModal, setAcceptJoinModal] = useState(null)
// Form state
const [formData, setFormData] = useState({})
const isAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const isTrainer = user?.role === 'trainer' || isAdmin
const canCreateClub = ['admin', 'superadmin', 'trainer', 'user'].includes(user?.role)
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const isSuperAdmin = user?.role === 'superadmin'
const clubAdminClubIds = new Set(
(user?.clubs || [])
.filter((c) => (c.roles || []).includes('club_admin'))
.map((c) => c.id)
)
const canManageClub = (clubId) => isPlatformAdmin || clubAdminClubIds.has(clubId)
const canCreateClub = isPlatformAdmin
const canManageOrgSomewhere = isPlatformAdmin || clubAdminClubIds.size > 0
const canCreateTrainingGroup =
isPlatformAdmin || (Array.isArray(user?.clubs) && user.clubs.length > 0)
const canEditGroup = (g) =>
isPlatformAdmin ||
clubAdminClubIds.has(g.club_id) ||
g.trainer_id === user?.id ||
(Array.isArray(g.co_trainer_ids) && g.co_trainer_ids.includes(user?.id))
const canDeleteGroup = (g) => isPlatformAdmin || clubAdminClubIds.has(g.club_id)
useEffect(() => {
loadData()
}, [])
@ -42,12 +78,82 @@ function ClubsPage() {
}
}
useEffect(() => {
if (!clubs.length) {
setMembersAdminClubId(null)
return
}
const mcl = clubs.filter((c) => isPlatformAdmin || clubAdminClubIds.has(c.id))
if (!mcl.length) {
setMembersAdminClubId(null)
return
}
setMembersAdminClubId((prev) =>
prev != null && mcl.some((x) => x.id === prev) ? prev : mcl[0].id
)
}, [clubs, isPlatformAdmin, user?.clubs])
useEffect(() => {
if (activeTab !== 'members' || !membersAdminClubId || !canManageClub(membersAdminClubId)) return
let cancelled = false
setMembersAdminLoading(true)
Promise.all([
api.listClubMembers(membersAdminClubId, { includeInactive: true }),
api.listClubJoinRequests(membersAdminClubId),
])
.then(([m, j]) => {
if (!cancelled) {
setClubMembersAdmin(m)
setJoinRequestsAdmin(j)
}
})
.catch((err) => {
if (!cancelled) alert('Mitglieder/Anträge: ' + err.message)
})
.finally(() => {
if (!cancelled) setMembersAdminLoading(false)
})
return () => {
cancelled = true
}
}, [activeTab, membersAdminClubId])
useEffect(() => {
if (!showModal || modalType !== 'group' || !formData.club_id) {
setGroupMemberDirectory([])
return
}
let cancelled = false
api
.clubMembersDirectory(formData.club_id)
.then((rows) => {
if (!cancelled) setGroupMemberDirectory(rows)
})
.catch(() => {
if (!cancelled) setGroupMemberDirectory([])
})
return () => {
cancelled = true
}
}, [showModal, modalType, formData.club_id])
useEffect(() => {
if (!showAddMemberModal || !isPlatformAdmin) return
api.listProfiles().then(setProfilesAdminList).catch(() => setProfilesAdminList([]))
}, [showAddMemberModal, isPlatformAdmin])
const handleCreate = (type) => {
setEditing(null)
setModalType(type)
if (type === 'club') {
setFormData({ name: '', abbreviation: '', description: '', status: 'active' })
setFormData({
name: '',
abbreviation: '',
description: '',
status: 'active',
primary_admin_profile_id: user?.id ?? '',
})
} else if (type === 'division') {
setFormData({ club_id: '', name: '', focus_area: '' })
} else if (type === 'group') {
@ -71,10 +177,34 @@ function ClubsPage() {
setShowModal(true)
}
const manageableClubs = clubs.filter((c) => canManageClub(c.id))
const reloadMembersAdmin = async () => {
if (!membersAdminClubId || !canManageClub(membersAdminClubId)) return
try {
const [m, j] = await Promise.all([
api.listClubMembers(membersAdminClubId, { includeInactive: true }),
api.listClubJoinRequests(membersAdminClubId),
])
setClubMembersAdmin(m)
setJoinRequestsAdmin(j)
} catch (err) {
console.error(err)
}
}
const handleEdit = (item, type) => {
setEditing(item)
setModalType(type)
setFormData({ ...item })
if (type === 'group') {
const co = item.co_trainer_ids
setFormData({
...item,
co_trainer_ids: Array.isArray(co) ? co : [],
})
} else {
setFormData({ ...item })
}
setShowModal(true)
}
@ -102,11 +232,19 @@ function ClubsPage() {
e.preventDefault()
try {
if (modalType === 'club') {
if (modalType === 'club') {
if (editing) {
await api.updateClub(editing.id, formData)
} else {
await api.createClub(formData)
const payload = {
...formData,
primary_admin_profile_id: Number(formData.primary_admin_profile_id),
}
if (!payload.primary_admin_profile_id) {
alert('Hauptverwalter (Profil-ID) ist Pflicht.')
return
}
await api.createClub(payload)
}
} else if (modalType === 'division') {
if (editing) {
@ -115,10 +253,24 @@ function ClubsPage() {
await api.createDivision(formData)
}
} else if (modalType === 'group') {
const trainerRaw = formData.trainer_id
const trainer_id =
trainerRaw === '' || trainerRaw === undefined || trainerRaw === null
? null
: Number(trainerRaw)
const coRaw = formData.co_trainer_ids
const co_trainer_ids = Array.isArray(coRaw)
? coRaw.map((x) => Number(x)).filter((n) => Number.isFinite(n))
: []
const payload = {
...formData,
trainer_id: Number.isFinite(trainer_id) ? trainer_id : null,
co_trainer_ids,
}
if (editing) {
await api.updateTrainingGroup(editing.id, formData)
await api.updateTrainingGroup(editing.id, payload)
} else {
await api.createTrainingGroup(formData)
await api.createTrainingGroup(payload)
}
}
@ -157,7 +309,10 @@ function ClubsPage() {
marginBottom: '1.5rem',
borderBottom: '2px solid var(--border)'
}}>
{['clubs', 'divisions', 'groups'].map(tab => (
{(canManageOrgSomewhere
? ['clubs', 'divisions', 'groups', 'members']
: ['clubs', 'divisions', 'groups']
).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
@ -174,6 +329,7 @@ function ClubsPage() {
{tab === 'clubs' && 'Vereine'}
{tab === 'divisions' && 'Sparten'}
{tab === 'groups' && 'Trainingsgruppen'}
{tab === 'members' && 'Mitglieder'}
</button>
))}
</div>
@ -235,7 +391,7 @@ function ClubsPage() {
{club.status}
</span>
</div>
{isAdmin && (
{canManageClub(club.id) && (
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
className="btn btn-secondary"
@ -243,6 +399,7 @@ function ClubsPage() {
>
Bearbeiten
</button>
{isSuperAdmin && (
<button
className="btn"
style={{
@ -254,6 +411,7 @@ function ClubsPage() {
>
Löschen
</button>
)}
</div>
)}
</div>
@ -269,7 +427,7 @@ function ClubsPage() {
<>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
<h2>Sparten</h2>
{isAdmin && (
{canManageOrgSomewhere && (
<button className="btn btn-primary" onClick={() => handleCreate('division')}>
+ Neue Sparte
</button>
@ -306,7 +464,7 @@ function ClubsPage() {
</span>
)}
</div>
{isAdmin && (
{canManageClub(division.club_id) && (
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
className="btn btn-secondary"
@ -340,7 +498,7 @@ function ClubsPage() {
<>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
<h2>Trainingsgruppen</h2>
{canCreateClub && (
{canCreateTrainingGroup && (
<button className="btn btn-primary" onClick={() => handleCreate('group')}>
+ Neue Gruppe
</button>
@ -377,7 +535,7 @@ function ClubsPage() {
{group.age_group && <div>👶 {group.age_group}</div>}
</div>
{(isAdmin || group.trainer_id === user?.id) && (
{canEditGroup(group) && (
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto' }}>
<button
className="btn btn-secondary"
@ -386,7 +544,7 @@ function ClubsPage() {
>
Bearbeiten
</button>
{isAdmin && (
{canDeleteGroup(group) && (
<button
className="btn"
style={{
@ -408,6 +566,156 @@ function ClubsPage() {
</>
)}
{activeTab === 'members' && canManageOrgSomewhere && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem', flexWrap: 'wrap', gap: '0.75rem' }}>
<h2>Mitglieder &amp; Beitrittsanträge</h2>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<label style={{ color: 'var(--text2)', fontSize: '0.875rem' }}>Verein</label>
<select
className="form-input"
style={{ minWidth: '200px' }}
value={membersAdminClubId ?? ''}
onChange={(e) =>
setMembersAdminClubId(e.target.value ? parseInt(e.target.value, 10) : null)
}
>
{manageableClubs.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<button
type="button"
className="btn btn-primary"
onClick={() => {
setAddMemberForm({ profile_id: '', roles: ['trainer'] })
setShowAddMemberModal(true)
}}
>
Mitglied hinzufügen
</button>
</div>
</div>
{manageableClubs.length === 0 ? (
<div className="card">
<p style={{ color: 'var(--text2)' }}>
Keine Vereine, für die du Mitglieder verwalten darfst.
</p>
</div>
) : membersAdminLoading ? (
<p style={{ color: 'var(--text2)' }}>Laden</p>
) : (
<>
{joinRequestsAdmin.length > 0 && (
<div className="card" style={{ marginBottom: '1rem' }}>
<h3 style={{ marginTop: 0 }}>Offene Beitrittsanträge</h3>
<div style={{ display: 'grid', gap: '0.75rem' }}>
{joinRequestsAdmin.map((req) => (
<div
key={req.id}
style={{
padding: '0.75rem',
borderRadius: '8px',
border: '1px solid var(--border)',
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: '0.5rem',
}}
>
<div>
<strong>{req.applicant_name || req.applicant_email || 'Nutzer'}</strong>
<div style={{ fontSize: '0.85rem', color: 'var(--text2)' }}>
{req.applicant_email} · Profil #{req.profile_id}
</div>
{req.message ? (
<div style={{ marginTop: '0.35rem', fontSize: '0.875rem' }}>{req.message}</div>
) : null}
</div>
<div style={{ display: 'flex', gap: '0.35rem', flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-primary"
onClick={() =>
setAcceptJoinModal({
id: req.id,
label: req.applicant_name || req.applicant_email,
roles: ['trainer'],
})
}
>
Annehmen
</button>
<button
type="button"
className="btn btn-secondary"
onClick={async () => {
if (!confirm('Antrag ablehnen?')) return
try {
await api.rejectClubJoinRequest(membersAdminClubId, req.id)
await reloadMembersAdmin()
} catch (err) {
alert(err.message || String(err))
}
}}
>
Ablehnen
</button>
</div>
</div>
))}
</div>
</div>
)}
<div className="card">
<h3 style={{ marginTop: 0 }}>Mitglieder</h3>
{clubMembersAdmin.length === 0 ? (
<p style={{ color: 'var(--text2)' }}>Noch keine Mitglieder erfasst.</p>
) : (
<div style={{ display: 'grid', gap: '0.65rem' }}>
{clubMembersAdmin.map((m) => (
<div
key={m.membership_id}
style={{
padding: '0.65rem',
borderRadius: '8px',
background: 'var(--surface2)',
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: '0.5rem',
}}
>
<div>
<strong>{m.name || m.email}</strong>
<div style={{ fontSize: '0.8rem', color: 'var(--text2)' }}>
{m.email} · #{m.profile_id} · {m.status}
</div>
<div style={{ fontSize: '0.8rem', marginTop: '0.25rem' }}>
Rollen: {(m.roles || []).join(', ') || '—'}
</div>
</div>
<button
type="button"
className="btn btn-secondary"
onClick={() => setEditMemberModal(m)}
>
Bearbeiten
</button>
</div>
))}
</div>
)}
</div>
</>
)}
</>
)}
{/* Modal */}
{showModal && (
<div style={{
@ -485,6 +793,28 @@ function ClubsPage() {
<option value="inactive">Inaktiv</option>
</select>
</div>
{!editing && canCreateClub && (
<div className="form-row">
<label className="form-label">Hauptverwalter (Profil-ID) *</label>
<input
type="number"
min={1}
className="form-input"
value={formData.primary_admin_profile_id ?? ''}
onChange={(e) =>
updateFormField(
'primary_admin_profile_id',
e.target.value === '' ? '' : parseInt(e.target.value, 10)
)
}
required
/>
<p style={{ fontSize: '0.8rem', color: 'var(--text3)', marginTop: '0.35rem' }}>
Nur Plattform-Administratoren legen Vereine an. Standard ist deine eigene Profil-ID.
</p>
</div>
)}
</>
)}
@ -664,6 +994,50 @@ function ClubsPage() {
/>
</div>
<div className="form-row">
<label className="form-label">Haupttrainer</label>
<select
className="form-input"
value={formData.trainer_id != null ? String(formData.trainer_id) : ''}
onChange={(e) =>
updateFormField(
'trainer_id',
e.target.value === '' ? '' : parseInt(e.target.value, 10)
)
}
>
<option value=""></option>
{groupMemberDirectory.map((p) => (
<option key={p.id} value={p.id}>
{(p.name || p.email || '').trim()} (#{p.id})
</option>
))}
</select>
<p style={{ fontSize: '0.75rem', color: 'var(--text3)', marginTop: '0.35rem' }}>
Liste = aktive Vereinsmitglieder. Nach Zuweisungen ggf. Seite neu laden.
</p>
</div>
<div className="form-row">
<label className="form-label">Co-Trainer (Mehrfachauswahl)</label>
<select
multiple
className="form-input"
size={Math.min(8, Math.max(4, groupMemberDirectory.length || 4))}
value={(formData.co_trainer_ids || []).map(String)}
onChange={(e) => {
const opts = [...e.target.selectedOptions].map((o) => parseInt(o.value, 10))
updateFormField('co_trainer_ids', opts)
}}
>
{groupMemberDirectory.map((p) => (
<option key={p.id} value={p.id}>
{(p.name || p.email || '').trim()} (#{p.id})
</option>
))}
</select>
</div>
<div className="form-row">
<label className="form-label">Status</label>
<select
@ -695,6 +1069,340 @@ function ClubsPage() {
</div>
</div>
)}
{showAddMemberModal && membersAdminClubId && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1100,
padding: '1rem',
}}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '1.5rem',
maxWidth: '480px',
width: '100%',
}}
>
<h2 style={{ marginTop: 0 }}>Mitglied hinzufügen</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.875rem' }}>
{isPlatformAdmin
? 'Nutzer aus der Liste wählen oder Profil-ID eingeben.'
: 'Profil-ID des Nutzers (z. B. aus der Nutzerverwaltung). Offene Beitrittsanträge kannst du oben direkt annehmen.'}
</p>
{isPlatformAdmin && profilesAdminList.length > 0 ? (
<div className="form-row">
<label className="form-label">Profil</label>
<select
className="form-input"
value={addMemberForm.profile_id === '' ? '' : String(addMemberForm.profile_id)}
onChange={(e) =>
setAddMemberForm((prev) => ({ ...prev, profile_id: e.target.value }))
}
>
<option value="">Bitte wählen</option>
{profilesAdminList.map((p) => (
<option key={p.id} value={p.id}>
#{p.id} {p.email || '—'} ({p.name || 'ohne Name'})
</option>
))}
</select>
</div>
) : (
<div className="form-row">
<label className="form-label">Profil-ID</label>
<input
type="number"
min={1}
className="form-input"
value={addMemberForm.profile_id}
onChange={(e) =>
setAddMemberForm((prev) => ({
...prev,
profile_id: e.target.value === '' ? '' : parseInt(e.target.value, 10),
}))
}
/>
</div>
)}
<div className="form-row">
<span className="form-label">Rollen</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
{CLUB_ROLE_OPTIONS.map((opt) => (
<label key={opt.code} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<input
type="checkbox"
checked={addMemberForm.roles.includes(opt.code)}
onChange={() => {
setAddMemberForm((prev) => {
const set = new Set(prev.roles)
if (set.has(opt.code)) set.delete(opt.code)
else set.add(opt.code)
return { ...prev, roles: Array.from(set) }
})
}}
/>
{opt.label}
</label>
))}
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button
type="button"
className="btn btn-primary"
style={{ flex: 1 }}
onClick={async () => {
const raw = addMemberForm.profile_id
const profile_id = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
if (!Number.isFinite(profile_id) || profile_id < 1) {
alert('Gültige Profil-ID wählen.')
return
}
if (!addMemberForm.roles.length) {
alert('Mindestens eine Rolle.')
return
}
try {
await api.addClubMember(membersAdminClubId, {
profile_id,
roles: addMemberForm.roles,
})
setShowAddMemberModal(false)
await reloadMembersAdmin()
await loadData()
} catch (err) {
alert(err.message || String(err))
}
}}
>
Hinzufügen
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => setShowAddMemberModal(false)}
>
Abbrechen
</button>
</div>
</div>
</div>
)}
{acceptJoinModal && membersAdminClubId && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1100,
padding: '1rem',
}}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '1.5rem',
maxWidth: '480px',
width: '100%',
}}
>
<h2 style={{ marginTop: 0 }}>Antrag annehmen</h2>
<p style={{ color: 'var(--text2)' }}>{acceptJoinModal.label}</p>
<div className="form-row">
<span className="form-label">Rollen bei Aufnahme</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
{CLUB_ROLE_OPTIONS.map((opt) => (
<label key={opt.code} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<input
type="checkbox"
checked={acceptJoinModal.roles.includes(opt.code)}
onChange={() => {
setAcceptJoinModal((prev) => {
if (!prev) return prev
const set = new Set(prev.roles)
if (set.has(opt.code)) set.delete(opt.code)
else set.add(opt.code)
const roles = Array.from(set)
return { ...prev, roles: roles.length ? roles : ['trainer'] }
})
}}
/>
{opt.label}
</label>
))}
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button
type="button"
className="btn btn-primary"
style={{ flex: 1 }}
onClick={async () => {
try {
await api.acceptClubJoinRequest(
membersAdminClubId,
acceptJoinModal.id,
acceptJoinModal.roles.length ? acceptJoinModal.roles : ['trainer']
)
setAcceptJoinModal(null)
await reloadMembersAdmin()
await loadData()
} catch (err) {
alert(err.message || String(err))
}
}}
>
Aufnehmen
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => setAcceptJoinModal(null)}
>
Abbrechen
</button>
</div>
</div>
</div>
)}
{editMemberModal && membersAdminClubId && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1100,
padding: '1rem',
}}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '1.5rem',
maxWidth: '480px',
width: '100%',
}}
>
<h2 style={{ marginTop: 0 }}>Mitglied bearbeiten</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>
{editMemberModal.name || editMemberModal.email} (#{editMemberModal.profile_id})
</p>
<div className="form-row">
<label className="form-label">Status</label>
<select
className="form-input"
value={editMemberModal.status || 'active'}
onChange={(e) =>
setEditMemberModal((prev) => (prev ? { ...prev, status: e.target.value } : prev))
}
>
<option value="active">aktiv</option>
<option value="inactive">inaktiv</option>
</select>
</div>
<div className="form-row">
<span className="form-label">Rollen</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
{CLUB_ROLE_OPTIONS.map((opt) => (
<label key={opt.code} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<input
type="checkbox"
checked={(editMemberModal.roles || []).includes(opt.code)}
onChange={() => {
setEditMemberModal((prev) => {
if (!prev) return prev
const set = new Set(prev.roles || [])
if (set.has(opt.code)) set.delete(opt.code)
else set.add(opt.code)
let roles = Array.from(set)
if (!roles.length) roles = ['trainer']
return { ...prev, roles }
})
}}
/>
{opt.label}
</label>
))}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '1rem' }}>
<button
type="button"
className="btn btn-primary"
onClick={async () => {
try {
await api.updateClubMember(membersAdminClubId, editMemberModal.profile_id, {
roles: editMemberModal.roles,
status: editMemberModal.status,
})
setEditMemberModal(null)
await reloadMembersAdmin()
await loadData()
} catch (err) {
alert(err.message || String(err))
}
}}
>
Speichern
</button>
<button
type="button"
className="btn"
style={{ background: 'var(--danger)', color: '#fff', border: 'none' }}
onClick={async () => {
if (
!confirm(
'Mitgliedschaft wirklich entfernen? (Nutzer verliert alle Rollen in diesem Verein.)'
)
)
return
try {
await api.removeClubMember(membersAdminClubId, editMemberModal.profile_id)
setEditMemberModal(null)
await reloadMembersAdmin()
await loadData()
} catch (err) {
alert(err.message || String(err))
}
}}
>
Aus Verein entfernen
</button>
<button type="button" className="btn btn-secondary" onClick={() => setEditMemberModal(null)}>
Schließen
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -1,11 +1,13 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import MultiSelectCombo from '../components/MultiSelectCombo'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
const PAGE_SIZE = 100
const BULK_MAX_IDS = 500
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
const INITIAL_FILTERS = {
@ -26,6 +28,9 @@ function levelOptionShort(levelStr) {
}
function ExercisesListPage() {
const { user } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const [exercises, setExercises] = useState([])
const [catalogs, setCatalogs] = useState({
focusAreas: [],
@ -47,6 +52,14 @@ function ExercisesListPage() {
const [filterModalOpen, setFilterModalOpen] = useState(false)
const [pageTab, setPageTab] = useState('list')
const [selectedIds, setSelectedIds] = useState(() => new Set())
const [bulkModalOpen, setBulkModalOpen] = useState(false)
const [bulkVisibility, setBulkVisibility] = useState('')
const [bulkStatus, setBulkStatus] = useState('')
const [bulkClubSelect, setBulkClubSelect] = useState('')
const [bulkClubManual, setBulkClubManual] = useState('')
const [bulkSubmitting, setBulkSubmitting] = useState(false)
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
return () => clearTimeout(t)
@ -254,6 +267,67 @@ function ExercisesListPage() {
return q
}, [filters, debouncedSearch, debouncedAiSearch])
useEffect(() => {
setSelectedIds(new Set())
}, [queryBase])
const clubNameById = useMemo(() => {
const m = {}
for (const c of user?.clubs || []) {
if (c?.id != null) m[Number(c.id)] = c.name || `#${c.id}`
}
return m
}, [user?.clubs])
const effectiveClubId =
user?.effective_club_id != null && user.effective_club_id !== ''
? Number(user.effective_club_id)
: user?.active_club_id != null && user.active_club_id !== ''
? Number(user.active_club_id)
: null
const toggleSelect = useCallback((id) => {
setSelectedIds((prev) => {
const n = new Set(prev)
const nid = Number(id)
if (Number.isNaN(nid)) return prev
if (n.has(nid)) n.delete(nid)
else n.add(nid)
return n
})
}, [])
const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
const toggleSelectAllPage = useCallback(() => {
setSelectedIds((prev) => {
const n = new Set(prev)
const allSel =
exercises.length > 0 && exercises.every((e) => n.has(Number(e.id)))
if (allSel) {
exercises.forEach((e) => n.delete(Number(e.id)))
} else {
exercises.forEach((e) => n.add(Number(e.id)))
}
return n
})
}, [exercises])
const allOnPageSelected = useMemo(
() => exercises.length > 0 && exercises.every((e) => selectedIds.has(Number(e.id))),
[exercises, selectedIds]
)
const bulkVisibilityOptions = useMemo(() => {
const base = [
{ id: '', label: '— nicht ändern —' },
{ id: 'private', label: 'Privat' },
{ id: 'club', label: 'Verein' },
]
if (isPlatformAdmin) base.push({ id: 'official', label: 'Offiziell (global)' })
return base
}, [isPlatformAdmin])
useEffect(() => {
let cancelled = false
;(async () => {
@ -342,6 +416,85 @@ function ExercisesListPage() {
const resetAllFilters = useCallback(() => setFilters({ ...INITIAL_FILTERS }), [])
const openBulkModal = () => {
setBulkVisibility('')
setBulkStatus('')
setBulkClubSelect('')
setBulkClubManual('')
setBulkModalOpen(true)
}
const handleBulkSubmit = async () => {
if (!bulkVisibility && !bulkStatus) {
alert('Bitte mindestens Sichtbarkeit oder Status wählen (nicht „nicht ändern“ bei beiden).')
return
}
const ids = Array.from(selectedIds).filter((x) => Number.isFinite(x) && x > 0)
if (ids.length === 0) {
alert('Keine Übungen ausgewählt.')
return
}
if (ids.length > BULK_MAX_IDS) {
alert(`Maximal ${BULK_MAX_IDS} Übungen pro Vorgang. Bitte Auswahl oder mehrere Durchläufe verwenden.`)
return
}
const payload = { exercise_ids: ids }
if (bulkVisibility) payload.visibility = bulkVisibility
if (bulkStatus) payload.status = bulkStatus
if (bulkVisibility === 'club') {
const manual = String(bulkClubManual || '').trim()
if (manual && /^\d+$/.test(manual)) payload.club_id = Number(manual)
else if (bulkClubSelect && /^\d+$/.test(String(bulkClubSelect))) {
payload.club_id = Number(bulkClubSelect)
}
}
setBulkSubmitting(true)
try {
const res = await api.bulkPatchExercisesMetadata(payload)
const updatedSet = new Set((res.updated || []).map((x) => Number(x)))
let resolvedClubId = null
if (bulkVisibility === 'club') {
if (payload.club_id != null) resolvedClubId = payload.club_id
else if (effectiveClubId != null && !Number.isNaN(effectiveClubId)) resolvedClubId = effectiveClubId
}
const clubLabel =
resolvedClubId != null ? clubNameById[resolvedClubId] || `Verein #${resolvedClubId}` : null
setExercises((prev) =>
prev.map((e) => {
if (!updatedSet.has(Number(e.id))) return e
const next = { ...e }
if (bulkVisibility) {
next.visibility = bulkVisibility
next.club_id = bulkVisibility === 'club' ? resolvedClubId : null
next.club_name = bulkVisibility === 'club' ? clubLabel : null
}
if (bulkStatus) next.status = bulkStatus
return next
})
)
let msg = `${res.updated_count ?? updatedSet.size} Übung(en) aktualisiert.`
if (res.failed_count) msg += `\n${res.failed_count} nicht geändert (siehe Details).`
if (Array.isArray(res.failed) && res.failed.length) {
msg +=
'\n\n' +
res.failed
.slice(0, 12)
.map((f) => `#${f.id}: ${f.detail}`)
.join('\n')
if (res.failed.length > 12) msg += '\n…'
}
alert(msg)
setBulkModalOpen(false)
clearSelection()
} catch (err) {
alert('Massenänderung fehlgeschlagen: ' + (err.message || String(err)))
} finally {
setBulkSubmitting(false)
}
}
if (!catalogsReady && pageTab === 'list') {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
@ -470,9 +623,43 @@ function ExercisesListPage() {
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '10px', marginBottom: 0 }}>
Trefferliste aktualisiert sich kurz nach Eingabe. Titel der aktuellen Liste erscheinen als Vorschläge (Pfeil im
Feld). Fachliche Filter über Filter zwischen Feldern UND, Auswahl mehrerer Werte je Feld mit ODER.
{exercises.length > 0 ? (
<>
{' '}
<button type="button" className="btn btn-secondary" style={{ marginLeft: '6px' }} onClick={toggleSelectAllPage}>
{allOnPageSelected ? 'Auswahl auf dieser Seite aufheben' : 'Alle auf dieser Seite auswählen'}
</button>
</>
) : null}
</p>
</div>
{selectedIds.size > 0 ? (
<div
className="card"
style={{
marginBottom: '12px',
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: '10px',
}}
>
<strong>{selectedIds.size} ausgewählt</strong>
<button type="button" className="btn btn-secondary" onClick={clearSelection}>
Auswahl aufheben
</button>
<button type="button" className="btn btn-primary" onClick={openBulkModal}>
Sichtbarkeit / Status ändern
</button>
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
Bis zu {BULK_MAX_IDS} pro Anfrage. Für Verein ohne Auswahl: aktiver Vereinskontext (
<code>X-Active-Club-Id</code>
).
</span>
</div>
) : null}
{filterModalOpen && (
<div
className="admin-modal-backdrop"
@ -637,6 +824,118 @@ function ExercisesListPage() {
</div>
)}
{bulkModalOpen ? (
<div
className="admin-modal-backdrop"
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget) setBulkModalOpen(false)
}}
>
<div
className="admin-modal-sheet exercise-filter-modal"
role="dialog"
aria-modal="true"
aria-labelledby="exercise-bulk-modal-title"
onClick={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
<h3 id="exercise-bulk-modal-title" className="admin-modal-sheet__title">
Massenänderung: Sichtbarkeit / Status
</h3>
<button
type="button"
className="btn btn-secondary admin-modal-sheet__close"
onClick={() => setBulkModalOpen(false)}
>
Schließen
</button>
</div>
<div className="admin-modal-sheet__body exercise-filter-modal__scroll">
<p style={{ fontSize: '13px', color: 'var(--text2)', marginTop: 0 }}>
Es werden <strong>{selectedIds.size}</strong> Übung(en) aus der aktuellen Auswahl bearbeitet. Pro Durchlauf
höchstens {BULK_MAX_IDS}. Ohne Berechtigung bleiben Einzelübungen unverändert (siehe Hinweis nach dem
Speichern).
</p>
<div className="form-row">
<label className="form-label">Sichtbarkeit</label>
<select
className="form-input"
value={bulkVisibility}
onChange={(e) => setBulkVisibility(e.target.value)}
>
{bulkVisibilityOptions.map((o) => (
<option key={o.id === '' ? '_unchanged' : o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</div>
{bulkVisibility === 'club' ? (
<div className="form-row">
<label className="form-label">Verein zuordnen</label>
<select
className="form-input"
value={bulkClubSelect}
onChange={(e) => setBulkClubSelect(e.target.value)}
>
<option value="">Aktiver Verein (Vereins-Umschalter / Header)</option>
{(user?.clubs || []).map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || `#${c.id}`}
</option>
))}
</select>
{isPlatformAdmin ? (
<>
<label className="form-label" style={{ marginTop: '10px' }}>
Oder Vereins-ID (Plattform-Admin)
</label>
<input
type="number"
min={1}
className="form-input"
placeholder="Leer = wie Dropdown / aktiver Verein"
value={bulkClubManual}
onChange={(e) => setBulkClubManual(e.target.value)}
/>
</>
) : null}
</div>
) : null}
<div className="form-row">
<label className="form-label">Status</label>
<select
className="form-input"
value={bulkStatus}
onChange={(e) => setBulkStatus(e.target.value)}
>
<option value=""> nicht ändern </option>
{statusOptions.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</div>
</div>
<div className="exercise-filter-modal__footer" style={{ justifyContent: 'flex-end' }}>
<button
type="button"
className="btn"
disabled={bulkSubmitting}
onClick={() => setBulkModalOpen(false)}
>
Abbrechen
</button>
<button type="button" className="btn btn-primary" disabled={bulkSubmitting} onClick={handleBulkSubmit}>
{bulkSubmitting ? 'Speichern…' : 'Anwenden'}
</button>
</div>
</div>
</div>
) : null}
{listFetching && exercises.length === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '2rem' }}>
<div className="spinner"></div>
@ -666,7 +965,15 @@ function ExercisesListPage() {
>
{exercises.map((exercise) => (
<div key={exercise.id} className="card exercise-card">
<div className="exercise-card__body">
<div style={{ display: 'flex', gap: '10px', alignItems: 'flex-start' }}>
<input
type="checkbox"
checked={selectedIds.has(Number(exercise.id))}
onChange={() => toggleSelect(exercise.id)}
aria-label={`${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`}
style={{ marginTop: '4px', flexShrink: 0 }}
/>
<div className="exercise-card__body" style={{ flex: 1, minWidth: 0 }}>
<h3 style={{ marginBottom: '8px', fontSize: '1.05rem', lineHeight: 1.3 }}>
<Link
to={`/exercises/${exercise.id}`}
@ -689,6 +996,7 @@ function ExercisesListPage() {
: exercise.summary}
</p>
)}
</div>
</div>
<div className="exercise-card__actions">
<Link to={`/exercises/${exercise.id}`} className="btn btn-secondary">

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
@ -11,11 +11,18 @@ function LoginPage() {
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState('')
const [publicClubs, setPublicClubs] = useState([])
const [requestedClubId, setRequestedClubId] = useState('')
const [resending, setResending] = useState(false)
const navigate = useNavigate()
const { checkAuth } = useAuth()
useEffect(() => {
if (mode !== 'register') return
api.listPublicClubsDirectory().then(setPublicClubs).catch(() => setPublicClubs([]))
}, [mode])
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
@ -29,10 +36,15 @@ function LoginPage() {
await checkAuth()
navigate('/')
} else {
await api.register(email, password, name)
const extra =
requestedClubId !== ''
? { requested_club_id: parseInt(requestedClubId, 10) }
: {}
await api.register(email, password, name, extra)
setSuccess('Registrierung erfolgreich! Bitte prüfe deine E-Mails (auch Spam).')
setMode('login')
setPassword('')
setRequestedClubId('')
}
} catch (err) {
setError(err.message || 'Ein Fehler ist aufgetreten')
@ -84,6 +96,7 @@ function LoginPage() {
onClick={() => {
setMode('login')
setError('')
setRequestedClubId('')
}}
style={{ flex: 1 }}
>
@ -104,6 +117,7 @@ function LoginPage() {
<form onSubmit={handleSubmit}>
{mode === 'register' && (
<>
<div className="form-row">
<label className="form-label">Name</label>
<input
@ -115,6 +129,26 @@ function LoginPage() {
placeholder="Dein Name"
/>
</div>
<div className="form-row">
<label className="form-label">Verein (optional)</label>
<select
className="form-input"
value={requestedClubId}
onChange={(e) => setRequestedClubId(e.target.value)}
>
<option value="">Kein Antrag / später</option>
{publicClubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name}
{c.abbreviation ? ` (${c.abbreviation})` : ''}
</option>
))}
</select>
<p style={{ fontSize: '0.72rem', color: 'var(--text3)', marginTop: '0.35rem', lineHeight: 1.4 }}>
Nach der E-Mail-Bestätigung kann der Vereinsadmin deinen Beitritt freigeben.
</p>
</div>
</>
)}
<div className="form-row">

View File

@ -8,6 +8,17 @@ import { stripHtmlToText } from './htmlUtils'
const API_URL = import.meta.env.VITE_API_URL || ''
/** LocalStorage + Request-Header für Mandanten-Kontext */
export const ACTIVE_CLUB_STORAGE_KEY = 'shinkan_active_club_id'
function mergeActiveClubHeader(headers = {}) {
const cid = localStorage.getItem(ACTIVE_CLUB_STORAGE_KEY)
if (cid && /^\d+$/.test(String(cid).trim())) {
return { ...headers, 'X-Active-Club-Id': String(cid).trim() }
}
return { ...headers }
}
/**
* Generic API request with automatic token injection
*/
@ -15,9 +26,9 @@ async function request(endpoint, options = {}) {
const token = localStorage.getItem('authToken')
const method = (options.method || 'GET').toUpperCase()
const headers = {
const headers = mergeActiveClubHeader({
...options.headers,
}
})
// GET ohne Body: kein Content-Type: application/json (manche Proxies/Headers stören sich)
if (method !== 'GET' && method !== 'HEAD') {
if (!headers['Content-Type'] && !headers['content-type']) {
@ -84,10 +95,10 @@ export async function login(email, password) {
})
}
export async function register(email, password, name) {
export async function register(email, password, name, extra = {}) {
return request('/api/auth/register', {
method: 'POST',
body: JSON.stringify({ email, password, name })
body: JSON.stringify({ email, password, name, ...extra }),
})
}
@ -99,6 +110,16 @@ export async function getCurrentProfile() {
return request('/api/profiles/me')
}
/** Liste aller Profile nur für Plattform-Admins (Vereinsanlage). */
export async function listProfiles() {
return request('/api/profiles')
}
/** Alle Nutzer inkl. Vereinsmitgliedschaften — nur Portal-Admin (UI: Admin → Nutzer). */
export async function listAdminUsers() {
return request('/api/admin/users')
}
export async function updateProfile(profileId, data) {
return request(`/api/profiles/${profileId}`, {
method: 'PUT',
@ -158,6 +179,79 @@ export async function deleteClub(id) {
return request(`/api/clubs/${id}`, { method: 'DELETE' })
}
/** Vereinsmitglieder (API für Admin ohne eigene UI) */
export async function listClubMembers(clubId, { includeInactive = false } = {}) {
const q = includeInactive ? '?include_inactive=true' : ''
return request(`/api/clubs/${clubId}/members${q}`)
}
export async function getClubMember(clubId, profileId) {
return request(`/api/clubs/${clubId}/members/${profileId}`)
}
export async function addClubMember(clubId, payload) {
return request(`/api/clubs/${clubId}/members`, {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function updateClubMember(clubId, profileId, payload) {
return request(`/api/clubs/${clubId}/members/${profileId}`, {
method: 'PUT',
body: JSON.stringify(payload),
})
}
export async function removeClubMember(clubId, profileId) {
return request(`/api/clubs/${clubId}/members/${profileId}`, { method: 'DELETE' })
}
/** Aktive Vereine (öffentlich, für Registrierungswahl). */
export async function listPublicClubsDirectory() {
return request('/api/clubs/public-directory')
}
/** Vereinsinternes Mitgliederverzeichnis (Trainer-/Co-Auswahl). */
export async function clubMembersDirectory(clubId) {
return request(`/api/clubs/${clubId}/members/directory`)
}
/** Eigene Beitrittsanträge. */
export async function getMyClubJoinRequests() {
return request('/api/me/club-join-requests')
}
export async function createClubJoinRequest(payload) {
return request('/api/me/club-join-requests', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function withdrawClubJoinRequest(requestId) {
return request(`/api/me/club-join-requests/${requestId}`, { method: 'DELETE' })
}
/** Offene Anträge (Vereins-/Plattform-Admin). */
export async function listClubJoinRequests(clubId) {
return request(`/api/clubs/${clubId}/join-requests`)
}
export async function acceptClubJoinRequest(clubId, requestId, roles = ['trainer']) {
return request(`/api/clubs/${clubId}/join-requests/${requestId}/accept`, {
method: 'POST',
body: JSON.stringify({ roles }),
})
}
export async function rejectClubJoinRequest(clubId, requestId) {
return request(`/api/clubs/${clubId}/join-requests/${requestId}/reject`, {
method: 'POST',
body: JSON.stringify({}),
})
}
export async function listDivisions(clubId) {
const query = clubId ? `?club_id=${clubId}` : ''
return request(`/api/divisions${query}`)
@ -414,6 +508,14 @@ export async function updateExercise(id, data) {
})
}
/** Massenänderung Sichtbarkeit / Status (`PATCH /api/exercises/bulk-metadata`). */
export async function bulkPatchExercisesMetadata(data) {
return request('/api/exercises/bulk-metadata', {
method: 'PATCH',
body: JSON.stringify(data),
})
}
export async function deleteExercise(id) {
return request(`/api/exercises/${id}`, { method: 'DELETE' })
}
@ -1008,6 +1110,8 @@ export const api = {
register,
logout,
getCurrentProfile,
listProfiles,
listAdminUsers,
updateProfile,
changePassword,
verifyEmail,
@ -1019,6 +1123,19 @@ export const api = {
createClub,
updateClub,
deleteClub,
listClubMembers,
getClubMember,
addClubMember,
updateClubMember,
removeClubMember,
listPublicClubsDirectory,
clubMembersDirectory,
getMyClubJoinRequests,
createClubJoinRequest,
withdrawClubJoinRequest,
listClubJoinRequests,
acceptClubJoinRequest,
rejectClubJoinRequest,
listDivisions,
createDivision,
updateDivision,
@ -1047,6 +1164,7 @@ export const api = {
getExercise,
createExercise,
updateExercise,
bulkPatchExercisesMetadata,
deleteExercise,
createExerciseVariant,
updateExerciseVariant,

View File

@ -1,14 +1,14 @@
// Shinkan Jinkendo Frontend Version
export const APP_VERSION = "0.5.1"
export const APP_VERSION = "0.8.29"
export const BUILD_DATE = "2026-05-05"
export const PAGE_VERSIONS = {
LoginPage: "1.0.0",
Dashboard: "1.0.0",
AccountSettingsPage: "1.0.0",
ExercisesPage: "1.1.0", // Updated: Katalog-Integration
ClubsPage: "1.0.0",
ExercisesPage: "1.2.0", // Massenänderung Sichtbarkeit/Status auf der Liste
ClubsPage: "1.1.0",
SkillsPage: "1.0.0",
TrainingPlanningPage: "1.3.1",
TrainingFrameworkProgramsListPage: "1.1.0",