diff --git a/.claude/docs/functional/SHINKAN_REQUIREMENTS.md b/.claude/docs/functional/SHINKAN_REQUIREMENTS.md
index 591a9cf..73e0c34 100644
--- a/.claude/docs/functional/SHINKAN_REQUIREMENTS.md
+++ b/.claude/docs/functional/SHINKAN_REQUIREMENTS.md
@@ -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).
diff --git a/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md b/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md
new file mode 100644
index 0000000..51b392e
--- /dev/null
+++ b/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md
@@ -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 1–4 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
diff --git a/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md b/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md
new file mode 100644
index 0000000..3f26dbf
--- /dev/null
+++ b/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md
@@ -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, §17–18 · `functional/DOMAIN_MODEL.md` (Sichtbarkeit §5.5) · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR-004–008)
+
+**Operative Reihenfolge & einheitliche Zugriffsschicht:** [`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`](./ACCESS_LAYER_AND_GOVERNANCE_PLAN.md) – dort sind Stufen A–F, Drift-Prävention und die Zurückstellung von Vereinsabo/Limits festgehalten; dieses Dokument bleibt das übergeordnete **Zielbild**.
+
+---
+
+## 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-004–008: Governance-Kern, spätere Policy | Datenfelder vorbereitet; **Policy/Erzwingung** folgt |
+| `working/SHINKAN_PROJECT_SETUP.md` §6 | „Multi-Tenant-Administration“ ausgeschlossen (MVP-Liste) | Historisch; **technische Mandanten** sind dennoch Ziel – UI-Komplexität kontrolliert einführen |
+
+**Fazit:** Die fachlichen Rollen und Sichtbarkeitsebenen sind **in den funktionalen Docs bereits skizziert**. Es fehlt die **stringente technische Schicht**: Vereinszugehörigkeit, aktiver Vereinskontext, effektive Berechtigungen pro Anfrage und konsequente Filterung bei `club`-sichtbaren Objekten.
+
+---
+
+## 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 A–F, einheitliche Zugriffsschicht, Scope-Erweiterung (`division`, später Community), Capability-Vorbereitung ohne Custom-Rollen-UI; Vereinsabo explizit zurückgestellt.
+
+---
+
+**Letzte Aktualisierung:** 2026-05-05
diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md
new file mode 100644
index 0000000..87ac058
--- /dev/null
+++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md
@@ -0,0 +1,33 @@
+# Endpoint-Audit: Mandanten & Governance
+
+Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
+
+| Router / Bereich | Beispiel-Endpunkt | tenant-relevant | `Depends(get_tenant_context)` / Kontext | Governance geprüft (Liste+Detail) | Notizen |
+|------------------|-------------------|-----------------|----------------------------------------|-------------------------------------|---------|
+| profiles | `GET /api/profiles/me` | ja | `resolve_tenant_context` inline (`invalid_header_policy=ignore`) | teils | + `effective_club_id`; veralteter Header bricht Refresh nicht |
+| profiles | `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.
diff --git a/.claude/rules/ARCHITECTURE.md b/.claude/rules/ARCHITECTURE.md
index d5e4e3b..066dbd3 100644
--- a/.claude/rules/ARCHITECTURE.md
+++ b/.claude/rules/ARCHITECTURE.md
@@ -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
diff --git a/.claude/rules/CODING_RULES.md b/.claude/rules/CODING_RULES.md
index bc219fe..3bdc1d2 100644
--- a/.claude/rules/CODING_RULES.md
+++ b/.claude/rules/CODING_RULES.md
@@ -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') ❌
```
diff --git a/.cursor/rules/access-layer.mdc b/.cursor/rules/access-layer.mdc
new file mode 100644
index 0000000..d563d04
--- /dev/null
+++ b/.cursor/rules/access-layer.mdc
@@ -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)):
+ ...
+```
diff --git a/CLAUDE.md b/CLAUDE.md
index fa53f8f..85026c9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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` |
diff --git a/backend/club_tenancy.py b/backend/club_tenancy.py
new file mode 100644
index 0000000..e81e374
--- /dev/null
+++ b/backend/club_tenancy.py
@@ -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
diff --git a/backend/db_init.py b/backend/db_init.py
index 6714613..af6b9c9 100644
--- a/backend/db_init.py
+++ b/backend/db_init.py
@@ -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")
diff --git a/backend/main.py b/backend/main.py
index ad11a2e..2699427 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -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)
diff --git a/backend/migrations/039_club_membership_rbac.sql b/backend/migrations/039_club_membership_rbac.sql
new file mode 100644
index 0000000..7c9ae34
--- /dev/null
+++ b/backend/migrations/039_club_membership_rbac.sql
@@ -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);
diff --git a/backend/migrations/040_club_membership_requests.sql b/backend/migrations/040_club_membership_requests.sql
new file mode 100644
index 0000000..976981a
--- /dev/null
+++ b/backend/migrations/040_club_membership_requests.sql
@@ -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();
diff --git a/backend/migrations/041_bootstrap_superadmin.sql b/backend/migrations/041_bootstrap_superadmin.sql
new file mode 100644
index 0000000..82c6bcb
--- /dev/null
+++ b/backend/migrations/041_bootstrap_superadmin.sql
@@ -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'
+ );
diff --git a/backend/models.py b/backend/models.py
index e2563e8..881bfa6 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -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
diff --git a/backend/pytest.ini b/backend/pytest.ini
new file mode 100644
index 0000000..4584de7
--- /dev/null
+++ b/backend/pytest.ini
@@ -0,0 +1,3 @@
+[pytest]
+testpaths = tests
+pythonpath = .
diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt
new file mode 100644
index 0000000..784f297
--- /dev/null
+++ b/backend/requirements-dev.txt
@@ -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
diff --git a/backend/routers/admin_users.py b/backend/routers/admin_users.py
new file mode 100644
index 0000000..2d32f41
--- /dev/null
+++ b/backend/routers/admin_users.py
@@ -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
diff --git a/backend/routers/auth.py b/backend/routers/auth.py
index b740576..cb8bcc8 100644
--- a/backend/routers/auth.py
+++ b/backend/routers/auth.py
@@ -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)
diff --git a/backend/routers/club_join_requests.py b/backend/routers/club_join_requests.py
new file mode 100644
index 0000000..dae7767
--- /dev/null
+++ b/backend/routers/club_join_requests.py
@@ -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}
diff --git a/backend/routers/club_memberships.py b/backend/routers/club_memberships.py
new file mode 100644
index 0000000..d1a4a6a
--- /dev/null
+++ b/backend/routers/club_memberships.py
@@ -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}
diff --git a/backend/routers/clubs.py b/backend/routers/clubs.py
index 9a630a0..b949ff0 100644
--- a/backend/routers/clubs.py
+++ b/backend/routers/clubs.py
@@ -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()
diff --git a/backend/routers/exercise_progression_graphs.py b/backend/routers/exercise_progression_graphs.py
index 13326aa..124b681 100644
--- a/backend/routers/exercise_progression_graphs.py
+++ b/backend/routers/exercise_progression_graphs.py
@@ -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 n−1 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
diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py
index 9dbfa77..e5b0e2b 100644
--- a/backend/routers/exercises.py
+++ b/backend/routers/exercises.py
@@ -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)
diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py
index c79b185..b0d8f82 100644
--- a/backend/routers/profiles.py
+++ b/backend/routers/profiles.py
@@ -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)
diff --git a/backend/routers/training_framework_programs.py b/backend/routers/training_framework_programs.py
index 459f7f1..cc8a367 100644
--- a/backend/routers/training_framework_programs.py
+++ b/backend/routers/training_framework_programs.py
@@ -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,),
diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py
index 2cd7f03..e664209 100644
--- a/backend/routers/training_planning.py
+++ b/backend/routers/training_planning.py
@@ -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)
diff --git a/backend/run_migrations.py b/backend/run_migrations.py
index bd5f5d8..b68d5a5 100644
--- a/backend/run_migrations.py
+++ b/backend/run_migrations.py
@@ -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
diff --git a/backend/scripts/check_access_layer_hints.py b/backend/scripts/check_access_layer_hints.py
new file mode 100644
index 0000000..858b664
--- /dev/null
+++ b/backend/scripts/check_access_layer_hints.py
@@ -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())
diff --git a/backend/tenant_context.py b/backend/tenant_context.py
new file mode 100644
index 0000000..7181a5a
--- /dev/null
+++ b/backend/tenant_context.py
@@ -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,
+ )
diff --git a/backend/tests/test_access_layer.py b/backend/tests/test_access_layer.py
new file mode 100644
index 0000000..11654b3
--- /dev/null
+++ b/backend/tests/test_access_layer.py
@@ -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
diff --git a/backend/version.py b/backend/version.py
index 00b7e14..82613d5 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -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",
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 069dd33..b545339 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -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() {
+ Beantrage die Mitgliedschaft in einem Verein. Vereinsadministratoren können den Antrag unter + „Vereinsverwaltung → Mitglieder“ annehmen oder ablehnen. +
+ + {myJoinRequests.length > 0 && ( +
diff --git a/frontend/src/pages/AdminUsersPage.jsx b/frontend/src/pages/AdminUsersPage.jsx
new file mode 100644
index 0000000..68902ac
--- /dev/null
+++ b/frontend/src/pages/AdminUsersPage.jsx
@@ -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
+ Alle Konten mit Vereinszuordnungen. Hier kannst du die Portal-Rolle (Zugriff auf + Admin-Funktionen) und das Tier setzen sowie Nutzer explizit einem Verein mit Rollen + zuordnen. +
+ + {loading ? ( +Laden…
+ ) : error ? ( ++ Keine Zuordnung. +
+ ) : ( +{assignModal.profileLabel}
++ {clubEditModal.profileLabel} → {clubEditModal.clubName} +
+
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 ? (
+ <>
+ {' '}
+
X-Active-Club-Id
+ ).
+
+ + Es werden {selectedIds.size} Ü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). +
+