Mandantenfähigkeit V1 #10
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
125
.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md
Normal file
125
.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# Einheitliche Zugriffsschicht & Governance – Umsetzungsplan
|
||||
|
||||
**Status:** verbindliche Umsetzungsreihenfolge (nachgelagert zum Zielbild in `MULTI_TENANCY_RBAC_ARCHITECTURE.md`)
|
||||
**Stand:** 2026-05-05
|
||||
**Zweck:** Drift vermeiden – eine nachvollziehbare Schicht für Mandanten-Kontext, Sichtbarkeit und Berechtigungen, auf die alle inhaltsbezogenen Module konsistent aufbauen.
|
||||
|
||||
**Explizit zurückgestellt (wie vereinbart):** kostenpflichtiges Vereins-Membership / Tier-Limits pro Verein (`club_subscriptions` o. Ä.) – kommt nach stabiler Zugriffs- und Datenisolationsbasis.
|
||||
|
||||
**Separates späteres Konzept:** durch Vereinsadmins definierbare Rollen mit feingranularen Rechten (Capability-Bundles in DB/UI); dieser Plan bereitet nur die **Capability-Idee** und **Erweiterungspunkte** vor.
|
||||
|
||||
---
|
||||
|
||||
## 1. Leitprinzipien
|
||||
|
||||
1. **Ein Mandant für Datenisolierung:** `club_id` ist die Grenze für „vereinsgeteilte“ Inhalte. Nur Ausnahmen: explizit **plattformweite/offizielle** Objekte und **privat**e Objekte des Erstellers.
|
||||
2. **Ein Kontext pro HTTP-Request:** Mitgliedschaft und gewählter aktiver Verein werden einmal aufgelöst und validiert; Folgelogik nutzt nur noch dieses Objekt (kein verteiltes erneutes „Rates“ aus Headers).
|
||||
3. **Eine fachliche Sichtbarkeits-Semantik** über alle Bibliotheks- und Planungsartefakte (Übungen, Vorlagen, Rahmenprogramme, …): gleiche Enums, gleiche Leseprüfung, angepasste Listenfilter.
|
||||
4. **Sparte optional verschärfen:** `division_id` auf Objekten und später auf Rollenzuweisung ausgewertet – ohne Vereinsgrenze zu sprengen.
|
||||
5. **Community später additive:** neue Freigabeebene oder Flags ergänzen die bestehende Semantik, ohne `club`-Isolation zu ersetzen.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architektur der Zugriffsschicht (Schichtenmodell)
|
||||
|
||||
| Schicht | Verantwortung |
|
||||
|---------|----------------|
|
||||
| **Authentifizierung** | Session / Token → `profile_id`, globale `role` (`require_auth`, bestehend). |
|
||||
| **TenantContext** (neu, zentral) | Aus `profile_id` + Header `X-Active-Club-Id` (optional) + DB `profiles.active_club_id`: effektive **`effective_club_id`** nur wenn Mitgliedschaft aktiv existiert; sonch klaren Fehler (403/400 nach Konvention). Optional: Cache der Mitgliedschaftszeilen/Rollen **einmal pro Request**. |
|
||||
| **Governance / Objekt contra Account** | Für jedes geschützte Objekt: `visibility`, `club_id`, `division_id` (nullable), `created_by` → **eine** zentrale Entscheidung `can_read` / `can_write` (interne Module, keine Copy-Paste-Logik pro Router). |
|
||||
| **Funktions-/Feature-Rechte** | „Darf dieser Nutzer im Verein X Trainergruppe anlegen?“ → Capability-Checks (`can_manage_club_org`, `can_plan_in_club`, …); später durch dynamische Rollen → gleiche Capability-Namen. |
|
||||
|
||||
**Ziel:** Router werden dünn: laden Daten nur noch durch Hilfen, die **TenantContext** und **Governance** bereits berücksichtigen oder explizit prüfen.
|
||||
|
||||
---
|
||||
|
||||
## 3. Scope- und Sichtbarkeitsmodell (einheitlich)
|
||||
|
||||
Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
|
||||
|
||||
**Zielbild (phasenweise DB/API-Anpassung):**
|
||||
|
||||
| Wert | Lesende Regel (kurz) |
|
||||
|------|----------------------|
|
||||
| `private` | Nur `created_by` (+ Plattform-Admin nach Policy); keine Vereinsliste ohne Ownership. |
|
||||
| `club` | Nur aktive Mitglieder des Objekt-`club_id`; **Cross-Verein nie**. |
|
||||
| `division` *(optional neue Stufe oder `club` + Pflicht `division_id`)* | Nur Mitglieder, die dieser Sparte zugeordnet sind (Regeln gesondert spezifizieren: Mitgliedschaft vs. Gruppe vs. Rolle `division_lead`). |
|
||||
| `official` | Plattform-weit lesbar (Superadmin publiziert/pflegt); weiterhin strikt von `club`-Daten getrennt. |
|
||||
| `community` *(reserviert)* | Noch nicht implementieren; Design nur additive Felder/Enum-Einträge dokumentieren, wenn erste Stories starten. |
|
||||
|
||||
**Trainer-Flow:** „Privat anlegen, dann im Verein teilen“ = Transition von `private` zu `club` (oder Kopie + neue Visibility – Produktentscheidung; technisch muss `club_id` gesetzt und Mitgliedschaft geprüft werden).
|
||||
|
||||
---
|
||||
|
||||
## 4. Roadmap (verbindliche Reihenfolge)
|
||||
|
||||
### Stufe A – Foundations & Audit
|
||||
|
||||
- **Router-Inventar:** Liste aller Endpoints mit Zugriff auf `club_id`, `visibility`, organisationalem Bezug oder Listenfiltern (Excel/Markdown-Tabelle im Repo oder unter `.claude/docs/working/`).
|
||||
- **Definition of Done je Endpoint:** „Default deny“ für tenant-sensitive Listen wenn Kontext fehlt/ungültig.
|
||||
- TenantContext-Spezifikation festhalten (Feldnamen, HTTP-Fehlercodes, Superadmin-Ausnahmen).
|
||||
|
||||
### Stufe B – TenantContext (Backend zentral)
|
||||
|
||||
- FastAPI-Dependency z. B. `get_tenant_context(session, x_active_club_id)` → Objekt mit `profile_id`, `global_role`, `effective_club_id`, `club_memberships` (optional gekürzt).
|
||||
- Alle neuen und bestehenden sicherheitsrelevanten Änderungen **nur** über diese Dependency oder darauf aufbauende Helfer.
|
||||
- Abgleich mit Frontend: `X-Active-Club-Id` und Persistenz `active_club_id` (bereits vorhanden) als einzige variabler Vereinskontext-Quellen.
|
||||
|
||||
### Stufe C – Governance vereinheitlichen
|
||||
|
||||
- **Eine** interne API-Stilebene (auch wenn mehrere Python-Funktionen):
|
||||
`content_readable(...)`, `content_writable(...)` oder zweischichtig „Governance“ vs „Org-Rechte“.
|
||||
- Module angleichen: **Übungen**, **Trainingsplanung**, **Rahmenprogramme**, **Vorlagen** – gleiche Regeln für `club`/`official`/`private`; **`division`** dort einführen, wo fachlich nötig (einheitliche Filter-Chips in UI).
|
||||
- Regressionstests: **zwei Vereine, zwei Nutzer**, kein Kreuzzugriff auf `club`-Objekte; Superadmin/Global-Path weiterhin getrennt testen.
|
||||
|
||||
### Stufe D – Sparten-Durchsetzung
|
||||
|
||||
- `division_lead` und optional `division_id` auf Mitgliedschaft/Rolle auswerten bei Schreib-/Lesevorgängen für Objekte mit `division_id`.
|
||||
- Dokumentieren: Was gilt für Objekte mit `division_id=NULL` innerhalb eines Vereins (vereinsweit vs. nur „ohne Sparte“).
|
||||
|
||||
### Stufe E – Capabilities dokumentieren (ohne UI für Custom Roles)
|
||||
|
||||
- Markdown-Tabelle **Capability-Fingerprint**: Kennungen wie `content.share_club`, `planning.edit_unit`, `org.manage_members`, … mit Zuordnung zu den **heutigen** festen Vereinsrollen.
|
||||
- Ziel: später `club_custom_roles` nur noch andere Kombination derselben Kennungen – keine zweite Philosophie.
|
||||
|
||||
### Stufe F – Community (eigenes Epic)
|
||||
|
||||
- Konzept: Freigabe **additiv** (Flag oder Enum), Moderation, Sichtbarkeit „öffentlich außerhalb meines Vereins“ ohne bestehende `club`-Isolation zu brechen.
|
||||
|
||||
### Zurückgestellt – Vereinsabo / Limits
|
||||
|
||||
- Wiederöffnen wenn ACCESS_LAYER Stufe C/D stabil; dann Enforcement vor ausgewählten Writes an einen Billing-Stripe binden.
|
||||
|
||||
---
|
||||
|
||||
## 5. Drift vermeiden (Arbeitsdisziplin)
|
||||
|
||||
| Mechanismus | Inhalt |
|
||||
|-------------|--------|
|
||||
| **Cursor / IDE** | Projektregel `.cursor/rules/access-layer.mdc` (Router); Agents sollen nicht auf „nur require_auth“ ausweichen. |
|
||||
| **Heuristik-Check** | `python backend/scripts/check_access_layer_hints.py`; CI optional mit `ACCESS_LAYER_STRICT=1`. Optional danach: `cd backend && pytest tests/` (nach `pip install -r requirements-dev.txt`). |
|
||||
| **PR-Checkliste** | Neuer/changed Endpoint: TenantContext verwendet? Governance für Listen + Detail? Tests für zweiten Verein? |
|
||||
| **Single Source of Truth** | Sichtbarkeitsregeln nur in Zugriffsmodul(en), nicht in Routers dupliziert. |
|
||||
| **Änderungen am Enum** | Nur zusammen mit Migration + Kurzbeschreibung in diesem Dokument (Datum/Changelog-Zeile). |
|
||||
| **Beziehung zu MULTI_TENANCY-Doc** | Phasen 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
|
||||
225
.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md
Normal file
225
.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
# Multi-Tenancy, Vereins-Membership und Rollenmodell – Zielarchitektur & Umsetzungsplan
|
||||
|
||||
**Status:** verbindliche Zielrichtung (Architekturpapier)
|
||||
**Stand:** 2026-05-05
|
||||
**Bezug:** `functional/shinkan_anforderungsdokument_entwurf.md` §5, §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
|
||||
33
.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md
Normal file
33
.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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') ❌
|
||||
```
|
||||
|
||||
|
|
|
|||
36
.cursor/rules/access-layer.mdc
Normal file
36
.cursor/rules/access-layer.mdc
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
description: Mandanten & Governance — TenantContext, Sichtbarkeit, keine Schnellpfade
|
||||
globs: backend/routers/*.py
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Zugriffsschicht (Shinkan)
|
||||
|
||||
Vor neuen oder geänderten Endpoints in `backend/routers/` kurz prüfen:
|
||||
|
||||
1. **Pflichtlektüre** (bei inhaltsbezogenen APIs): `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` und `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md`.
|
||||
|
||||
2. **Auth**: kein geschützter Endpoint ohne `Depends(require_auth)` bzw. eingebettet in `Depends(get_tenant_context)` (der holt die Session bereits).
|
||||
|
||||
3. **Verein / Sichtbarkeit** (`visibility`, `club_id`, Mitglieder-Inhalte):
|
||||
`tenant: TenantContext = Depends(get_tenant_context)` verwenden; Profile-ID aus `tenant.profile_id`, Rolle aus `tenant.global_role`.
|
||||
|
||||
4. **Bibliothekslisten** (Übungen, Vorlagen, Rahmenprogramme, Progressionsgraphen, gleiche Semantik): Filter über `library_content_visibility_sql(...)` bzw. gleiche Leseregel wie bestehende Module — nicht „alle Zeilen aus SELECT“.
|
||||
|
||||
5. **Schreiben mit Governance**: bei `visibility`/`club_id`-Änderungen `assert_valid_governance_visibility`; bei `club` ohne `club_id` im Body → `tenant.effective_club_id` oder klare 400-Hinweise (wie bei Übungen).
|
||||
|
||||
6. **Ausnahmen** nur mit kurzem Kommentar im Code: `# ACCESS_LAYER exempt: …` und Eintrag im Audit oder in `backend/scripts/check_access_layer_hints.py` (EXEMPT-Liste).
|
||||
|
||||
7. **Nach Merge**: eine Zeile `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md` anpassen, wenn sich Tenant oder Governance ändert.
|
||||
|
||||
```python
|
||||
# ❌ Schnellpfad: nur Session, obwohl Vereinsdaten betroffen
|
||||
@router.get("/foo")
|
||||
def foo(session=Depends(require_auth)):
|
||||
...
|
||||
|
||||
# ✅ Konsistent zu clubs/exercises/planning
|
||||
@router.get("/foo")
|
||||
def foo(tenant: TenantContext = Depends(get_tenant_context)):
|
||||
...
|
||||
```
|
||||
|
|
@ -5,6 +5,9 @@
|
|||
> VOR jeder Implementierung lesen:
|
||||
> | Architektur-Regeln | `.claude/rules/ARCHITECTURE.md` |
|
||||
> | Coding-Regeln | `.claude/rules/CODING_RULES.md` |
|
||||
> | Zugriffsschicht (Multi-Tenancy, Governance) | `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` |
|
||||
> | Endpoint-Audit (Tenant/Governance) | `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md` |
|
||||
> | Cursor-Regel Zugriffsschicht | `.cursor/rules/access-layer.mdc` |
|
||||
> | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` |
|
||||
> | Setup-Dokument | `.claude/docs/working/SHINKAN_PROJECT_SETUP.md` |
|
||||
> | Anforderungen | `.claude/docs/functional/SHINKAN_REQUIREMENTS.md` |
|
||||
|
|
|
|||
173
backend/club_tenancy.py
Normal file
173
backend/club_tenancy.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"""
|
||||
Vereins-Mandanten: Mitgliedschaften, aktiver Vereinskontext, einfache Berechtigungen.
|
||||
|
||||
Siehe .claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md
|
||||
"""
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
def is_platform_admin(role: Optional[str]) -> bool:
|
||||
return (role or "").lower() in ("admin", "superadmin")
|
||||
|
||||
|
||||
def club_ids_for_profile(cur, profile_id: int) -> Set[int]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT club_id FROM club_members
|
||||
WHERE profile_id = %s AND status = 'active'
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
return {r["club_id"] for r in cur.fetchall()}
|
||||
|
||||
|
||||
def assert_club_member(cur, profile_id: int, club_id: int) -> None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM club_members
|
||||
WHERE profile_id = %s AND club_id = %s AND status = 'active'
|
||||
LIMIT 1
|
||||
""",
|
||||
(profile_id, club_id),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=403, detail="Keine Mitgliedschaft in diesem Verein")
|
||||
|
||||
|
||||
def has_club_role(cur, profile_id: int, club_id: int, *role_codes: str) -> bool:
|
||||
if not role_codes:
|
||||
return False
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM club_members cm
|
||||
INNER JOIN club_member_roles r ON r.club_member_id = cm.id
|
||||
WHERE cm.profile_id = %s AND cm.club_id = %s AND cm.status = 'active'
|
||||
AND r.role_code IN ({ph})
|
||||
LIMIT 1
|
||||
""".format(ph=",".join(["%s"] * len(role_codes))),
|
||||
(profile_id, club_id, *role_codes),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def can_manage_club_org(cur, profile_id: int, club_id: int, global_role: Optional[str]) -> bool:
|
||||
"""Sparten/Gruppen/Struktur: Vereinsadmin oder Plattform-Admin."""
|
||||
if is_platform_admin(global_role):
|
||||
return True
|
||||
return has_club_role(cur, profile_id, club_id, "club_admin")
|
||||
|
||||
|
||||
def can_plan_in_club(cur, profile_id: int, club_id: int, global_role: Optional[str]) -> bool:
|
||||
"""Trainingsgruppen anlegen / planen: Admin-Rollen im Verein oder Plattform."""
|
||||
if is_platform_admin(global_role):
|
||||
return True
|
||||
return has_club_role(
|
||||
cur, profile_id, club_id, "club_admin", "trainer", "content_editor", "division_lead"
|
||||
)
|
||||
|
||||
|
||||
def memberships_with_roles(cur, profile_id: int, active_only: bool = True) -> List[Dict[str, Any]]:
|
||||
status_filter = "AND cm.status = 'active'" if active_only else ""
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT c.id, c.name, c.abbreviation, c.status,
|
||||
cm.status AS membership_status,
|
||||
COALESCE(
|
||||
ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL),
|
||||
ARRAY[]::varchar[]
|
||||
) AS roles
|
||||
FROM club_members cm
|
||||
INNER JOIN clubs c ON c.id = cm.club_id
|
||||
LEFT JOIN club_member_roles r ON r.club_member_id = cm.id
|
||||
WHERE cm.profile_id = %s {status_filter}
|
||||
GROUP BY c.id, c.name, c.abbreviation, c.status, cm.status
|
||||
ORDER BY c.name
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
out: List[Dict[str, Any]] = []
|
||||
for row in cur.fetchall():
|
||||
d = dict(row)
|
||||
roles = d.get("roles") or []
|
||||
if hasattr(roles, "tolist"):
|
||||
roles = roles.tolist()
|
||||
d["roles"] = list(roles)
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
_GOVERNANCE_VISIBILITY = frozenset({"private", "club", "official"})
|
||||
|
||||
|
||||
def assert_valid_governance_visibility(
|
||||
cur,
|
||||
profile_id: int,
|
||||
role: Optional[str],
|
||||
visibility: str,
|
||||
club_id: Optional[int],
|
||||
) -> None:
|
||||
"""Pflicht club_id bei visibility=club; Mitgliedschaft außer Plattform-Admin; official nur Plattform-Admin."""
|
||||
if visibility not in _GOVERNANCE_VISIBILITY:
|
||||
raise HTTPException(status_code=400, detail="Ungültige visibility")
|
||||
if visibility == "official" and not is_platform_admin(role):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Nur Plattform-Admins dürfen offizielle Inhalte setzen",
|
||||
)
|
||||
if visibility == "club":
|
||||
if club_id is None:
|
||||
raise HTTPException(status_code=400, detail="club_id ist bei visibility=club erforderlich")
|
||||
if is_platform_admin(role):
|
||||
cur.execute("SELECT 1 FROM clubs WHERE id = %s", (club_id,))
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=400, detail="Verein nicht gefunden")
|
||||
else:
|
||||
assert_club_member(cur, profile_id, club_id)
|
||||
|
||||
|
||||
def library_content_visible_to_profile(
|
||||
cur,
|
||||
profile_id: int,
|
||||
visibility: str,
|
||||
content_club_id: Optional[int],
|
||||
created_by: Optional[int],
|
||||
global_role: Optional[str],
|
||||
) -> bool:
|
||||
"""Leserechte wie Übungen für alle Objekte mit visibility/club_id/created_by (Bibliothek & Co.)."""
|
||||
return exercise_visible_to_profile(
|
||||
cur, profile_id, visibility, content_club_id, created_by, global_role
|
||||
)
|
||||
|
||||
|
||||
def exercise_visible_to_profile(
|
||||
cur,
|
||||
profile_id: int,
|
||||
visibility: str,
|
||||
exercise_club_id: Optional[int],
|
||||
created_by: Optional[int],
|
||||
global_role: Optional[str],
|
||||
) -> bool:
|
||||
"""Leserechte einer Übung. Für neue Codepfade lieber `library_content_visible_to_profile` verwenden."""
|
||||
if is_platform_admin(global_role):
|
||||
return True
|
||||
if visibility == "official":
|
||||
return True
|
||||
if created_by is not None and created_by == profile_id:
|
||||
return True
|
||||
if visibility == "private":
|
||||
return False
|
||||
if visibility == "club":
|
||||
if exercise_club_id is None:
|
||||
return False
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM club_members
|
||||
WHERE profile_id = %s AND club_id = %s AND status = 'active'
|
||||
LIMIT 1
|
||||
""",
|
||||
(profile_id, exercise_club_id),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
return False
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
119
backend/migrations/039_club_membership_rbac.sql
Normal file
119
backend/migrations/039_club_membership_rbac.sql
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
-- Migration 039: Vereins-Mitgliedschaft, Rollen pro Verein, aktiver Vereinskontext (Multi-Tenancy Phase 1)
|
||||
-- Erstellt: 2026-05-05
|
||||
|
||||
-- Mitgliedschaft Profil ↔ Verein
|
||||
CREATE TABLE club_members (
|
||||
id SERIAL PRIMARY KEY,
|
||||
profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(profile_id, club_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_club_members_profile ON club_members(profile_id);
|
||||
CREATE INDEX idx_club_members_club ON club_members(club_id);
|
||||
CREATE INDEX idx_club_members_status ON club_members(status);
|
||||
|
||||
-- Rollen pro Mitgliedschaft (role_code: club_admin, trainer, division_lead, content_editor)
|
||||
CREATE TABLE club_member_roles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
club_member_id INT NOT NULL REFERENCES club_members(id) ON DELETE CASCADE,
|
||||
role_code VARCHAR(50) NOT NULL,
|
||||
division_id INT REFERENCES divisions(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE (club_member_id, role_code)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_club_member_roles_member ON club_member_roles(club_member_id);
|
||||
CREATE INDEX idx_club_member_roles_code ON club_member_roles(role_code);
|
||||
|
||||
-- Hauptverwalter:in (fachliche Referenz; Rechte über club_member_roles.club_admin)
|
||||
ALTER TABLE clubs ADD COLUMN IF NOT EXISTS primary_admin_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX idx_clubs_primary_admin ON clubs(primary_admin_profile_id);
|
||||
|
||||
-- Persistenz gewählter aktiver Verein (UI); Request-Header kann überschreiben
|
||||
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS active_club_id INT REFERENCES clubs(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX idx_profiles_active_club ON profiles(active_club_id);
|
||||
|
||||
-- ── Backfill: Trainer/Co-Trainer aus Trainingsgruppen → Mitgliedschaft + Rolle trainer
|
||||
INSERT INTO club_members (profile_id, club_id, status)
|
||||
SELECT DISTINCT t.trainer_id, t.club_id, 'active'
|
||||
FROM training_groups t
|
||||
WHERE t.trainer_id IS NOT NULL
|
||||
ON CONFLICT (profile_id, club_id) DO NOTHING;
|
||||
|
||||
INSERT INTO club_members (profile_id, club_id, status)
|
||||
SELECT DISTINCT elem::int, x.club_id, 'active'
|
||||
FROM (
|
||||
SELECT club_id, co_trainer_ids
|
||||
FROM training_groups
|
||||
WHERE CASE WHEN jsonb_typeof(co_trainer_ids) = 'array'
|
||||
THEN jsonb_array_length(co_trainer_ids)
|
||||
ELSE 0 END > 0
|
||||
) x,
|
||||
LATERAL jsonb_array_elements_text(x.co_trainer_ids) AS elem
|
||||
ON CONFLICT (profile_id, club_id) DO NOTHING;
|
||||
|
||||
INSERT INTO club_member_roles (club_member_id, role_code)
|
||||
SELECT cm.id, 'trainer'
|
||||
FROM club_members cm
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM club_member_roles r
|
||||
WHERE r.club_member_id = cm.id AND r.role_code = 'trainer'
|
||||
);
|
||||
|
||||
-- Pro Verein: kleinste trainer_id einer Gruppe als primärer Admin (Pilot / CURR-008-Analog)
|
||||
UPDATE clubs c
|
||||
SET primary_admin_profile_id = sub.pid
|
||||
FROM (
|
||||
SELECT DISTINCT ON (club_id)
|
||||
club_id,
|
||||
trainer_id AS pid
|
||||
FROM training_groups
|
||||
WHERE trainer_id IS NOT NULL
|
||||
ORDER BY club_id, id ASC
|
||||
) sub
|
||||
WHERE c.id = sub.club_id
|
||||
AND c.primary_admin_profile_id IS NULL;
|
||||
|
||||
INSERT INTO club_member_roles (club_member_id, role_code)
|
||||
SELECT cm.id, 'club_admin'
|
||||
FROM club_members cm
|
||||
INNER JOIN clubs cl ON cl.id = cm.club_id AND cl.primary_admin_profile_id = cm.profile_id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM club_member_roles r
|
||||
WHERE r.club_member_id = cm.id AND r.role_code = 'club_admin'
|
||||
);
|
||||
|
||||
-- Globale Admins: in jedem bestehenden Verein als club_admin spiegeln (volle Plattform-Sicht)
|
||||
INSERT INTO club_members (profile_id, club_id, status)
|
||||
SELECT p.id, c.id, 'active'
|
||||
FROM profiles p
|
||||
CROSS JOIN clubs c
|
||||
WHERE p.role IN ('admin', 'superadmin')
|
||||
ON CONFLICT (profile_id, club_id) DO NOTHING;
|
||||
|
||||
INSERT INTO club_member_roles (club_member_id, role_code)
|
||||
SELECT cm.id, 'club_admin'
|
||||
FROM club_members cm
|
||||
INNER JOIN profiles p ON p.id = cm.profile_id AND p.role IN ('admin', 'superadmin')
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM club_member_roles r
|
||||
WHERE r.club_member_id = cm.id AND r.role_code = 'club_admin'
|
||||
);
|
||||
|
||||
-- Default aktiver Verein: einziger Verein des Nutzers
|
||||
UPDATE profiles p
|
||||
SET active_club_id = sub.only_club
|
||||
FROM (
|
||||
SELECT profile_id, MIN(club_id) AS only_club
|
||||
FROM club_members
|
||||
WHERE status = 'active'
|
||||
GROUP BY profile_id
|
||||
HAVING COUNT(DISTINCT club_id) = 1
|
||||
) sub
|
||||
WHERE p.id = sub.profile_id AND (p.active_club_id IS NULL);
|
||||
29
backend/migrations/040_club_membership_requests.sql
Normal file
29
backend/migrations/040_club_membership_requests.sql
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
-- Migration 040: Antrag auf Vereinsbeitritt (pending → accept/reject durch Vereins-/Plattform-Admin)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_membership_requests (
|
||||
id SERIAL PRIMARY KEY,
|
||||
profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'accepted', 'rejected', 'withdrawn')),
|
||||
message TEXT,
|
||||
decided_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||
decided_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_membership_requests_pending
|
||||
ON club_membership_requests (profile_id, club_id)
|
||||
WHERE status = 'pending';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_membership_requests_club_status
|
||||
ON club_membership_requests (club_id, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_membership_requests_profile
|
||||
ON club_membership_requests (profile_id);
|
||||
|
||||
DROP TRIGGER IF EXISTS club_membership_requests_update ON club_membership_requests;
|
||||
CREATE TRIGGER club_membership_requests_update
|
||||
BEFORE UPDATE ON club_membership_requests
|
||||
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
|
||||
20
backend/migrations/041_bootstrap_superadmin.sql
Normal file
20
backend/migrations/041_bootstrap_superadmin.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
-- Migration 041: Super-Admin für bestehende Installationen
|
||||
-- Bisheriger Bootstrap (registration_role) setzte nur 'admin'. Viele Endpunkte
|
||||
-- (z. B. Verein löschen, Super-Rolle vergeben) verlangen 'superadmin'.
|
||||
-- Wenn noch kein Super-Admin existiert: den ältesten Nutzer mit role = admin hochstufen.
|
||||
|
||||
UPDATE profiles p
|
||||
SET role = 'superadmin', updated_at = NOW()
|
||||
FROM (
|
||||
SELECT id
|
||||
FROM profiles
|
||||
WHERE lower(trim(COALESCE(role, ''))) = 'admin'
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
) sub
|
||||
WHERE p.id = sub.id
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM profiles
|
||||
WHERE lower(trim(COALESCE(role, ''))) = 'superadmin'
|
||||
);
|
||||
|
|
@ -19,6 +19,7 @@ class RegisterRequest(BaseModel):
|
|||
email: EmailStr
|
||||
password: str
|
||||
name: Optional[str] = None
|
||||
requested_club_id: Optional[int] = Field(default=None, ge=1)
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
email: str
|
||||
|
|
@ -34,6 +35,12 @@ class ProfileCreate(BaseModel):
|
|||
class ProfileUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
active_club_id: Optional[int] = None
|
||||
role: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Portal-Rolle: user, trainer, admin, superadmin (nur Plattform-Admin)",
|
||||
)
|
||||
tier: Optional[str] = Field(default=None, max_length=50)
|
||||
|
||||
class ProfileResponse(BaseModel):
|
||||
id: int
|
||||
|
|
|
|||
3
backend/pytest.ini
Normal file
3
backend/pytest.ini
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[pytest]
|
||||
testpaths = tests
|
||||
pythonpath = .
|
||||
3
backend/requirements-dev.txt
Normal file
3
backend/requirements-dev.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Entwicklung / CI — zusätzlich zu backend/requirements.txt installieren:
|
||||
# pip install -r backend/requirements.txt -r backend/requirements-dev.txt
|
||||
pytest>=8.0,<9
|
||||
41
backend/routers/admin_users.py
Normal file
41
backend/routers/admin_users.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""
|
||||
Plattform-Admin: Übersicht aller Nutzer inkl. Vereinsmitgliedschaften (ohne Passwort-Hashes).
|
||||
"""
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from auth import require_auth
|
||||
from club_tenancy import is_platform_admin, memberships_with_roles
|
||||
from db import get_db, get_cursor, r2d
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin_users"])
|
||||
|
||||
_SAFE_PROFILE_COLS = """
|
||||
id, name, email, role, tier, email_verified, active_club_id,
|
||||
created_at, updated_at, auth_type
|
||||
"""
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
def list_platform_users(session: dict = Depends(require_auth)):
|
||||
"""Alle Profile mit Vereinen/Rollen — nur Portal-Admin (admin oder superadmin)."""
|
||||
role = (session.get("role") or "").lower()
|
||||
if not is_platform_admin(role):
|
||||
raise HTTPException(status_code=403, detail="Nur Portal-Administratoren")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT {_SAFE_PROFILE_COLS.strip()}
|
||||
FROM profiles
|
||||
ORDER BY COALESCE(lower(trim(email)), ''), id
|
||||
"""
|
||||
)
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for r in cur.fetchall():
|
||||
d = r2d(r)
|
||||
d["clubs"] = memberships_with_roles(cur, int(d["id"]), active_only=False)
|
||||
rows.append(d)
|
||||
return rows
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
281
backend/routers/club_join_requests.py
Normal file
281
backend/routers/club_join_requests.py
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
"""
|
||||
Anträge auf Vereinsbeitritt: Nutzer stellt Antrag, Vereins-/Plattform-Admin nimmt an oder lehnt ab.
|
||||
"""
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from club_tenancy import can_manage_club_org
|
||||
from db import get_db, get_cursor, r2d
|
||||
from tenant_context import TenantContext, get_tenant_context
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["club_join_requests"])
|
||||
|
||||
_ALLOWED_MEMBER_ROLES = frozenset({"club_admin", "trainer", "division_lead", "content_editor"})
|
||||
|
||||
|
||||
def _normalize_roles(raw: List[str]) -> List[str]:
|
||||
out: List[str] = []
|
||||
seen = set()
|
||||
for r in raw:
|
||||
if not isinstance(r, str):
|
||||
raise HTTPException(status_code=400, detail="Rollen müssen Strings sein")
|
||||
code = r.strip().lower()
|
||||
if not code or code not in _ALLOWED_MEMBER_ROLES:
|
||||
raise HTTPException(status_code=400, detail=f"Unbekannte Rolle: {code}")
|
||||
if code not in seen:
|
||||
seen.add(code)
|
||||
out.append(code)
|
||||
return out
|
||||
|
||||
|
||||
def _club_active(cur, club_id: int) -> bool:
|
||||
cur.execute("SELECT 1 FROM clubs WHERE id = %s AND status = 'active'", (club_id,))
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def _assert_manage_club(cur, tenant: TenantContext, club_id: int) -> None:
|
||||
pid = tenant.profile_id
|
||||
role = tenant.global_role
|
||||
if not can_manage_club_org(cur, pid, club_id, role):
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung für Mitglieder-Verwaltung in diesem Verein")
|
||||
|
||||
|
||||
def _is_active_member(cur, profile_id: int, club_id: int) -> bool:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM club_members
|
||||
WHERE profile_id = %s AND club_id = %s AND status = 'active'
|
||||
LIMIT 1
|
||||
""",
|
||||
(profile_id, club_id),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def _upsert_active_member_with_roles(cur, club_id: int, profile_id: int, roles: List[str]) -> None:
|
||||
roles_n = _normalize_roles(roles)
|
||||
if not roles_n:
|
||||
raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO club_members (profile_id, club_id, status)
|
||||
VALUES (%s, %s, 'active')
|
||||
ON CONFLICT (profile_id, club_id)
|
||||
DO UPDATE SET status = 'active', updated_at = NOW()
|
||||
RETURNING id
|
||||
""",
|
||||
(profile_id, club_id),
|
||||
)
|
||||
cm_id = cur.fetchone()["id"]
|
||||
cur.execute("DELETE FROM club_member_roles WHERE club_member_id = %s", (cm_id,))
|
||||
for rc in roles_n:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO club_member_roles (club_member_id, role_code)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (club_member_id, role_code) DO NOTHING
|
||||
""",
|
||||
(cm_id, rc),
|
||||
)
|
||||
|
||||
|
||||
class JoinRequestCreate(BaseModel):
|
||||
club_id: int = Field(..., ge=1)
|
||||
message: Optional[str] = Field(None, max_length=2000)
|
||||
|
||||
|
||||
class JoinRequestAccept(BaseModel):
|
||||
roles: List[str] = Field(default_factory=lambda: ["trainer"])
|
||||
|
||||
|
||||
def _response_one(cur, req_id: int, viewer_profile_id: int) -> Dict[str, Any]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT r.*, c.name AS club_name, c.abbreviation AS club_abbreviation
|
||||
FROM club_membership_requests r
|
||||
INNER JOIN clubs c ON c.id = r.club_id
|
||||
WHERE r.id = %s AND r.profile_id = %s
|
||||
""",
|
||||
(req_id, viewer_profile_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Antrag nicht gefunden")
|
||||
return r2d(row)
|
||||
|
||||
|
||||
@router.get("/me/club-join-requests")
|
||||
def get_my_join_requests(tenant: TenantContext = Depends(get_tenant_context)):
|
||||
pid = tenant.profile_id
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT r.*, c.name AS club_name, c.abbreviation AS club_abbreviation
|
||||
FROM club_membership_requests r
|
||||
INNER JOIN clubs c ON c.id = r.club_id
|
||||
WHERE r.profile_id = %s
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT 100
|
||||
""",
|
||||
(pid,),
|
||||
)
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
@router.post("/me/club-join-requests", status_code=201)
|
||||
def create_my_join_request(body: JoinRequestCreate, tenant: TenantContext = Depends(get_tenant_context)):
|
||||
"""Antrag stellen (nicht möglich wenn bereits aktives Mitglied)."""
|
||||
pid = tenant.profile_id
|
||||
msg = (body.message or "").strip() or None
|
||||
cid = body.club_id
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
if not _club_active(cur, cid):
|
||||
raise HTTPException(status_code=404, detail="Verein nicht gefunden oder nicht aktiv")
|
||||
|
||||
if _is_active_member(cur, pid, cid):
|
||||
raise HTTPException(status_code=400, detail="Du bist bereits Mitglied in diesem Verein")
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM club_membership_requests
|
||||
WHERE profile_id = %s AND club_id = %s AND status = 'pending'
|
||||
LIMIT 1
|
||||
""",
|
||||
(pid, cid),
|
||||
)
|
||||
if cur.fetchone():
|
||||
raise HTTPException(status_code=409, detail="Für diesen Verein liegt bereits ein offener Antrag vor")
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO club_membership_requests (profile_id, club_id, status, message)
|
||||
VALUES (%s, %s, 'pending', %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(pid, cid, msg),
|
||||
)
|
||||
rid = cur.fetchone()["id"]
|
||||
conn.commit()
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
return _response_one(cur, rid, pid)
|
||||
|
||||
|
||||
@router.delete("/me/club-join-requests/{request_id}")
|
||||
def withdraw_my_join_request(request_id: int, tenant: TenantContext = Depends(get_tenant_context)):
|
||||
pid = tenant.profile_id
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE club_membership_requests
|
||||
SET status = 'withdrawn', updated_at = NOW()
|
||||
WHERE id = %s AND profile_id = %s AND status = 'pending'
|
||||
RETURNING id
|
||||
""",
|
||||
(request_id, pid),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Offener Antrag nicht gefunden")
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/clubs/{club_id}/join-requests")
|
||||
def list_club_join_requests(club_id: int, tenant: TenantContext = Depends(get_tenant_context)):
|
||||
"""Offene Anträge für einen Verein (Vereins-/Plattform-Admin)."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
_assert_manage_club(cur, tenant, club_id)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT r.*, p.email AS applicant_email, p.name AS applicant_name
|
||||
FROM club_membership_requests r
|
||||
INNER JOIN profiles p ON p.id = r.profile_id
|
||||
WHERE r.club_id = %s AND r.status = 'pending'
|
||||
ORDER BY r.created_at ASC
|
||||
""",
|
||||
(club_id,),
|
||||
)
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
@router.post("/clubs/{club_id}/join-requests/{request_id}/accept")
|
||||
def accept_club_join_request(
|
||||
club_id: int,
|
||||
request_id: int,
|
||||
body: JoinRequestAccept,
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
admin_pid = tenant.profile_id
|
||||
roles = _normalize_roles(body.roles)
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
_assert_manage_club(cur, tenant, club_id)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, profile_id, status FROM club_membership_requests
|
||||
WHERE id = %s AND club_id = %s
|
||||
""",
|
||||
(request_id, club_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Antrag nicht gefunden")
|
||||
if row["status"] != "pending":
|
||||
raise HTTPException(status_code=400, detail="Antrag ist nicht mehr offen")
|
||||
|
||||
applicant_id = row["profile_id"]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE club_membership_requests
|
||||
SET status = 'accepted',
|
||||
decided_by_profile_id = %s,
|
||||
decided_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = %s AND club_id = %s AND status = 'pending'
|
||||
RETURNING id
|
||||
""",
|
||||
(admin_pid, request_id, club_id),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=409, detail="Antrag konnte nicht angenommen werden")
|
||||
|
||||
_upsert_active_member_with_roles(cur, club_id, applicant_id, roles)
|
||||
conn.commit()
|
||||
|
||||
return {"ok": True, "profile_id": applicant_id, "club_id": club_id}
|
||||
|
||||
|
||||
@router.post("/clubs/{club_id}/join-requests/{request_id}/reject")
|
||||
def reject_club_join_request(club_id: int, request_id: int, tenant: TenantContext = Depends(get_tenant_context)):
|
||||
admin_pid = tenant.profile_id
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
_assert_manage_club(cur, tenant, club_id)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE club_membership_requests
|
||||
SET status = 'rejected',
|
||||
decided_by_profile_id = %s,
|
||||
decided_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = %s AND club_id = %s AND status = 'pending'
|
||||
RETURNING id
|
||||
""",
|
||||
(admin_pid, request_id, club_id),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Offener Antrag nicht gefunden")
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
268
backend/routers/club_memberships.py
Normal file
268
backend/routers/club_memberships.py
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
"""
|
||||
Vereins-Mitgliedschaften und Rollen (ohne UI nutzbar für Admin/Automatisierung).
|
||||
|
||||
Berechtigung: Plattform-Admin (admin/superadmin) oder Vereinsadmin (club_admin) im Zielverein.
|
||||
"""
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from club_tenancy import can_manage_club_org
|
||||
from db import get_db, get_cursor, r2d
|
||||
from tenant_context import TenantContext, get_tenant_context
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["club_memberships"])
|
||||
|
||||
_ALLOWED_ROLES = frozenset({"club_admin", "trainer", "division_lead", "content_editor"})
|
||||
_ALLOWED_STATUS = frozenset({"active", "inactive"})
|
||||
|
||||
|
||||
def _normalize_roles(raw: List[str]) -> List[str]:
|
||||
out: List[str] = []
|
||||
seen = set()
|
||||
for r in raw:
|
||||
if not isinstance(r, str):
|
||||
raise HTTPException(status_code=400, detail="Rollen müssen Strings sein")
|
||||
code = r.strip().lower()
|
||||
if not code:
|
||||
continue
|
||||
if code not in _ALLOWED_ROLES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unbekannte Rolle: {code}. Erlaubt: {', '.join(sorted(_ALLOWED_ROLES))}",
|
||||
)
|
||||
if code not in seen:
|
||||
seen.add(code)
|
||||
out.append(code)
|
||||
return out
|
||||
|
||||
|
||||
def _assert_manage(cur, tenant: TenantContext, club_id: int) -> None:
|
||||
pid = tenant.profile_id
|
||||
role = tenant.global_role
|
||||
if not can_manage_club_org(cur, pid, club_id, role):
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung zur Mitglieder-Verwaltung in diesem Verein")
|
||||
|
||||
|
||||
def _club_exists(cur, club_id: int) -> bool:
|
||||
cur.execute("SELECT 1 FROM clubs WHERE id = %s", (club_id,))
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
class ClubMemberUpsert(BaseModel):
|
||||
profile_id: int = Field(..., ge=1)
|
||||
roles: List[str] = Field(default_factory=list, description="Mindestens eine Vereinsrolle")
|
||||
|
||||
|
||||
class ClubMemberPatch(BaseModel):
|
||||
roles: Optional[List[str]] = None
|
||||
status: Optional[str] = Field(None, description="active oder inactive")
|
||||
|
||||
|
||||
@router.get("/clubs/{club_id}/members")
|
||||
def list_club_members(
|
||||
club_id: int,
|
||||
include_inactive: bool = Query(False),
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
"""Alle Mitglieder eines Vereins mit Rollen (nur Vereins-/Plattform-Admin)."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
if not _club_exists(cur, club_id):
|
||||
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
|
||||
_assert_manage(cur, tenant, club_id)
|
||||
|
||||
status_clause = "" if include_inactive else "AND cm.status = 'active'"
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT cm.id AS membership_id, cm.profile_id, cm.status, cm.created_at, cm.updated_at,
|
||||
p.email, p.name,
|
||||
COALESCE(
|
||||
ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL),
|
||||
ARRAY[]::varchar[]
|
||||
) AS roles
|
||||
FROM club_members cm
|
||||
INNER JOIN profiles p ON p.id = cm.profile_id
|
||||
LEFT JOIN club_member_roles r ON r.club_member_id = cm.id
|
||||
WHERE cm.club_id = %s {status_clause}
|
||||
GROUP BY cm.id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name
|
||||
ORDER BY p.name NULLS LAST, p.email
|
||||
""",
|
||||
(club_id,),
|
||||
)
|
||||
rows = []
|
||||
for row in cur.fetchall():
|
||||
d = r2d(row)
|
||||
roles = d.get("roles") or []
|
||||
if hasattr(roles, "tolist"):
|
||||
roles = roles.tolist()
|
||||
d["roles"] = list(roles)
|
||||
rows.append(d)
|
||||
return rows
|
||||
|
||||
|
||||
@router.post("/clubs/{club_id}/members", status_code=201)
|
||||
def upsert_club_member(
|
||||
club_id: int,
|
||||
body: ClubMemberUpsert,
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
"""Mitglied anlegen oder aktivieren; Rollen werden vollständig ersetzt."""
|
||||
roles = _normalize_roles(body.roles)
|
||||
if not roles:
|
||||
raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
if not _club_exists(cur, club_id):
|
||||
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
|
||||
_assert_manage(cur, tenant, club_id)
|
||||
|
||||
cur.execute("SELECT id FROM profiles WHERE id = %s", (body.profile_id,))
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO club_members (profile_id, club_id, status)
|
||||
VALUES (%s, %s, 'active')
|
||||
ON CONFLICT (profile_id, club_id)
|
||||
DO UPDATE SET status = 'active', updated_at = NOW()
|
||||
RETURNING id
|
||||
""",
|
||||
(body.profile_id, club_id),
|
||||
)
|
||||
cm_id = cur.fetchone()["id"]
|
||||
|
||||
cur.execute("DELETE FROM club_member_roles WHERE club_member_id = %s", (cm_id,))
|
||||
for rc in roles:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO club_member_roles (club_member_id, role_code)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (club_member_id, role_code) DO NOTHING
|
||||
""",
|
||||
(cm_id, rc),
|
||||
)
|
||||
conn.commit()
|
||||
return _one_member(cur, club_id, body.profile_id)
|
||||
|
||||
|
||||
def _one_member(cur, club_id: int, profile_id: int) -> Dict[str, Any]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT cm.id AS membership_id, cm.profile_id, cm.status, cm.created_at, cm.updated_at,
|
||||
p.email, p.name,
|
||||
COALESCE(
|
||||
ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL),
|
||||
ARRAY[]::varchar[]
|
||||
) AS roles
|
||||
FROM club_members cm
|
||||
INNER JOIN profiles p ON p.id = cm.profile_id
|
||||
LEFT JOIN club_member_roles r ON r.club_member_id = cm.id
|
||||
WHERE cm.club_id = %s AND cm.profile_id = %s
|
||||
GROUP BY cm.id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name
|
||||
""",
|
||||
(club_id, profile_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Mitgliedschaft nicht gefunden")
|
||||
d = r2d(row)
|
||||
roles = d.get("roles") or []
|
||||
if hasattr(roles, "tolist"):
|
||||
roles = roles.tolist()
|
||||
d["roles"] = list(roles)
|
||||
return d
|
||||
|
||||
|
||||
@router.get("/clubs/{club_id}/members/{profile_id}")
|
||||
def get_club_member(
|
||||
club_id: int,
|
||||
profile_id: int,
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
if not _club_exists(cur, club_id):
|
||||
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
|
||||
_assert_manage(cur, tenant, club_id)
|
||||
return _one_member(cur, club_id, profile_id)
|
||||
|
||||
|
||||
@router.put("/clubs/{club_id}/members/{profile_id}")
|
||||
def update_club_member(
|
||||
club_id: int,
|
||||
profile_id: int,
|
||||
body: ClubMemberPatch,
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
"""Rollen ersetzen und/oder Status setzen."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
if not _club_exists(cur, club_id):
|
||||
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
|
||||
_assert_manage(cur, tenant, club_id)
|
||||
|
||||
cur.execute(
|
||||
"SELECT id FROM club_members WHERE club_id = %s AND profile_id = %s",
|
||||
(club_id, profile_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Mitgliedschaft nicht gefunden")
|
||||
cm_id = row["id"]
|
||||
|
||||
if body.roles is None and body.status is None:
|
||||
return _one_member(cur, club_id, profile_id)
|
||||
|
||||
if body.status is not None:
|
||||
st = body.status.strip().lower()
|
||||
if st not in _ALLOWED_STATUS:
|
||||
raise HTTPException(status_code=400, detail="status muss active oder inactive sein")
|
||||
cur.execute(
|
||||
"UPDATE club_members SET status = %s, updated_at = NOW() WHERE id = %s",
|
||||
(st, cm_id),
|
||||
)
|
||||
|
||||
if body.roles is not None:
|
||||
roles = _normalize_roles(body.roles)
|
||||
if not roles:
|
||||
raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben")
|
||||
cur.execute("DELETE FROM club_member_roles WHERE club_member_id = %s", (cm_id,))
|
||||
for rc in roles:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO club_member_roles (club_member_id, role_code)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (club_member_id, role_code) DO NOTHING
|
||||
""",
|
||||
(cm_id, rc),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return _one_member(cur, club_id, profile_id)
|
||||
|
||||
|
||||
@router.delete("/clubs/{club_id}/members/{profile_id}")
|
||||
def delete_club_member(
|
||||
club_id: int,
|
||||
profile_id: int,
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
"""Mitgliedschaft löschen (Rollen per CASCADE)."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
if not _club_exists(cur, club_id):
|
||||
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
|
||||
_assert_manage(cur, tenant, club_id)
|
||||
|
||||
cur.execute(
|
||||
"DELETE FROM club_members WHERE club_id = %s AND profile_id = %s RETURNING id",
|
||||
(club_id, profile_id),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Mitgliedschaft nicht gefunden")
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
76
backend/scripts/check_access_layer_hints.py
Normal file
76
backend/scripts/check_access_layer_hints.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Heuristik-Check: Router mit FastAPI-Routen und Auth sollten bei mandantenrelevanten APIs
|
||||
get_tenant_context nutzen — siehe ACCESS_LAYER_AND_GOVERNANCE_PLAN.md.
|
||||
|
||||
Lauf aus Repo-Root:
|
||||
python backend/scripts/check_access_layer_hints.py
|
||||
|
||||
Mit Fehler-Exit bei Verstößen (z. B. CI):
|
||||
set ACCESS_LAYER_STRICT=1 # Windows: set ACCESS_LAYER_STRICT=1
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Router, die bewusst keinen TenantContext verwenden (global / Auth-only / Admin-Tools).
|
||||
# Neuen Eintrag nur mit kurzer Begründung im Commit/Audit ergänzen.
|
||||
EXEMPT_ROUTERS: frozenset[str] = frozenset(
|
||||
{
|
||||
"auth.py",
|
||||
"admin_users.py",
|
||||
"catalogs.py",
|
||||
"skills.py",
|
||||
"maturity_models.py",
|
||||
"matrix_stack_bundle.py",
|
||||
"import_wiki.py",
|
||||
"import_wiki_admin.py",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
strict = os.environ.get("ACCESS_LAYER_STRICT", "").strip().lower() in ("1", "true", "yes")
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
routers = root / "routers"
|
||||
if not routers.is_dir():
|
||||
print("check_access_layer_hints: routers/ nicht gefunden", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
issues: list[str] = []
|
||||
for path in sorted(routers.glob("*.py")):
|
||||
name = path.name
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if "@router." not in text:
|
||||
continue
|
||||
if name in EXEMPT_ROUTERS:
|
||||
continue
|
||||
if "get_tenant_context" in text:
|
||||
continue
|
||||
if "require_auth" not in text:
|
||||
continue
|
||||
issues.append(
|
||||
f" {path.relative_to(root.parent)}: Routen + require_auth, "
|
||||
f"aber kein get_tenant_context — TenantContext ergänzen oder in EXEMPT_ROUTERS eintragen "
|
||||
f"(mit Begründung / Audit)."
|
||||
)
|
||||
|
||||
if not issues:
|
||||
print("check_access_layer_hints: OK (keine auffälligen Router außerhalb EXEMPT).")
|
||||
return 0
|
||||
|
||||
print("check_access_layer_hints: mögliche ACCESS_LAYER-Abweichungen:\n", file=sys.stderr)
|
||||
for line in issues:
|
||||
print(line, file=sys.stderr)
|
||||
print(
|
||||
"\nHinweis: Heuristik — false positives möglich. "
|
||||
"Bei echter Ausnahme Datei zu EXEMPT_ROUTERS hinzufügen.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1 if strict else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
206
backend/tenant_context.py
Normal file
206
backend/tenant_context.py
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
"""
|
||||
Request-weiter Mandanten-Kontext (ACCESS_LAYER_AND_GOVERNANCE_PLAN.md, Stufe B).
|
||||
|
||||
Zielt auf einheitliche Auflösung aus Session + Header X-Active-Club-Id + Profilfeld active_club_id.
|
||||
Router können Depends(get_tenant_context) nutzen oder resolve_tenant_context mit bereits geladenen Mitgliedschaften (ein DB-Block).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import Depends, Header, HTTPException
|
||||
|
||||
from auth import require_auth
|
||||
from club_tenancy import is_platform_admin, memberships_with_roles
|
||||
from db import get_db, get_cursor
|
||||
|
||||
|
||||
def _club_exists(cur, club_id: int) -> bool:
|
||||
cur.execute("SELECT 1 FROM clubs WHERE id = %s", (club_id,))
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def parse_active_club_header(raw: Optional[str]) -> Optional[int]:
|
||||
"""Parst X-Active-Club-Id; leer → None. Ungültig → HTTP 400."""
|
||||
if raw is None:
|
||||
return None
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
v = int(s)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="X-Active-Club-Id ungültig")
|
||||
if v < 1:
|
||||
raise HTTPException(status_code=400, detail="X-Active-Club-Id ungültig")
|
||||
return v
|
||||
|
||||
|
||||
def library_content_visibility_sql(
|
||||
*,
|
||||
alias: str,
|
||||
profile_id: int,
|
||||
role: str,
|
||||
effective_club_id: Optional[int],
|
||||
) -> tuple[str, List[Any]]:
|
||||
"""
|
||||
WHERE-Baustein für Bibliothekslisten (Übungen, Vorlagen, Rahmenprogramme):
|
||||
official, eigene private, club nur im aktiven Vereinskontext (effective_club_id).
|
||||
Plattform-Admin: keine Einschränkung (TRUE).
|
||||
Ohne effective_club_id: kein club-Zweig (nur official + private).
|
||||
"""
|
||||
if is_platform_admin(role):
|
||||
return "TRUE", []
|
||||
|
||||
parts: List[str] = [
|
||||
f"{alias}.visibility = 'official'",
|
||||
f"({alias}.visibility = 'private' AND {alias}.created_by = %s)",
|
||||
]
|
||||
params: List[Any] = [profile_id]
|
||||
|
||||
if effective_club_id is not None:
|
||||
parts.append(
|
||||
f"""(
|
||||
{alias}.visibility = 'club'
|
||||
AND {alias}.club_id IS NOT NULL
|
||||
AND {alias}.club_id = %s
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM club_members cm
|
||||
WHERE cm.profile_id = %s AND cm.club_id = {alias}.club_id AND cm.status = 'active'
|
||||
)
|
||||
)"""
|
||||
)
|
||||
params.extend([effective_club_id, profile_id])
|
||||
|
||||
return "(" + " OR ".join(parts) + ")", params
|
||||
|
||||
|
||||
@dataclass
|
||||
class TenantContext:
|
||||
profile_id: int
|
||||
global_role: str
|
||||
# Header > gespeichertes Profil > Fallback; Plattform-Admin ohne Header oft None
|
||||
effective_club_id: Optional[int]
|
||||
club_ids: frozenset[int]
|
||||
memberships: List[Dict[str, Any]]
|
||||
|
||||
|
||||
def resolve_tenant_context(
|
||||
cur,
|
||||
*,
|
||||
profile_id: int,
|
||||
global_role: str,
|
||||
header_raw: Optional[str],
|
||||
memberships: Optional[List[Dict[str, Any]]] = None,
|
||||
stored_active_club_id: Optional[int] = None,
|
||||
invalid_header_policy: str = "reject",
|
||||
) -> TenantContext:
|
||||
"""
|
||||
Mitgliedschaften: wenn nicht übergeben, wird aus der DB geladen (aktive Mitgliedschaften).
|
||||
|
||||
Auflösung effective_club_id:
|
||||
- Plattform-Admin: Header setzt beliebigen existierenden Verein; ohne Header → None.
|
||||
- Sonst: gültiger Header zwingend Mitgliedschaft — bei ``reject`` sonst 403, bei ``ignore`` wie ohne Header.
|
||||
Ohne gültigen Header: gespeichertes active_club_id wenn Mitglied; sonst einziger Verein;
|
||||
bei mehreren ohne gültige Vorgabe → min(club_ids) (Fallback).
|
||||
"""
|
||||
role_lc = (global_role or "").lower()
|
||||
header_cid = parse_active_club_header(header_raw)
|
||||
|
||||
if memberships is None:
|
||||
memberships = memberships_with_roles(cur, profile_id, active_only=True)
|
||||
|
||||
club_ids = frozenset(int(r["id"]) for r in memberships if r.get("id") is not None)
|
||||
|
||||
if is_platform_admin(role_lc):
|
||||
if header_cid is not None:
|
||||
if not _club_exists(cur, header_cid):
|
||||
raise HTTPException(status_code=400, detail="Verein nicht gefunden")
|
||||
effective = header_cid
|
||||
else:
|
||||
effective = None
|
||||
return TenantContext(
|
||||
profile_id=profile_id,
|
||||
global_role=role_lc,
|
||||
effective_club_id=effective,
|
||||
club_ids=club_ids,
|
||||
memberships=memberships,
|
||||
)
|
||||
|
||||
chosen_header = header_cid
|
||||
if chosen_header is not None and chosen_header not in club_ids:
|
||||
if invalid_header_policy == "reject":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Keine Mitgliedschaft im gewählten Verein",
|
||||
)
|
||||
chosen_header = None
|
||||
|
||||
if chosen_header is not None:
|
||||
effective = chosen_header
|
||||
elif stored_active_club_id is not None and stored_active_club_id in club_ids:
|
||||
effective = stored_active_club_id
|
||||
elif len(club_ids) == 1:
|
||||
effective = next(iter(club_ids))
|
||||
elif len(club_ids) == 0:
|
||||
effective = None
|
||||
else:
|
||||
effective = min(club_ids)
|
||||
|
||||
return TenantContext(
|
||||
profile_id=profile_id,
|
||||
global_role=role_lc,
|
||||
effective_club_id=effective,
|
||||
club_ids=club_ids,
|
||||
memberships=memberships,
|
||||
)
|
||||
|
||||
|
||||
def get_tenant_context(
|
||||
session: dict = Depends(require_auth),
|
||||
x_active_club_id: Optional[str] = Header(default=None, alias="X-Active-Club-Id"),
|
||||
) -> TenantContext:
|
||||
"""FastAPI-Dependency: öffnet eine DB-Verbindung und liefert TenantContext."""
|
||||
pid = int(session["profile_id"])
|
||||
role = session.get("role") or ""
|
||||
stored: Optional[int] = None
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT active_club_id FROM profiles WHERE id = %s", (pid,))
|
||||
row = cur.fetchone()
|
||||
if row is not None:
|
||||
ac = row.get("active_club_id")
|
||||
if ac is not None:
|
||||
stored = int(ac)
|
||||
return resolve_tenant_context(
|
||||
cur,
|
||||
profile_id=pid,
|
||||
global_role=role,
|
||||
header_raw=x_active_club_id,
|
||||
memberships=None,
|
||||
stored_active_club_id=stored,
|
||||
)
|
||||
|
||||
|
||||
def tenant_context_from_session_only(
|
||||
cur,
|
||||
session: dict,
|
||||
header_raw: Optional[str],
|
||||
*,
|
||||
memberships: Optional[List[Dict[str, Any]]] = None,
|
||||
stored_active_club_id: Optional[int] = None,
|
||||
invalid_header_policy: str = "reject",
|
||||
) -> TenantContext:
|
||||
"""Variante ohne FastAPI (Tests / gemeinsamer Cursor mit anderen Queries)."""
|
||||
pid = int(session["profile_id"])
|
||||
role = session.get("role") or ""
|
||||
return resolve_tenant_context(
|
||||
cur,
|
||||
profile_id=pid,
|
||||
global_role=role,
|
||||
header_raw=header_raw,
|
||||
memberships=memberships,
|
||||
stored_active_club_id=stored_active_club_id,
|
||||
invalid_header_policy=invalid_header_policy,
|
||||
)
|
||||
76
backend/tests/test_access_layer.py
Normal file
76
backend/tests/test_access_layer.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"""Unit tests ohne Datenbank für die Zugriffsschicht (Visibility-SQL, Header-Parsing)."""
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from tenant_context import library_content_visibility_sql, parse_active_club_header
|
||||
|
||||
|
||||
def test_library_visibility_sql_platform_admin_no_filter():
|
||||
sql, params = library_content_visibility_sql(
|
||||
alias="e",
|
||||
profile_id=1,
|
||||
role="admin",
|
||||
effective_club_id=None,
|
||||
)
|
||||
assert sql == "TRUE"
|
||||
assert params == []
|
||||
|
||||
|
||||
def test_library_visibility_sql_superadmin():
|
||||
sql, params = library_content_visibility_sql(
|
||||
alias="fp",
|
||||
profile_id=2,
|
||||
role="superadmin",
|
||||
effective_club_id=100,
|
||||
)
|
||||
assert sql == "TRUE"
|
||||
assert params == []
|
||||
|
||||
|
||||
def test_library_visibility_sql_trainer_without_active_club_no_shared_club_branch():
|
||||
sql, params = library_content_visibility_sql(
|
||||
alias="g",
|
||||
profile_id=42,
|
||||
role="trainer",
|
||||
effective_club_id=None,
|
||||
)
|
||||
assert "official" in sql
|
||||
assert "private" in sql
|
||||
assert "visibility = 'club'" not in sql
|
||||
assert params == [42]
|
||||
|
||||
|
||||
def test_library_visibility_sql_user_with_active_club_includes_club_branch():
|
||||
sql, params = library_content_visibility_sql(
|
||||
alias="t",
|
||||
profile_id=7,
|
||||
role="user",
|
||||
effective_club_id=99,
|
||||
)
|
||||
assert "visibility = 'club'" in sql
|
||||
assert "club_members" in sql
|
||||
assert params[0] == 7 # private branch created_by
|
||||
assert 99 in params
|
||||
assert params.count(7) >= 2 # private + EXISTS membership
|
||||
|
||||
|
||||
def test_parse_active_club_header_none_and_empty():
|
||||
assert parse_active_club_header(None) is None
|
||||
assert parse_active_club_header("") is None
|
||||
assert parse_active_club_header(" ") is None
|
||||
|
||||
|
||||
def test_parse_active_club_header_valid():
|
||||
assert parse_active_club_header("12") == 12
|
||||
|
||||
|
||||
def test_parse_active_club_header_invalid():
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
parse_active_club_header("not-int")
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
def test_parse_active_club_header_non_positive():
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
parse_active_club_header("0")
|
||||
assert exc.value.status_code == 400
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ import AdminHierarchyPage from './pages/AdminHierarchyPage'
|
|||
import AdminMaturityModelsPage from './pages/AdminMaturityModelsPage'
|
||||
import TrainerContextsPage from './pages/TrainerContextsPage'
|
||||
import MediaWikiImportPage from './pages/MediaWikiImportPage'
|
||||
import AdminUsersPage from './pages/AdminUsersPage'
|
||||
import ActiveClubSwitcher from './components/ActiveClubSwitcher'
|
||||
import './app.css'
|
||||
|
||||
// Bottom Navigation (Mobile)
|
||||
|
|
@ -98,8 +100,11 @@ function ProtectedLayout() {
|
|||
<DesktopSidebar isAdmin={isAdmin} user={user} onLogout={handleLogout} />
|
||||
<div className="app-shell">
|
||||
<div className="app-shell__column">
|
||||
<div className="app-header app-header--mobile">
|
||||
<div className="app-logo">🥋 Shinkan</div>
|
||||
<div className="app-header app-header--mobile app-header--mobile-stack">
|
||||
<div className="app-header-mobile__top">
|
||||
<div className="app-logo">🥋 Shinkan</div>
|
||||
</div>
|
||||
<ActiveClubSwitcher variant="mobile" />
|
||||
</div>
|
||||
<div className="app-main">
|
||||
<Outlet />
|
||||
|
|
@ -166,6 +171,7 @@ function AppRoutes() {
|
|||
<Route path="planning/run/:unitId" element={<TrainingUnitRunPage />} />
|
||||
<Route path="planning" element={<TrainingPlanningPage />} />
|
||||
<Route path="admin" element={<Navigate to="/admin/hierarchy" replace />} />
|
||||
<Route path="admin/users" element={<AdminUsersPage />} />
|
||||
<Route path="admin/hierarchy" element={<AdminHierarchyPage />} />
|
||||
<Route path="admin/maturity-models" element={<AdminMaturityModelsPage />} />
|
||||
<Route path="admin/catalogs" element={<AdminCatalogsPage />} />
|
||||
|
|
|
|||
|
|
@ -69,8 +69,42 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
|
|||
padding-right: max(16px, env(safe-area-inset-right, 0px));
|
||||
}
|
||||
}
|
||||
.app-header--mobile-stack {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.app-header-mobile__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: var(--header-h);
|
||||
}
|
||||
.app-logo { font-size: 18px; font-weight: 700; color: var(--accent); letter-spacing: -0.02em; }
|
||||
|
||||
.active-club-switch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.active-club-switch__label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text2);
|
||||
}
|
||||
.active-club-switch__select {
|
||||
width: 100%;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
}
|
||||
.active-club-switch--sidebar {
|
||||
width: 100%;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.active-club-switch--mobile {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* === Seiten-Inhalt: volle Breite der Spalte, kein künstlicher Max-Wert auf großen Screens === */
|
||||
.app-page {
|
||||
width: 100%;
|
||||
|
|
@ -2198,7 +2232,14 @@ a.analysis-split__nav-item {
|
|||
|
||||
.desktop-sidebar__footer {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 16px 12px 0;
|
||||
padding: 16px 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.desktop-sidebar__footer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
|
|
|||
44
frontend/src/components/ActiveClubSwitcher.jsx
Normal file
44
frontend/src/components/ActiveClubSwitcher.jsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
/**
|
||||
* Zeigt einen Vereins-Umschalter, wenn der Nutzer mehreren Vereinen zugeordnet ist.
|
||||
* Steuert den Mandanten-Kontext (Header X-Active-Club-Id + Profilfeld active_club_id).
|
||||
*/
|
||||
export default function ActiveClubSwitcher({ variant = 'sidebar' }) {
|
||||
const { user, setActiveClub } = useAuth()
|
||||
const clubs = user?.clubs || []
|
||||
if (clubs.length <= 1) return null
|
||||
|
||||
const selectClubId =
|
||||
user?.active_club_id != null && clubs.some((c) => c.id === user.active_club_id)
|
||||
? user.active_club_id
|
||||
: clubs[0]?.id
|
||||
|
||||
const isMobile = variant === 'mobile'
|
||||
|
||||
return (
|
||||
<label
|
||||
className={
|
||||
'active-club-switch' +
|
||||
(isMobile ? ' active-club-switch--mobile' : ' active-club-switch--sidebar')
|
||||
}
|
||||
>
|
||||
<span className="active-club-switch__label">Aktiver Verein</span>
|
||||
<select
|
||||
className="form-input active-club-switch__select"
|
||||
aria-label="Aktiven Verein wählen"
|
||||
value={selectClubId ?? ''}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
if (v) setActiveClub(Number(v))
|
||||
}}
|
||||
>
|
||||
{clubs.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name || `Verein #${c.id}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { TreePine, FolderTree, Download, Grid3x3 } from 'lucide-react'
|
||||
import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Admin-Seiten-Navigation (horizontal)
|
||||
|
|
@ -10,6 +10,7 @@ export default function AdminPageNav() {
|
|||
|
||||
const pages = [
|
||||
{ to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine },
|
||||
{ to: '/admin/users', label: 'Nutzer', icon: Users },
|
||||
{ to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
|
||||
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },
|
||||
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { LogOut } from 'lucide-react'
|
||||
import { getMainNavItems } from '../config/appNav'
|
||||
import ActiveClubSwitcher from './ActiveClubSwitcher'
|
||||
|
||||
function sidebarLinkActive(pathname, item, routerIsActive) {
|
||||
if (item.to.startsWith('/admin')) return pathname.startsWith('/admin')
|
||||
|
|
@ -46,40 +47,43 @@ export default function DesktopSidebar({
|
|||
</nav>
|
||||
|
||||
<div className="desktop-sidebar__footer">
|
||||
<div className="desktop-sidebar__user">
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--accent)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
fontSize: '14px'
|
||||
}}
|
||||
<ActiveClubSwitcher variant="sidebar" />
|
||||
<div className="desktop-sidebar__footer-row">
|
||||
<div className="desktop-sidebar__user">
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--accent)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
{(user?.name || user?.email || '?').trim().slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div className="desktop-sidebar__user-text">
|
||||
<span className="desktop-sidebar__user-name">
|
||||
{user?.name || user?.email || 'User'}
|
||||
</span>
|
||||
{tier ? (
|
||||
<span className="desktop-sidebar__user-tier">{tier}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="desktop-sidebar__logout"
|
||||
onClick={onLogout}
|
||||
title="Abmelden"
|
||||
>
|
||||
{(user?.name || user?.email || '?').trim().slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div className="desktop-sidebar__user-text">
|
||||
<span className="desktop-sidebar__user-name">
|
||||
{user?.name || user?.email || 'User'}
|
||||
</span>
|
||||
{tier ? (
|
||||
<span className="desktop-sidebar__user-tier">{tier}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="desktop-sidebar__logout"
|
||||
onClick={onLogout}
|
||||
title="Abmelden"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,13 @@ import { useAuth } from '../context/AuthContext'
|
|||
function Navigation() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuth()
|
||||
const { user, logout, setActiveClub } = useAuth()
|
||||
|
||||
const clubs = user?.clubs || []
|
||||
const selectClubId =
|
||||
user?.active_club_id != null && clubs.some((c) => c.id === user.active_club_id)
|
||||
? user.active_club_id
|
||||
: clubs[0]?.id
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
|
|
@ -84,6 +90,25 @@ function Navigation() {
|
|||
Profil
|
||||
</Link>
|
||||
|
||||
{(clubs?.length ?? 0) > 1 && (
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', fontSize: '0.8125rem', color: 'var(--text2)' }}>
|
||||
<span>Verein</span>
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ padding: '0.35rem 0.5rem', minWidth: '9rem', fontSize: '0.8125rem' }}
|
||||
value={selectClubId ?? ''}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
if (v) setActiveClub(Number(v))
|
||||
}}
|
||||
>
|
||||
{clubs.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* User Menu */}
|
||||
<div style={{
|
||||
borderLeft: '1px solid var(--border)',
|
||||
|
|
|
|||
|
|
@ -1,11 +1,40 @@
|
|||
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||
import api from '../utils/api'
|
||||
import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'
|
||||
import api, { ACTIVE_CLUB_STORAGE_KEY } from '../utils/api'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
function syncStoredActiveClub(profile) {
|
||||
const clubs = profile?.clubs || []
|
||||
const ids = new Set(clubs.map((c) => String(c.id)))
|
||||
const eff = profile?.effective_club_id
|
||||
if (eff != null && eff !== '' && ids.has(String(eff))) {
|
||||
localStorage.setItem(ACTIVE_CLUB_STORAGE_KEY, String(eff))
|
||||
return
|
||||
}
|
||||
const stored = localStorage.getItem(ACTIVE_CLUB_STORAGE_KEY)
|
||||
if (stored && ids.has(stored)) return
|
||||
|
||||
const ac =
|
||||
profile?.active_club_id != null && profile.active_club_id !== ''
|
||||
? String(profile.active_club_id)
|
||||
: ''
|
||||
if (ac && ids.has(ac)) {
|
||||
localStorage.setItem(ACTIVE_CLUB_STORAGE_KEY, ac)
|
||||
return
|
||||
}
|
||||
if (clubs.length >= 1) {
|
||||
localStorage.setItem(ACTIVE_CLUB_STORAGE_KEY, String(clubs[0].id))
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const userRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
userRef.current = user
|
||||
}, [user])
|
||||
|
||||
const checkAuth = useCallback(async () => {
|
||||
const token = localStorage.getItem('authToken')
|
||||
|
|
@ -16,6 +45,7 @@ export function AuthProvider({ children }) {
|
|||
|
||||
try {
|
||||
const profile = await api.getCurrentProfile()
|
||||
syncStoredActiveClub(profile)
|
||||
setUser(profile)
|
||||
} catch (err) {
|
||||
console.error('Auth check failed:', err)
|
||||
|
|
@ -29,9 +59,23 @@ export function AuthProvider({ children }) {
|
|||
checkAuth()
|
||||
}, [checkAuth])
|
||||
|
||||
const setActiveClub = useCallback(async (clubId) => {
|
||||
const cid = Number(clubId)
|
||||
const uid = userRef.current?.id
|
||||
if (!Number.isFinite(cid) || cid < 1 || !uid) return
|
||||
localStorage.setItem(ACTIVE_CLUB_STORAGE_KEY, String(cid))
|
||||
setUser((prev) => (prev?.id ? { ...prev, active_club_id: cid } : prev))
|
||||
try {
|
||||
await api.updateProfile(uid, { active_club_id: cid })
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** Fallback, falls ohne checkAuth gesetzt wird (Legacy / Token-Injektion) */
|
||||
const login = (payload) => {
|
||||
if (payload?.profile != null) {
|
||||
syncStoredActiveClub(payload.profile)
|
||||
setUser(payload.profile)
|
||||
return
|
||||
}
|
||||
|
|
@ -52,6 +96,7 @@ export function AuthProvider({ children }) {
|
|||
const logout = () => {
|
||||
setUser(null)
|
||||
localStorage.removeItem('authToken')
|
||||
localStorage.removeItem(ACTIVE_CLUB_STORAGE_KEY)
|
||||
}
|
||||
|
||||
const value = {
|
||||
|
|
@ -60,7 +105,8 @@ export function AuthProvider({ children }) {
|
|||
loading,
|
||||
login,
|
||||
logout,
|
||||
checkAuth
|
||||
checkAuth,
|
||||
setActiveClub,
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ function AccountSettingsPage() {
|
|||
const [name, setName] = useState('')
|
||||
const [savingProfile, setSavingProfile] = useState(false)
|
||||
|
||||
const [publicClubsDir, setPublicClubsDir] = useState([])
|
||||
const [myJoinRequests, setMyJoinRequests] = useState([])
|
||||
const [joinClubId, setJoinClubId] = useState('')
|
||||
const [joinMessage, setJoinMessage] = useState('')
|
||||
const [joinBusy, setJoinBusy] = useState(false)
|
||||
|
||||
const [newPw1, setNewPw1] = useState('')
|
||||
const [newPw2, setNewPw2] = useState('')
|
||||
const [savingPw, setSavingPw] = useState(false)
|
||||
|
|
@ -22,6 +28,32 @@ function AccountSettingsPage() {
|
|||
setName(typeof user?.name === 'string' ? user.name : '')
|
||||
}, [user])
|
||||
|
||||
const refreshJoinRequests = () => {
|
||||
api.getMyClubJoinRequests().then(setMyJoinRequests).catch(() => {})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.id) return
|
||||
api.listPublicClubsDirectory().then(setPublicClubsDir).catch(() => {})
|
||||
refreshJoinRequests()
|
||||
}, [user?.id])
|
||||
|
||||
const memberClubIds = new Set((user?.clubs || []).map((c) => c.id))
|
||||
const pendingClubIds = new Set(
|
||||
myJoinRequests.filter((r) => r.status === 'pending').map((r) => r.club_id)
|
||||
)
|
||||
const joinClubChoices = publicClubsDir.filter(
|
||||
(c) => !memberClubIds.has(c.id) && !pendingClubIds.has(c.id)
|
||||
)
|
||||
|
||||
const joinStatusLabel = (s) =>
|
||||
({
|
||||
pending: 'ausstehend',
|
||||
accepted: 'angenommen',
|
||||
rejected: 'abgelehnt',
|
||||
withdrawn: 'zurückgezogen',
|
||||
})[s] || s
|
||||
|
||||
/** API: boolean true / Legacy: fehlt oder false → als „nicht verifiziert“ behandeln */
|
||||
const emailExplicitlyVerified =
|
||||
user?.email_verified === true ||
|
||||
|
|
@ -214,9 +246,133 @@ function AccountSettingsPage() {
|
|||
<span style={{ textTransform: 'uppercase', letterSpacing: '0.03em', fontWeight: 600 }}>
|
||||
{user?.tier || 'free'}
|
||||
</span>
|
||||
|
||||
<strong style={{ color: 'var(--text2)' }}>Vereine</strong>
|
||||
<span style={{ lineHeight: 1.45 }}>
|
||||
{user?.clubs?.length ? (
|
||||
<>
|
||||
{user.clubs.map((c) => (
|
||||
<div key={c.id}>
|
||||
<strong style={{ color: 'var(--text1)' }}>{c.name}</strong>
|
||||
{': '}
|
||||
{(c.roles || []).length ? (c.roles || []).join(', ') : '—'}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: '1rem' }}>
|
||||
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Vereinsbeitritt</h2>
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||
Beantrage die Mitgliedschaft in einem Verein. Vereinsadministratoren können den Antrag unter
|
||||
„Vereinsverwaltung → Mitglieder“ annehmen oder ablehnen.
|
||||
</p>
|
||||
|
||||
{myJoinRequests.length > 0 && (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<strong style={{ fontSize: '0.9rem' }}>Meine Anträge</strong>
|
||||
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.25rem', color: 'var(--text2)', fontSize: '0.9rem' }}>
|
||||
{myJoinRequests.map((r) => (
|
||||
<li key={r.id} style={{ marginBottom: '0.35rem' }}>
|
||||
{r.club_name || `Verein #${r.club_id}`} — {joinStatusLabel(r.status)}
|
||||
{r.status === 'pending' ? (
|
||||
<>
|
||||
{' '}
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
marginLeft: '0.35rem',
|
||||
fontSize: '0.75rem',
|
||||
padding: '0.15rem 0.45rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface2)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={async () => {
|
||||
if (!confirm('Antrag wirklich zurückziehen?')) return
|
||||
try {
|
||||
await api.withdrawClubJoinRequest(r.id)
|
||||
refreshJoinRequests()
|
||||
} catch (err) {
|
||||
showErr(err.message || 'Zurückziehen fehlgeschlagen.')
|
||||
}
|
||||
}}
|
||||
>
|
||||
zurückziehen
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault()
|
||||
if (!joinClubId) {
|
||||
showErr('Bitte einen Verein auswählen.')
|
||||
return
|
||||
}
|
||||
setJoinBusy(true)
|
||||
try {
|
||||
await api.createClubJoinRequest({
|
||||
club_id: parseInt(joinClubId, 10),
|
||||
message: (joinMessage || '').trim() || undefined,
|
||||
})
|
||||
setJoinMessage('')
|
||||
setJoinClubId('')
|
||||
refreshJoinRequests()
|
||||
await checkAuth()
|
||||
showOk('Antrag gesendet.')
|
||||
} catch (err) {
|
||||
showErr(err.message || 'Antrag fehlgeschlagen.')
|
||||
} finally {
|
||||
setJoinBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label className="form-label" htmlFor="join-club-select">
|
||||
Verein auswählen
|
||||
</label>
|
||||
<select
|
||||
id="join-club-select"
|
||||
className="form-input"
|
||||
value={joinClubId}
|
||||
onChange={(e) => setJoinClubId(e.target.value)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{joinClubChoices.map((c) => (
|
||||
<option key={c.id} value={String(c.id)}>
|
||||
{c.name}
|
||||
{c.abbreviation ? ` (${c.abbreviation})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="form-label" htmlFor="join-msg" style={{ marginTop: '0.75rem' }}>
|
||||
Nachricht (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="join-msg"
|
||||
className="form-input"
|
||||
rows={2}
|
||||
value={joinMessage}
|
||||
onChange={(e) => setJoinMessage(e.target.value)}
|
||||
placeholder="z. B. Trainingsgruppe oder Kontakt zum Verein"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary" disabled={joinBusy} style={{ marginTop: '0.85rem' }}>
|
||||
{joinBusy ? 'Senden…' : 'Beitritt beantragen'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Passwort ändern</h2>
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||
|
|
|
|||
448
frontend/src/pages/AdminUsersPage.jsx
Normal file
448
frontend/src/pages/AdminUsersPage.jsx
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../utils/api'
|
||||
import AdminPageNav from '../components/AdminPageNav'
|
||||
|
||||
const CLUB_ROLE_OPTIONS = [
|
||||
{ code: 'club_admin', label: 'Vereinsadmin' },
|
||||
{ code: 'trainer', label: 'Trainer' },
|
||||
{ code: 'division_lead', label: 'Spartenleitung' },
|
||||
{ code: 'content_editor', label: 'Inhalte bearbeiten' },
|
||||
]
|
||||
|
||||
const TIER_OPTIONS = ['free', 'premium', 'pro', 'enterprise']
|
||||
|
||||
const ROLE_LABEL = {
|
||||
user: 'Nutzer',
|
||||
trainer: 'Trainer',
|
||||
admin: 'Portal-Admin',
|
||||
superadmin: 'Super-Admin',
|
||||
}
|
||||
|
||||
function AdminUsersPage() {
|
||||
const { user } = useAuth()
|
||||
const isSuper = user?.role === 'superadmin'
|
||||
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
||||
const portalRoleChoices = isSuper
|
||||
? ['user', 'trainer', 'admin', 'superadmin']
|
||||
: ['user', 'trainer', 'admin']
|
||||
|
||||
const [users, setUsers] = useState([])
|
||||
const [clubs, setClubs] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [portalDraft, setPortalDraft] = useState({})
|
||||
const [assignModal, setAssignModal] = useState(null)
|
||||
const [assignRoles, setAssignRoles] = useState(['trainer'])
|
||||
const [clubEditModal, setClubEditModal] = useState(null)
|
||||
|
||||
const load = async () => {
|
||||
setError('')
|
||||
try {
|
||||
const [u, c] = await Promise.all([api.listAdminUsers(), api.listClubs()])
|
||||
setUsers(u)
|
||||
setClubs(c)
|
||||
const d = {}
|
||||
for (const row of u) {
|
||||
d[row.id] = {
|
||||
role: (row.role || 'user').toLowerCase(),
|
||||
tier: row.tier || 'free',
|
||||
}
|
||||
}
|
||||
setPortalDraft(d)
|
||||
} catch (e) {
|
||||
setError(e.message || String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlatformAdmin) return
|
||||
load()
|
||||
}, [isPlatformAdmin])
|
||||
|
||||
if (!isPlatformAdmin) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
const savePortal = async (profileId) => {
|
||||
const dr = portalDraft[profileId]
|
||||
if (!dr) return
|
||||
try {
|
||||
await api.updateProfile(profileId, { role: dr.role, tier: dr.tier })
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || String(e))
|
||||
}
|
||||
}
|
||||
|
||||
const submitAssignClub = async () => {
|
||||
if (!assignModal) return
|
||||
const clubId = assignModal.clubId
|
||||
const profileId = assignModal.profileId
|
||||
if (!clubId || !assignRoles.length) {
|
||||
alert('Verein und mindestens eine Rolle wählen.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.addClubMember(clubId, { profile_id: profileId, roles: assignRoles })
|
||||
setAssignModal(null)
|
||||
setAssignRoles(['trainer'])
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || String(e))
|
||||
}
|
||||
}
|
||||
|
||||
const saveClubMembership = async () => {
|
||||
if (!clubEditModal) return
|
||||
const { clubId, profileId, roles, status } = clubEditModal
|
||||
try {
|
||||
await api.updateClubMember(clubId, profileId, { roles, status })
|
||||
setClubEditModal(null)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || String(e))
|
||||
}
|
||||
}
|
||||
|
||||
const removeClubMembership = async () => {
|
||||
if (!clubEditModal) return
|
||||
if (!confirm('Mitgliedschaft in diesem Verein wirklich entfernen?')) return
|
||||
try {
|
||||
await api.removeClubMember(clubEditModal.clubId, clubEditModal.profileId)
|
||||
setClubEditModal(null)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || String(e))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-page">
|
||||
<AdminPageNav />
|
||||
<h1 style={{ marginTop: 0 }}>Portal-Nutzer & Vereine</h1>
|
||||
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '1.25rem' }}>
|
||||
Alle Konten mit Vereinszuordnungen. Hier kannst du die <strong>Portal-Rolle</strong> (Zugriff auf
|
||||
Admin-Funktionen) und das <strong>Tier</strong> setzen sowie Nutzer explizit einem Verein mit Rollen
|
||||
zuordnen.
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<p style={{ color: 'var(--text2)' }}>Laden…</p>
|
||||
) : error ? (
|
||||
<div className="card" style={{ borderColor: 'var(--danger)', color: 'var(--danger)' }}>
|
||||
{error}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||
{users.map((row) => {
|
||||
const tierValue = portalDraft[row.id]?.tier ?? row.tier ?? 'free'
|
||||
const tierChoices = [...TIER_OPTIONS]
|
||||
if (tierValue && !tierChoices.includes(tierValue)) tierChoices.unshift(tierValue)
|
||||
return (
|
||||
<div key={row.id} className="card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||
<div>
|
||||
<strong style={{ fontSize: '1.05rem' }}>
|
||||
{row.name || '—'} <span style={{ color: 'var(--text2)', fontWeight: 400 }}>#{row.id}</span>
|
||||
</strong>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text2)' }}>{row.email || '—'}</div>
|
||||
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginTop: '0.35rem' }}>
|
||||
Verifiziert: {row.email_verified ? 'ja' : 'nein'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'flex-end' }}>
|
||||
<div>
|
||||
<label className="form-label" style={{ fontSize: '0.75rem' }}>
|
||||
Portal-Rolle
|
||||
</label>
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ minWidth: '140px' }}
|
||||
value={(portalDraft[row.id]?.role || row.role || 'user').toLowerCase()}
|
||||
onChange={(e) =>
|
||||
setPortalDraft((prev) => ({
|
||||
...prev,
|
||||
[row.id]: { ...prev[row.id], role: e.target.value, tier: prev[row.id]?.tier ?? row.tier },
|
||||
}))
|
||||
}
|
||||
>
|
||||
{portalRoleChoices.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{ROLE_LABEL[r] || r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label" style={{ fontSize: '0.75rem' }}>
|
||||
Tier
|
||||
</label>
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ minWidth: '120px' }}
|
||||
value={tierValue}
|
||||
onChange={(e) =>
|
||||
setPortalDraft((prev) => ({
|
||||
...prev,
|
||||
[row.id]: {
|
||||
...prev[row.id],
|
||||
tier: e.target.value,
|
||||
role: prev[row.id]?.role ?? row.role,
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
{tierChoices.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => savePortal(row.id)}>
|
||||
Portal speichern
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!clubs.length}
|
||||
title={!clubs.length ? 'Zuerst einen Verein anlegen' : undefined}
|
||||
onClick={() => {
|
||||
if (!clubs.length) return
|
||||
setAssignRoles(['trainer'])
|
||||
setAssignModal({
|
||||
profileId: row.id,
|
||||
profileLabel: row.name || row.email || `#${row.id}`,
|
||||
clubId: clubs[0]?.id ?? '',
|
||||
})
|
||||
}}
|
||||
>
|
||||
Verein zuweisen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '1rem', paddingTop: '0.75rem', borderTop: '1px solid var(--border)' }}>
|
||||
<strong style={{ fontSize: '0.85rem' }}>Vereinsmitgliedschaften</strong>
|
||||
{!row.clubs?.length ? (
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', margin: '0.35rem 0 0' }}>
|
||||
Keine Zuordnung.
|
||||
</p>
|
||||
) : (
|
||||
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem', fontSize: '0.9rem' }}>
|
||||
{row.clubs.map((c) => (
|
||||
<li key={c.id} style={{ marginBottom: '0.35rem' }}>
|
||||
<strong>{c.name}</strong>
|
||||
{c.abbreviation ? ` (${c.abbreviation})` : ''} —{' '}
|
||||
{(c.roles || []).join(', ') || '—'}
|
||||
{c.membership_status === 'inactive' ? (
|
||||
<span style={{ color: 'var(--text3)', fontSize: '0.8rem' }}> (inaktiv)</span>
|
||||
) : null}{' '}
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
marginLeft: '0.35rem',
|
||||
fontSize: '0.75rem',
|
||||
padding: '0.12rem 0.45rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface2)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() =>
|
||||
setClubEditModal({
|
||||
clubId: c.id,
|
||||
clubName: c.name,
|
||||
profileId: row.id,
|
||||
profileLabel: row.name || row.email,
|
||||
roles: [...(c.roles || [])],
|
||||
status: (c.membership_status || 'active').toLowerCase(),
|
||||
})
|
||||
}
|
||||
>
|
||||
bearbeiten
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{assignModal && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1200,
|
||||
padding: '1rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
borderRadius: '12px',
|
||||
padding: '1.5rem',
|
||||
maxWidth: '440px',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginTop: 0 }}>Verein zuweisen</h2>
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{assignModal.profileLabel}</p>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Verein</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={assignModal.clubId === '' ? '' : String(assignModal.clubId)}
|
||||
onChange={(e) =>
|
||||
setAssignModal((prev) =>
|
||||
prev ? { ...prev, clubId: e.target.value ? parseInt(e.target.value, 10) : '' } : prev
|
||||
)
|
||||
}
|
||||
>
|
||||
{clubs.map((c) => (
|
||||
<option key={c.id} value={String(c.id)}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<span className="form-label">Rollen im Verein</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
{CLUB_ROLE_OPTIONS.map((opt) => (
|
||||
<label key={opt.code} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={assignRoles.includes(opt.code)}
|
||||
onChange={() => {
|
||||
setAssignRoles((prev) => {
|
||||
const s = new Set(prev)
|
||||
if (s.has(opt.code)) s.delete(opt.code)
|
||||
else s.add(opt.code)
|
||||
const out = Array.from(s)
|
||||
return out.length ? out : ['trainer']
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
|
||||
<button type="button" className="btn btn-primary" style={{ flex: 1 }} onClick={submitAssignClub}>
|
||||
Zuweisen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setAssignModal(null)}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{clubEditModal && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1200,
|
||||
padding: '1rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
borderRadius: '12px',
|
||||
padding: '1.5rem',
|
||||
maxWidth: '440px',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginTop: 0 }}>Vereinsmitgliedschaft</h2>
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>
|
||||
{clubEditModal.profileLabel} → {clubEditModal.clubName}
|
||||
</p>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Status</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={clubEditModal.status}
|
||||
onChange={(e) =>
|
||||
setClubEditModal((prev) => (prev ? { ...prev, status: e.target.value } : prev))
|
||||
}
|
||||
>
|
||||
<option value="active">aktiv</option>
|
||||
<option value="inactive">inaktiv</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<span className="form-label">Rollen</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
{CLUB_ROLE_OPTIONS.map((opt) => (
|
||||
<label key={opt.code} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={clubEditModal.roles.includes(opt.code)}
|
||||
onChange={() => {
|
||||
setClubEditModal((prev) => {
|
||||
if (!prev) return prev
|
||||
const s = new Set(prev.roles)
|
||||
if (s.has(opt.code)) s.delete(opt.code)
|
||||
else s.add(opt.code)
|
||||
let roles = Array.from(s)
|
||||
if (!roles.length) roles = ['trainer']
|
||||
return { ...prev, roles }
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '1rem' }}>
|
||||
<button type="button" className="btn btn-primary" onClick={saveClubMembership}>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ background: 'var(--danger)', color: '#fff', border: 'none' }}
|
||||
onClick={removeClubMembership}
|
||||
>
|
||||
Aus Verein entfernen
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setClubEditModal(null)}>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminUsersPage
|
||||
|
|
@ -2,6 +2,13 @@ import React, { useState, useEffect } from 'react'
|
|||
import api from '../utils/api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
const CLUB_ROLE_OPTIONS = [
|
||||
{ code: 'club_admin', label: 'Vereinsadmin' },
|
||||
{ code: 'trainer', label: 'Trainer' },
|
||||
{ code: 'division_lead', label: 'Spartenleitung' },
|
||||
{ code: 'content_editor', label: 'Inhalte bearbeiten' },
|
||||
]
|
||||
|
||||
function ClubsPage() {
|
||||
const { user } = useAuth()
|
||||
const [activeTab, setActiveTab] = useState('clubs')
|
||||
|
|
@ -13,13 +20,42 @@ function ClubsPage() {
|
|||
const [editing, setEditing] = useState(null)
|
||||
const [modalType, setModalType] = useState('club')
|
||||
|
||||
const [membersAdminClubId, setMembersAdminClubId] = useState(null)
|
||||
const [clubMembersAdmin, setClubMembersAdmin] = useState([])
|
||||
const [joinRequestsAdmin, setJoinRequestsAdmin] = useState([])
|
||||
const [membersAdminLoading, setMembersAdminLoading] = useState(false)
|
||||
const [groupMemberDirectory, setGroupMemberDirectory] = useState([])
|
||||
|
||||
const [showAddMemberModal, setShowAddMemberModal] = useState(false)
|
||||
const [profilesAdminList, setProfilesAdminList] = useState([])
|
||||
const [addMemberForm, setAddMemberForm] = useState({ profile_id: '', roles: ['trainer'] })
|
||||
|
||||
const [editMemberModal, setEditMemberModal] = useState(null)
|
||||
const [acceptJoinModal, setAcceptJoinModal] = useState(null)
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({})
|
||||
|
||||
const isAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
||||
const isTrainer = user?.role === 'trainer' || isAdmin
|
||||
const canCreateClub = ['admin', 'superadmin', 'trainer', 'user'].includes(user?.role)
|
||||
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
||||
const isSuperAdmin = user?.role === 'superadmin'
|
||||
const clubAdminClubIds = new Set(
|
||||
(user?.clubs || [])
|
||||
.filter((c) => (c.roles || []).includes('club_admin'))
|
||||
.map((c) => c.id)
|
||||
)
|
||||
const canManageClub = (clubId) => isPlatformAdmin || clubAdminClubIds.has(clubId)
|
||||
const canCreateClub = isPlatformAdmin
|
||||
const canManageOrgSomewhere = isPlatformAdmin || clubAdminClubIds.size > 0
|
||||
const canCreateTrainingGroup =
|
||||
isPlatformAdmin || (Array.isArray(user?.clubs) && user.clubs.length > 0)
|
||||
|
||||
const canEditGroup = (g) =>
|
||||
isPlatformAdmin ||
|
||||
clubAdminClubIds.has(g.club_id) ||
|
||||
g.trainer_id === user?.id ||
|
||||
(Array.isArray(g.co_trainer_ids) && g.co_trainer_ids.includes(user?.id))
|
||||
|
||||
const canDeleteGroup = (g) => isPlatformAdmin || clubAdminClubIds.has(g.club_id)
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
|
@ -42,12 +78,82 @@ function ClubsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!clubs.length) {
|
||||
setMembersAdminClubId(null)
|
||||
return
|
||||
}
|
||||
const mcl = clubs.filter((c) => isPlatformAdmin || clubAdminClubIds.has(c.id))
|
||||
if (!mcl.length) {
|
||||
setMembersAdminClubId(null)
|
||||
return
|
||||
}
|
||||
setMembersAdminClubId((prev) =>
|
||||
prev != null && mcl.some((x) => x.id === prev) ? prev : mcl[0].id
|
||||
)
|
||||
}, [clubs, isPlatformAdmin, user?.clubs])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'members' || !membersAdminClubId || !canManageClub(membersAdminClubId)) return
|
||||
let cancelled = false
|
||||
setMembersAdminLoading(true)
|
||||
Promise.all([
|
||||
api.listClubMembers(membersAdminClubId, { includeInactive: true }),
|
||||
api.listClubJoinRequests(membersAdminClubId),
|
||||
])
|
||||
.then(([m, j]) => {
|
||||
if (!cancelled) {
|
||||
setClubMembersAdmin(m)
|
||||
setJoinRequestsAdmin(j)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) alert('Mitglieder/Anträge: ' + err.message)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setMembersAdminLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [activeTab, membersAdminClubId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showModal || modalType !== 'group' || !formData.club_id) {
|
||||
setGroupMemberDirectory([])
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
api
|
||||
.clubMembersDirectory(formData.club_id)
|
||||
.then((rows) => {
|
||||
if (!cancelled) setGroupMemberDirectory(rows)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setGroupMemberDirectory([])
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [showModal, modalType, formData.club_id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAddMemberModal || !isPlatformAdmin) return
|
||||
api.listProfiles().then(setProfilesAdminList).catch(() => setProfilesAdminList([]))
|
||||
}, [showAddMemberModal, isPlatformAdmin])
|
||||
|
||||
const handleCreate = (type) => {
|
||||
setEditing(null)
|
||||
setModalType(type)
|
||||
|
||||
if (type === 'club') {
|
||||
setFormData({ name: '', abbreviation: '', description: '', status: 'active' })
|
||||
setFormData({
|
||||
name: '',
|
||||
abbreviation: '',
|
||||
description: '',
|
||||
status: 'active',
|
||||
primary_admin_profile_id: user?.id ?? '',
|
||||
})
|
||||
} else if (type === 'division') {
|
||||
setFormData({ club_id: '', name: '', focus_area: '' })
|
||||
} else if (type === 'group') {
|
||||
|
|
@ -71,10 +177,34 @@ function ClubsPage() {
|
|||
setShowModal(true)
|
||||
}
|
||||
|
||||
const manageableClubs = clubs.filter((c) => canManageClub(c.id))
|
||||
|
||||
const reloadMembersAdmin = async () => {
|
||||
if (!membersAdminClubId || !canManageClub(membersAdminClubId)) return
|
||||
try {
|
||||
const [m, j] = await Promise.all([
|
||||
api.listClubMembers(membersAdminClubId, { includeInactive: true }),
|
||||
api.listClubJoinRequests(membersAdminClubId),
|
||||
])
|
||||
setClubMembersAdmin(m)
|
||||
setJoinRequestsAdmin(j)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (item, type) => {
|
||||
setEditing(item)
|
||||
setModalType(type)
|
||||
setFormData({ ...item })
|
||||
if (type === 'group') {
|
||||
const co = item.co_trainer_ids
|
||||
setFormData({
|
||||
...item,
|
||||
co_trainer_ids: Array.isArray(co) ? co : [],
|
||||
})
|
||||
} else {
|
||||
setFormData({ ...item })
|
||||
}
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
|
|
@ -102,11 +232,19 @@ function ClubsPage() {
|
|||
e.preventDefault()
|
||||
|
||||
try {
|
||||
if (modalType === 'club') {
|
||||
if (modalType === 'club') {
|
||||
if (editing) {
|
||||
await api.updateClub(editing.id, formData)
|
||||
} else {
|
||||
await api.createClub(formData)
|
||||
const payload = {
|
||||
...formData,
|
||||
primary_admin_profile_id: Number(formData.primary_admin_profile_id),
|
||||
}
|
||||
if (!payload.primary_admin_profile_id) {
|
||||
alert('Hauptverwalter (Profil-ID) ist Pflicht.')
|
||||
return
|
||||
}
|
||||
await api.createClub(payload)
|
||||
}
|
||||
} else if (modalType === 'division') {
|
||||
if (editing) {
|
||||
|
|
@ -115,10 +253,24 @@ function ClubsPage() {
|
|||
await api.createDivision(formData)
|
||||
}
|
||||
} else if (modalType === 'group') {
|
||||
const trainerRaw = formData.trainer_id
|
||||
const trainer_id =
|
||||
trainerRaw === '' || trainerRaw === undefined || trainerRaw === null
|
||||
? null
|
||||
: Number(trainerRaw)
|
||||
const coRaw = formData.co_trainer_ids
|
||||
const co_trainer_ids = Array.isArray(coRaw)
|
||||
? coRaw.map((x) => Number(x)).filter((n) => Number.isFinite(n))
|
||||
: []
|
||||
const payload = {
|
||||
...formData,
|
||||
trainer_id: Number.isFinite(trainer_id) ? trainer_id : null,
|
||||
co_trainer_ids,
|
||||
}
|
||||
if (editing) {
|
||||
await api.updateTrainingGroup(editing.id, formData)
|
||||
await api.updateTrainingGroup(editing.id, payload)
|
||||
} else {
|
||||
await api.createTrainingGroup(formData)
|
||||
await api.createTrainingGroup(payload)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -157,7 +309,10 @@ function ClubsPage() {
|
|||
marginBottom: '1.5rem',
|
||||
borderBottom: '2px solid var(--border)'
|
||||
}}>
|
||||
{['clubs', 'divisions', 'groups'].map(tab => (
|
||||
{(canManageOrgSomewhere
|
||||
? ['clubs', 'divisions', 'groups', 'members']
|
||||
: ['clubs', 'divisions', 'groups']
|
||||
).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
|
|
@ -174,6 +329,7 @@ function ClubsPage() {
|
|||
{tab === 'clubs' && 'Vereine'}
|
||||
{tab === 'divisions' && 'Sparten'}
|
||||
{tab === 'groups' && 'Trainingsgruppen'}
|
||||
{tab === 'members' && 'Mitglieder'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -235,7 +391,7 @@ function ClubsPage() {
|
|||
{club.status}
|
||||
</span>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
{canManageClub(club.id) && (
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
|
|
@ -243,6 +399,7 @@ function ClubsPage() {
|
|||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
{isSuperAdmin && (
|
||||
<button
|
||||
className="btn"
|
||||
style={{
|
||||
|
|
@ -254,6 +411,7 @@ function ClubsPage() {
|
|||
>
|
||||
Löschen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -269,7 +427,7 @@ function ClubsPage() {
|
|||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
||||
<h2>Sparten</h2>
|
||||
{isAdmin && (
|
||||
{canManageOrgSomewhere && (
|
||||
<button className="btn btn-primary" onClick={() => handleCreate('division')}>
|
||||
+ Neue Sparte
|
||||
</button>
|
||||
|
|
@ -306,7 +464,7 @@ function ClubsPage() {
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
{canManageClub(division.club_id) && (
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
|
|
@ -340,7 +498,7 @@ function ClubsPage() {
|
|||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
||||
<h2>Trainingsgruppen</h2>
|
||||
{canCreateClub && (
|
||||
{canCreateTrainingGroup && (
|
||||
<button className="btn btn-primary" onClick={() => handleCreate('group')}>
|
||||
+ Neue Gruppe
|
||||
</button>
|
||||
|
|
@ -377,7 +535,7 @@ function ClubsPage() {
|
|||
{group.age_group && <div>👶 {group.age_group}</div>}
|
||||
</div>
|
||||
|
||||
{(isAdmin || group.trainer_id === user?.id) && (
|
||||
{canEditGroup(group) && (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
|
|
@ -386,7 +544,7 @@ function ClubsPage() {
|
|||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
{isAdmin && (
|
||||
{canDeleteGroup(group) && (
|
||||
<button
|
||||
className="btn"
|
||||
style={{
|
||||
|
|
@ -408,6 +566,156 @@ function ClubsPage() {
|
|||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'members' && canManageOrgSomewhere && (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||
<h2>Mitglieder & Beitrittsanträge</h2>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<label style={{ color: 'var(--text2)', fontSize: '0.875rem' }}>Verein</label>
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ minWidth: '200px' }}
|
||||
value={membersAdminClubId ?? ''}
|
||||
onChange={(e) =>
|
||||
setMembersAdminClubId(e.target.value ? parseInt(e.target.value, 10) : null)
|
||||
}
|
||||
>
|
||||
{manageableClubs.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
setAddMemberForm({ profile_id: '', roles: ['trainer'] })
|
||||
setShowAddMemberModal(true)
|
||||
}}
|
||||
>
|
||||
Mitglied hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{manageableClubs.length === 0 ? (
|
||||
<div className="card">
|
||||
<p style={{ color: 'var(--text2)' }}>
|
||||
Keine Vereine, für die du Mitglieder verwalten darfst.
|
||||
</p>
|
||||
</div>
|
||||
) : membersAdminLoading ? (
|
||||
<p style={{ color: 'var(--text2)' }}>Laden…</p>
|
||||
) : (
|
||||
<>
|
||||
{joinRequestsAdmin.length > 0 && (
|
||||
<div className="card" style={{ marginBottom: '1rem' }}>
|
||||
<h3 style={{ marginTop: 0 }}>Offene Beitrittsanträge</h3>
|
||||
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
||||
{joinRequestsAdmin.map((req) => (
|
||||
<div
|
||||
key={req.id}
|
||||
style={{
|
||||
padding: '0.75rem',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>{req.applicant_name || req.applicant_email || 'Nutzer'}</strong>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text2)' }}>
|
||||
{req.applicant_email} · Profil #{req.profile_id}
|
||||
</div>
|
||||
{req.message ? (
|
||||
<div style={{ marginTop: '0.35rem', fontSize: '0.875rem' }}>{req.message}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.35rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() =>
|
||||
setAcceptJoinModal({
|
||||
id: req.id,
|
||||
label: req.applicant_name || req.applicant_email,
|
||||
roles: ['trainer'],
|
||||
})
|
||||
}
|
||||
>
|
||||
Annehmen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={async () => {
|
||||
if (!confirm('Antrag ablehnen?')) return
|
||||
try {
|
||||
await api.rejectClubJoinRequest(membersAdminClubId, req.id)
|
||||
await reloadMembersAdmin()
|
||||
} catch (err) {
|
||||
alert(err.message || String(err))
|
||||
}
|
||||
}}
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Mitglieder</h3>
|
||||
{clubMembersAdmin.length === 0 ? (
|
||||
<p style={{ color: 'var(--text2)' }}>Noch keine Mitglieder erfasst.</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: '0.65rem' }}>
|
||||
{clubMembersAdmin.map((m) => (
|
||||
<div
|
||||
key={m.membership_id}
|
||||
style={{
|
||||
padding: '0.65rem',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--surface2)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>{m.name || m.email}</strong>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text2)' }}>
|
||||
{m.email} · #{m.profile_id} · {m.status}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8rem', marginTop: '0.25rem' }}>
|
||||
Rollen: {(m.roles || []).join(', ') || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setEditMemberModal(m)}
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div style={{
|
||||
|
|
@ -485,6 +793,28 @@ function ClubsPage() {
|
|||
<option value="inactive">Inaktiv</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{!editing && canCreateClub && (
|
||||
<div className="form-row">
|
||||
<label className="form-label">Hauptverwalter (Profil-ID) *</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="form-input"
|
||||
value={formData.primary_admin_profile_id ?? ''}
|
||||
onChange={(e) =>
|
||||
updateFormField(
|
||||
'primary_admin_profile_id',
|
||||
e.target.value === '' ? '' : parseInt(e.target.value, 10)
|
||||
)
|
||||
}
|
||||
required
|
||||
/>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text3)', marginTop: '0.35rem' }}>
|
||||
Nur Plattform-Administratoren legen Vereine an. Standard ist deine eigene Profil-ID.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
@ -664,6 +994,50 @@ function ClubsPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Haupttrainer</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={formData.trainer_id != null ? String(formData.trainer_id) : ''}
|
||||
onChange={(e) =>
|
||||
updateFormField(
|
||||
'trainer_id',
|
||||
e.target.value === '' ? '' : parseInt(e.target.value, 10)
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{groupMemberDirectory.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{(p.name || p.email || '').trim()} (#{p.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--text3)', marginTop: '0.35rem' }}>
|
||||
Liste = aktive Vereinsmitglieder. Nach Zuweisungen ggf. Seite neu laden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Co-Trainer (Mehrfachauswahl)</label>
|
||||
<select
|
||||
multiple
|
||||
className="form-input"
|
||||
size={Math.min(8, Math.max(4, groupMemberDirectory.length || 4))}
|
||||
value={(formData.co_trainer_ids || []).map(String)}
|
||||
onChange={(e) => {
|
||||
const opts = [...e.target.selectedOptions].map((o) => parseInt(o.value, 10))
|
||||
updateFormField('co_trainer_ids', opts)
|
||||
}}
|
||||
>
|
||||
{groupMemberDirectory.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{(p.name || p.email || '').trim()} (#{p.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Status</label>
|
||||
<select
|
||||
|
|
@ -695,6 +1069,340 @@ function ClubsPage() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddMemberModal && membersAdminClubId && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1100,
|
||||
padding: '1rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
borderRadius: '12px',
|
||||
padding: '1.5rem',
|
||||
maxWidth: '480px',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginTop: 0 }}>Mitglied hinzufügen</h2>
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.875rem' }}>
|
||||
{isPlatformAdmin
|
||||
? 'Nutzer aus der Liste wählen oder Profil-ID eingeben.'
|
||||
: 'Profil-ID des Nutzers (z. B. aus der Nutzerverwaltung). Offene Beitrittsanträge kannst du oben direkt annehmen.'}
|
||||
</p>
|
||||
{isPlatformAdmin && profilesAdminList.length > 0 ? (
|
||||
<div className="form-row">
|
||||
<label className="form-label">Profil</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={addMemberForm.profile_id === '' ? '' : String(addMemberForm.profile_id)}
|
||||
onChange={(e) =>
|
||||
setAddMemberForm((prev) => ({ ...prev, profile_id: e.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="">Bitte wählen…</option>
|
||||
{profilesAdminList.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
#{p.id} — {p.email || '—'} ({p.name || 'ohne Name'})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="form-row">
|
||||
<label className="form-label">Profil-ID</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="form-input"
|
||||
value={addMemberForm.profile_id}
|
||||
onChange={(e) =>
|
||||
setAddMemberForm((prev) => ({
|
||||
...prev,
|
||||
profile_id: e.target.value === '' ? '' : parseInt(e.target.value, 10),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-row">
|
||||
<span className="form-label">Rollen</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
{CLUB_ROLE_OPTIONS.map((opt) => (
|
||||
<label key={opt.code} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={addMemberForm.roles.includes(opt.code)}
|
||||
onChange={() => {
|
||||
setAddMemberForm((prev) => {
|
||||
const set = new Set(prev.roles)
|
||||
if (set.has(opt.code)) set.delete(opt.code)
|
||||
else set.add(opt.code)
|
||||
return { ...prev, roles: Array.from(set) }
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={async () => {
|
||||
const raw = addMemberForm.profile_id
|
||||
const profile_id = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
|
||||
if (!Number.isFinite(profile_id) || profile_id < 1) {
|
||||
alert('Gültige Profil-ID wählen.')
|
||||
return
|
||||
}
|
||||
if (!addMemberForm.roles.length) {
|
||||
alert('Mindestens eine Rolle.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.addClubMember(membersAdminClubId, {
|
||||
profile_id,
|
||||
roles: addMemberForm.roles,
|
||||
})
|
||||
setShowAddMemberModal(false)
|
||||
await reloadMembersAdmin()
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
alert(err.message || String(err))
|
||||
}
|
||||
}}
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setShowAddMemberModal(false)}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{acceptJoinModal && membersAdminClubId && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1100,
|
||||
padding: '1rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
borderRadius: '12px',
|
||||
padding: '1.5rem',
|
||||
maxWidth: '480px',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginTop: 0 }}>Antrag annehmen</h2>
|
||||
<p style={{ color: 'var(--text2)' }}>{acceptJoinModal.label}</p>
|
||||
<div className="form-row">
|
||||
<span className="form-label">Rollen bei Aufnahme</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
{CLUB_ROLE_OPTIONS.map((opt) => (
|
||||
<label key={opt.code} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acceptJoinModal.roles.includes(opt.code)}
|
||||
onChange={() => {
|
||||
setAcceptJoinModal((prev) => {
|
||||
if (!prev) return prev
|
||||
const set = new Set(prev.roles)
|
||||
if (set.has(opt.code)) set.delete(opt.code)
|
||||
else set.add(opt.code)
|
||||
const roles = Array.from(set)
|
||||
return { ...prev, roles: roles.length ? roles : ['trainer'] }
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await api.acceptClubJoinRequest(
|
||||
membersAdminClubId,
|
||||
acceptJoinModal.id,
|
||||
acceptJoinModal.roles.length ? acceptJoinModal.roles : ['trainer']
|
||||
)
|
||||
setAcceptJoinModal(null)
|
||||
await reloadMembersAdmin()
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
alert(err.message || String(err))
|
||||
}
|
||||
}}
|
||||
>
|
||||
Aufnehmen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setAcceptJoinModal(null)}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editMemberModal && membersAdminClubId && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1100,
|
||||
padding: '1rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
borderRadius: '12px',
|
||||
padding: '1.5rem',
|
||||
maxWidth: '480px',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginTop: 0 }}>Mitglied bearbeiten</h2>
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>
|
||||
{editMemberModal.name || editMemberModal.email} (#{editMemberModal.profile_id})
|
||||
</p>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Status</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={editMemberModal.status || 'active'}
|
||||
onChange={(e) =>
|
||||
setEditMemberModal((prev) => (prev ? { ...prev, status: e.target.value } : prev))
|
||||
}
|
||||
>
|
||||
<option value="active">aktiv</option>
|
||||
<option value="inactive">inaktiv</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<span className="form-label">Rollen</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
{CLUB_ROLE_OPTIONS.map((opt) => (
|
||||
<label key={opt.code} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(editMemberModal.roles || []).includes(opt.code)}
|
||||
onChange={() => {
|
||||
setEditMemberModal((prev) => {
|
||||
if (!prev) return prev
|
||||
const set = new Set(prev.roles || [])
|
||||
if (set.has(opt.code)) set.delete(opt.code)
|
||||
else set.add(opt.code)
|
||||
let roles = Array.from(set)
|
||||
if (!roles.length) roles = ['trainer']
|
||||
return { ...prev, roles }
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '1rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await api.updateClubMember(membersAdminClubId, editMemberModal.profile_id, {
|
||||
roles: editMemberModal.roles,
|
||||
status: editMemberModal.status,
|
||||
})
|
||||
setEditMemberModal(null)
|
||||
await reloadMembersAdmin()
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
alert(err.message || String(err))
|
||||
}
|
||||
}}
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ background: 'var(--danger)', color: '#fff', border: 'none' }}
|
||||
onClick={async () => {
|
||||
if (
|
||||
!confirm(
|
||||
'Mitgliedschaft wirklich entfernen? (Nutzer verliert alle Rollen in diesem Verein.)'
|
||||
)
|
||||
)
|
||||
return
|
||||
try {
|
||||
await api.removeClubMember(membersAdminClubId, editMemberModal.profile_id)
|
||||
setEditMemberModal(null)
|
||||
await reloadMembersAdmin()
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
alert(err.message || String(err))
|
||||
}
|
||||
}}
|
||||
>
|
||||
Aus Verein entfernen
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setEditMemberModal(null)}>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
|
||||
import MultiSelectCombo from '../components/MultiSelectCombo'
|
||||
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
const BULK_MAX_IDS = 500
|
||||
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
|
||||
|
||||
const INITIAL_FILTERS = {
|
||||
|
|
@ -26,6 +28,9 @@ function levelOptionShort(levelStr) {
|
|||
}
|
||||
|
||||
function ExercisesListPage() {
|
||||
const { user } = useAuth()
|
||||
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
||||
|
||||
const [exercises, setExercises] = useState([])
|
||||
const [catalogs, setCatalogs] = useState({
|
||||
focusAreas: [],
|
||||
|
|
@ -47,6 +52,14 @@ function ExercisesListPage() {
|
|||
const [filterModalOpen, setFilterModalOpen] = useState(false)
|
||||
const [pageTab, setPageTab] = useState('list')
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState(() => new Set())
|
||||
const [bulkModalOpen, setBulkModalOpen] = useState(false)
|
||||
const [bulkVisibility, setBulkVisibility] = useState('')
|
||||
const [bulkStatus, setBulkStatus] = useState('')
|
||||
const [bulkClubSelect, setBulkClubSelect] = useState('')
|
||||
const [bulkClubManual, setBulkClubManual] = useState('')
|
||||
const [bulkSubmitting, setBulkSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
|
||||
return () => clearTimeout(t)
|
||||
|
|
@ -254,6 +267,67 @@ function ExercisesListPage() {
|
|||
return q
|
||||
}, [filters, debouncedSearch, debouncedAiSearch])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds(new Set())
|
||||
}, [queryBase])
|
||||
|
||||
const clubNameById = useMemo(() => {
|
||||
const m = {}
|
||||
for (const c of user?.clubs || []) {
|
||||
if (c?.id != null) m[Number(c.id)] = c.name || `#${c.id}`
|
||||
}
|
||||
return m
|
||||
}, [user?.clubs])
|
||||
|
||||
const effectiveClubId =
|
||||
user?.effective_club_id != null && user.effective_club_id !== ''
|
||||
? Number(user.effective_club_id)
|
||||
: user?.active_club_id != null && user.active_club_id !== ''
|
||||
? Number(user.active_club_id)
|
||||
: null
|
||||
|
||||
const toggleSelect = useCallback((id) => {
|
||||
setSelectedIds((prev) => {
|
||||
const n = new Set(prev)
|
||||
const nid = Number(id)
|
||||
if (Number.isNaN(nid)) return prev
|
||||
if (n.has(nid)) n.delete(nid)
|
||||
else n.add(nid)
|
||||
return n
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
|
||||
|
||||
const toggleSelectAllPage = useCallback(() => {
|
||||
setSelectedIds((prev) => {
|
||||
const n = new Set(prev)
|
||||
const allSel =
|
||||
exercises.length > 0 && exercises.every((e) => n.has(Number(e.id)))
|
||||
if (allSel) {
|
||||
exercises.forEach((e) => n.delete(Number(e.id)))
|
||||
} else {
|
||||
exercises.forEach((e) => n.add(Number(e.id)))
|
||||
}
|
||||
return n
|
||||
})
|
||||
}, [exercises])
|
||||
|
||||
const allOnPageSelected = useMemo(
|
||||
() => exercises.length > 0 && exercises.every((e) => selectedIds.has(Number(e.id))),
|
||||
[exercises, selectedIds]
|
||||
)
|
||||
|
||||
const bulkVisibilityOptions = useMemo(() => {
|
||||
const base = [
|
||||
{ id: '', label: '— nicht ändern —' },
|
||||
{ id: 'private', label: 'Privat' },
|
||||
{ id: 'club', label: 'Verein' },
|
||||
]
|
||||
if (isPlatformAdmin) base.push({ id: 'official', label: 'Offiziell (global)' })
|
||||
return base
|
||||
}, [isPlatformAdmin])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
|
|
@ -342,6 +416,85 @@ function ExercisesListPage() {
|
|||
|
||||
const resetAllFilters = useCallback(() => setFilters({ ...INITIAL_FILTERS }), [])
|
||||
|
||||
const openBulkModal = () => {
|
||||
setBulkVisibility('')
|
||||
setBulkStatus('')
|
||||
setBulkClubSelect('')
|
||||
setBulkClubManual('')
|
||||
setBulkModalOpen(true)
|
||||
}
|
||||
|
||||
const handleBulkSubmit = async () => {
|
||||
if (!bulkVisibility && !bulkStatus) {
|
||||
alert('Bitte mindestens Sichtbarkeit oder Status wählen (nicht „nicht ändern“ bei beiden).')
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds).filter((x) => Number.isFinite(x) && x > 0)
|
||||
if (ids.length === 0) {
|
||||
alert('Keine Übungen ausgewählt.')
|
||||
return
|
||||
}
|
||||
if (ids.length > BULK_MAX_IDS) {
|
||||
alert(`Maximal ${BULK_MAX_IDS} Übungen pro Vorgang. Bitte Auswahl oder mehrere Durchläufe verwenden.`)
|
||||
return
|
||||
}
|
||||
const payload = { exercise_ids: ids }
|
||||
if (bulkVisibility) payload.visibility = bulkVisibility
|
||||
if (bulkStatus) payload.status = bulkStatus
|
||||
if (bulkVisibility === 'club') {
|
||||
const manual = String(bulkClubManual || '').trim()
|
||||
if (manual && /^\d+$/.test(manual)) payload.club_id = Number(manual)
|
||||
else if (bulkClubSelect && /^\d+$/.test(String(bulkClubSelect))) {
|
||||
payload.club_id = Number(bulkClubSelect)
|
||||
}
|
||||
}
|
||||
setBulkSubmitting(true)
|
||||
try {
|
||||
const res = await api.bulkPatchExercisesMetadata(payload)
|
||||
const updatedSet = new Set((res.updated || []).map((x) => Number(x)))
|
||||
let resolvedClubId = null
|
||||
if (bulkVisibility === 'club') {
|
||||
if (payload.club_id != null) resolvedClubId = payload.club_id
|
||||
else if (effectiveClubId != null && !Number.isNaN(effectiveClubId)) resolvedClubId = effectiveClubId
|
||||
}
|
||||
const clubLabel =
|
||||
resolvedClubId != null ? clubNameById[resolvedClubId] || `Verein #${resolvedClubId}` : null
|
||||
|
||||
setExercises((prev) =>
|
||||
prev.map((e) => {
|
||||
if (!updatedSet.has(Number(e.id))) return e
|
||||
const next = { ...e }
|
||||
if (bulkVisibility) {
|
||||
next.visibility = bulkVisibility
|
||||
next.club_id = bulkVisibility === 'club' ? resolvedClubId : null
|
||||
next.club_name = bulkVisibility === 'club' ? clubLabel : null
|
||||
}
|
||||
if (bulkStatus) next.status = bulkStatus
|
||||
return next
|
||||
})
|
||||
)
|
||||
|
||||
let msg = `${res.updated_count ?? updatedSet.size} Übung(en) aktualisiert.`
|
||||
if (res.failed_count) msg += `\n${res.failed_count} nicht geändert (siehe Details).`
|
||||
if (Array.isArray(res.failed) && res.failed.length) {
|
||||
msg +=
|
||||
'\n\n' +
|
||||
res.failed
|
||||
.slice(0, 12)
|
||||
.map((f) => `#${f.id}: ${f.detail}`)
|
||||
.join('\n')
|
||||
if (res.failed.length > 12) msg += '\n…'
|
||||
}
|
||||
alert(msg)
|
||||
setBulkModalOpen(false)
|
||||
clearSelection()
|
||||
} catch (err) {
|
||||
alert('Massenänderung fehlgeschlagen: ' + (err.message || String(err)))
|
||||
} finally {
|
||||
setBulkSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!catalogsReady && pageTab === 'list') {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
|
|
@ -470,9 +623,43 @@ function ExercisesListPage() {
|
|||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '10px', marginBottom: 0 }}>
|
||||
Trefferliste aktualisiert sich kurz nach Eingabe. Titel der aktuellen Liste erscheinen als Vorschläge (Pfeil im
|
||||
Feld). Fachliche Filter über „Filter“ – zwischen Feldern UND, Auswahl mehrerer Werte je Feld mit ODER.
|
||||
{exercises.length > 0 ? (
|
||||
<>
|
||||
{' '}
|
||||
<button type="button" className="btn btn-secondary" style={{ marginLeft: '6px' }} onClick={toggleSelectAllPage}>
|
||||
{allOnPageSelected ? 'Auswahl auf dieser Seite aufheben' : 'Alle auf dieser Seite auswählen'}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedIds.size > 0 ? (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
marginBottom: '12px',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
}}
|
||||
>
|
||||
<strong>{selectedIds.size} ausgewählt</strong>
|
||||
<button type="button" className="btn btn-secondary" onClick={clearSelection}>
|
||||
Auswahl aufheben
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={openBulkModal}>
|
||||
Sichtbarkeit / Status ändern…
|
||||
</button>
|
||||
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
|
||||
Bis zu {BULK_MAX_IDS} pro Anfrage. Für „Verein“ ohne Auswahl: aktiver Vereinskontext (
|
||||
<code>X-Active-Club-Id</code>
|
||||
).
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{filterModalOpen && (
|
||||
<div
|
||||
className="admin-modal-backdrop"
|
||||
|
|
@ -637,6 +824,118 @@ function ExercisesListPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{bulkModalOpen ? (
|
||||
<div
|
||||
className="admin-modal-backdrop"
|
||||
role="presentation"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setBulkModalOpen(false)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="admin-modal-sheet exercise-filter-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="exercise-bulk-modal-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="admin-modal-sheet__header">
|
||||
<h3 id="exercise-bulk-modal-title" className="admin-modal-sheet__title">
|
||||
Massenänderung: Sichtbarkeit / Status
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary admin-modal-sheet__close"
|
||||
onClick={() => setBulkModalOpen(false)}
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-modal-sheet__body exercise-filter-modal__scroll">
|
||||
<p style={{ fontSize: '13px', color: 'var(--text2)', marginTop: 0 }}>
|
||||
Es werden <strong>{selectedIds.size}</strong> Übung(en) aus der aktuellen Auswahl bearbeitet. Pro Durchlauf
|
||||
höchstens {BULK_MAX_IDS}. Ohne Berechtigung bleiben Einzelübungen unverändert (siehe Hinweis nach dem
|
||||
Speichern).
|
||||
</p>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Sichtbarkeit</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={bulkVisibility}
|
||||
onChange={(e) => setBulkVisibility(e.target.value)}
|
||||
>
|
||||
{bulkVisibilityOptions.map((o) => (
|
||||
<option key={o.id === '' ? '_unchanged' : o.id} value={o.id}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{bulkVisibility === 'club' ? (
|
||||
<div className="form-row">
|
||||
<label className="form-label">Verein zuordnen</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={bulkClubSelect}
|
||||
onChange={(e) => setBulkClubSelect(e.target.value)}
|
||||
>
|
||||
<option value="">Aktiver Verein (Vereins-Umschalter / Header)</option>
|
||||
{(user?.clubs || []).map((c) => (
|
||||
<option key={c.id} value={String(c.id)}>
|
||||
{c.name || `#${c.id}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isPlatformAdmin ? (
|
||||
<>
|
||||
<label className="form-label" style={{ marginTop: '10px' }}>
|
||||
Oder Vereins-ID (Plattform-Admin)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="form-input"
|
||||
placeholder="Leer = wie Dropdown / aktiver Verein"
|
||||
value={bulkClubManual}
|
||||
onChange={(e) => setBulkClubManual(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="form-row">
|
||||
<label className="form-label">Status</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={bulkStatus}
|
||||
onChange={(e) => setBulkStatus(e.target.value)}
|
||||
>
|
||||
<option value="">— nicht ändern —</option>
|
||||
{statusOptions.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="exercise-filter-modal__footer" style={{ justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={bulkSubmitting}
|
||||
onClick={() => setBulkModalOpen(false)}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" disabled={bulkSubmitting} onClick={handleBulkSubmit}>
|
||||
{bulkSubmitting ? 'Speichern…' : 'Anwenden'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{listFetching && exercises.length === 0 ? (
|
||||
<div className="card" style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<div className="spinner"></div>
|
||||
|
|
@ -666,7 +965,15 @@ function ExercisesListPage() {
|
|||
>
|
||||
{exercises.map((exercise) => (
|
||||
<div key={exercise.id} className="card exercise-card">
|
||||
<div className="exercise-card__body">
|
||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'flex-start' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(Number(exercise.id))}
|
||||
onChange={() => toggleSelect(exercise.id)}
|
||||
aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`}
|
||||
style={{ marginTop: '4px', flexShrink: 0 }}
|
||||
/>
|
||||
<div className="exercise-card__body" style={{ flex: 1, minWidth: 0 }}>
|
||||
<h3 style={{ marginBottom: '8px', fontSize: '1.05rem', lineHeight: 1.3 }}>
|
||||
<Link
|
||||
to={`/exercises/${exercise.id}`}
|
||||
|
|
@ -689,6 +996,7 @@ function ExercisesListPage() {
|
|||
: exercise.summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="exercise-card__actions">
|
||||
<Link to={`/exercises/${exercise.id}`} className="btn btn-secondary">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../utils/api'
|
||||
|
|
@ -11,11 +11,18 @@ function LoginPage() {
|
|||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [success, setSuccess] = useState('')
|
||||
const [publicClubs, setPublicClubs] = useState([])
|
||||
const [requestedClubId, setRequestedClubId] = useState('')
|
||||
const [resending, setResending] = useState(false)
|
||||
|
||||
const navigate = useNavigate()
|
||||
const { checkAuth } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'register') return
|
||||
api.listPublicClubsDirectory().then(setPublicClubs).catch(() => setPublicClubs([]))
|
||||
}, [mode])
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
|
@ -29,10 +36,15 @@ function LoginPage() {
|
|||
await checkAuth()
|
||||
navigate('/')
|
||||
} else {
|
||||
await api.register(email, password, name)
|
||||
const extra =
|
||||
requestedClubId !== ''
|
||||
? { requested_club_id: parseInt(requestedClubId, 10) }
|
||||
: {}
|
||||
await api.register(email, password, name, extra)
|
||||
setSuccess('Registrierung erfolgreich! Bitte prüfe deine E-Mails (auch Spam).')
|
||||
setMode('login')
|
||||
setPassword('')
|
||||
setRequestedClubId('')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || 'Ein Fehler ist aufgetreten')
|
||||
|
|
@ -84,6 +96,7 @@ function LoginPage() {
|
|||
onClick={() => {
|
||||
setMode('login')
|
||||
setError('')
|
||||
setRequestedClubId('')
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
|
|
@ -104,6 +117,7 @@ function LoginPage() {
|
|||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{mode === 'register' && (
|
||||
<>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Name</label>
|
||||
<input
|
||||
|
|
@ -115,6 +129,26 @@ function LoginPage() {
|
|||
placeholder="Dein Name"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Verein (optional)</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={requestedClubId}
|
||||
onChange={(e) => setRequestedClubId(e.target.value)}
|
||||
>
|
||||
<option value="">Kein Antrag / später</option>
|
||||
{publicClubs.map((c) => (
|
||||
<option key={c.id} value={String(c.id)}>
|
||||
{c.name}
|
||||
{c.abbreviation ? ` (${c.abbreviation})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p style={{ fontSize: '0.72rem', color: 'var(--text3)', marginTop: '0.35rem', lineHeight: 1.4 }}>
|
||||
Nach der E-Mail-Bestätigung kann der Vereinsadmin deinen Beitritt freigeben.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="form-row">
|
||||
|
|
|
|||
|
|
@ -8,6 +8,17 @@ import { stripHtmlToText } from './htmlUtils'
|
|||
|
||||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||
|
||||
/** LocalStorage + Request-Header für Mandanten-Kontext */
|
||||
export const ACTIVE_CLUB_STORAGE_KEY = 'shinkan_active_club_id'
|
||||
|
||||
function mergeActiveClubHeader(headers = {}) {
|
||||
const cid = localStorage.getItem(ACTIVE_CLUB_STORAGE_KEY)
|
||||
if (cid && /^\d+$/.test(String(cid).trim())) {
|
||||
return { ...headers, 'X-Active-Club-Id': String(cid).trim() }
|
||||
}
|
||||
return { ...headers }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic API request with automatic token injection
|
||||
*/
|
||||
|
|
@ -15,9 +26,9 @@ async function request(endpoint, options = {}) {
|
|||
const token = localStorage.getItem('authToken')
|
||||
const method = (options.method || 'GET').toUpperCase()
|
||||
|
||||
const headers = {
|
||||
const headers = mergeActiveClubHeader({
|
||||
...options.headers,
|
||||
}
|
||||
})
|
||||
// GET ohne Body: kein Content-Type: application/json (manche Proxies/Headers stören sich)
|
||||
if (method !== 'GET' && method !== 'HEAD') {
|
||||
if (!headers['Content-Type'] && !headers['content-type']) {
|
||||
|
|
@ -84,10 +95,10 @@ export async function login(email, password) {
|
|||
})
|
||||
}
|
||||
|
||||
export async function register(email, password, name) {
|
||||
export async function register(email, password, name, extra = {}) {
|
||||
return request('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password, name })
|
||||
body: JSON.stringify({ email, password, name, ...extra }),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -99,6 +110,16 @@ export async function getCurrentProfile() {
|
|||
return request('/api/profiles/me')
|
||||
}
|
||||
|
||||
/** Liste aller Profile – nur für Plattform-Admins (Vereinsanlage). */
|
||||
export async function listProfiles() {
|
||||
return request('/api/profiles')
|
||||
}
|
||||
|
||||
/** Alle Nutzer inkl. Vereinsmitgliedschaften — nur Portal-Admin (UI: Admin → Nutzer). */
|
||||
export async function listAdminUsers() {
|
||||
return request('/api/admin/users')
|
||||
}
|
||||
|
||||
export async function updateProfile(profileId, data) {
|
||||
return request(`/api/profiles/${profileId}`, {
|
||||
method: 'PUT',
|
||||
|
|
@ -158,6 +179,79 @@ export async function deleteClub(id) {
|
|||
return request(`/api/clubs/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Vereinsmitglieder (API für Admin ohne eigene UI) */
|
||||
export async function listClubMembers(clubId, { includeInactive = false } = {}) {
|
||||
const q = includeInactive ? '?include_inactive=true' : ''
|
||||
return request(`/api/clubs/${clubId}/members${q}`)
|
||||
}
|
||||
|
||||
export async function getClubMember(clubId, profileId) {
|
||||
return request(`/api/clubs/${clubId}/members/${profileId}`)
|
||||
}
|
||||
|
||||
export async function addClubMember(clubId, payload) {
|
||||
return request(`/api/clubs/${clubId}/members`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateClubMember(clubId, profileId, payload) {
|
||||
return request(`/api/clubs/${clubId}/members/${profileId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function removeClubMember(clubId, profileId) {
|
||||
return request(`/api/clubs/${clubId}/members/${profileId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Aktive Vereine (öffentlich, für Registrierungswahl). */
|
||||
export async function listPublicClubsDirectory() {
|
||||
return request('/api/clubs/public-directory')
|
||||
}
|
||||
|
||||
/** Vereinsinternes Mitgliederverzeichnis (Trainer-/Co-Auswahl). */
|
||||
export async function clubMembersDirectory(clubId) {
|
||||
return request(`/api/clubs/${clubId}/members/directory`)
|
||||
}
|
||||
|
||||
/** Eigene Beitrittsanträge. */
|
||||
export async function getMyClubJoinRequests() {
|
||||
return request('/api/me/club-join-requests')
|
||||
}
|
||||
|
||||
export async function createClubJoinRequest(payload) {
|
||||
return request('/api/me/club-join-requests', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function withdrawClubJoinRequest(requestId) {
|
||||
return request(`/api/me/club-join-requests/${requestId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Offene Anträge (Vereins-/Plattform-Admin). */
|
||||
export async function listClubJoinRequests(clubId) {
|
||||
return request(`/api/clubs/${clubId}/join-requests`)
|
||||
}
|
||||
|
||||
export async function acceptClubJoinRequest(clubId, requestId, roles = ['trainer']) {
|
||||
return request(`/api/clubs/${clubId}/join-requests/${requestId}/accept`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ roles }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function rejectClubJoinRequest(clubId, requestId) {
|
||||
return request(`/api/clubs/${clubId}/join-requests/${requestId}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function listDivisions(clubId) {
|
||||
const query = clubId ? `?club_id=${clubId}` : ''
|
||||
return request(`/api/divisions${query}`)
|
||||
|
|
@ -414,6 +508,14 @@ export async function updateExercise(id, data) {
|
|||
})
|
||||
}
|
||||
|
||||
/** Massenänderung Sichtbarkeit / Status (`PATCH /api/exercises/bulk-metadata`). */
|
||||
export async function bulkPatchExercisesMetadata(data) {
|
||||
return request('/api/exercises/bulk-metadata', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteExercise(id) {
|
||||
return request(`/api/exercises/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
|
@ -1008,6 +1110,8 @@ export const api = {
|
|||
register,
|
||||
logout,
|
||||
getCurrentProfile,
|
||||
listProfiles,
|
||||
listAdminUsers,
|
||||
updateProfile,
|
||||
changePassword,
|
||||
verifyEmail,
|
||||
|
|
@ -1019,6 +1123,19 @@ export const api = {
|
|||
createClub,
|
||||
updateClub,
|
||||
deleteClub,
|
||||
listClubMembers,
|
||||
getClubMember,
|
||||
addClubMember,
|
||||
updateClubMember,
|
||||
removeClubMember,
|
||||
listPublicClubsDirectory,
|
||||
clubMembersDirectory,
|
||||
getMyClubJoinRequests,
|
||||
createClubJoinRequest,
|
||||
withdrawClubJoinRequest,
|
||||
listClubJoinRequests,
|
||||
acceptClubJoinRequest,
|
||||
rejectClubJoinRequest,
|
||||
listDivisions,
|
||||
createDivision,
|
||||
updateDivision,
|
||||
|
|
@ -1047,6 +1164,7 @@ export const api = {
|
|||
getExercise,
|
||||
createExercise,
|
||||
updateExercise,
|
||||
bulkPatchExercisesMetadata,
|
||||
deleteExercise,
|
||||
createExerciseVariant,
|
||||
updateExerciseVariant,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
// Shinkan Jinkendo Frontend Version
|
||||
|
||||
export const APP_VERSION = "0.5.1"
|
||||
export const APP_VERSION = "0.8.29"
|
||||
export const BUILD_DATE = "2026-05-05"
|
||||
|
||||
export const PAGE_VERSIONS = {
|
||||
LoginPage: "1.0.0",
|
||||
Dashboard: "1.0.0",
|
||||
AccountSettingsPage: "1.0.0",
|
||||
ExercisesPage: "1.1.0", // Updated: Katalog-Integration
|
||||
ClubsPage: "1.0.0",
|
||||
ExercisesPage: "1.2.0", // Massenänderung Sichtbarkeit/Status auf der Liste
|
||||
ClubsPage: "1.1.0",
|
||||
SkillsPage: "1.0.0",
|
||||
TrainingPlanningPage: "1.3.1",
|
||||
TrainingFrameworkProgramsListPage: "1.1.0",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user