diff --git a/.claude/docs/functional/SHINKAN_REQUIREMENTS.md b/.claude/docs/functional/SHINKAN_REQUIREMENTS.md index 591a9cf..73e0c34 100644 --- a/.claude/docs/functional/SHINKAN_REQUIREMENTS.md +++ b/.claude/docs/functional/SHINKAN_REQUIREMENTS.md @@ -6,6 +6,7 @@ Ausführliche fachliche Inhalte: |----------|--------| | [shinkan_anforderungsdokument_entwurf.md](./shinkan_anforderungsdokument_entwurf.md) | Gesamtentwurf Anforderungen | | [DOMAIN_MODEL.md](./DOMAIN_MODEL.md) | Domänenmodell, Variantenlogik (Abschnitt 11.2) | +| [MULTI_TENANCY_RBAC_ARCHITECTURE.md](../technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md) | Zielarchitektur Mandanten/Rollen/Membership & Umsetzungsplan | **Lieferstand & Umsetzung (Stand Code):** siehe [`../PROJECT_STATUS.md`](../PROJECT_STATUS.md) und [`../library/FEATURES_DELIVERED_2026-Q2.md`](../library/FEATURES_DELIVERED_2026-Q2.md). diff --git a/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md b/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md new file mode 100644 index 0000000..51b392e --- /dev/null +++ b/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md @@ -0,0 +1,125 @@ +# Einheitliche Zugriffsschicht & Governance – Umsetzungsplan + +**Status:** verbindliche Umsetzungsreihenfolge (nachgelagert zum Zielbild in `MULTI_TENANCY_RBAC_ARCHITECTURE.md`) +**Stand:** 2026-05-05 +**Zweck:** Drift vermeiden – eine nachvollziehbare Schicht für Mandanten-Kontext, Sichtbarkeit und Berechtigungen, auf die alle inhaltsbezogenen Module konsistent aufbauen. + +**Explizit zurückgestellt (wie vereinbart):** kostenpflichtiges Vereins-Membership / Tier-Limits pro Verein (`club_subscriptions` o. Ä.) – kommt nach stabiler Zugriffs- und Datenisolationsbasis. + +**Separates späteres Konzept:** durch Vereinsadmins definierbare Rollen mit feingranularen Rechten (Capability-Bundles in DB/UI); dieser Plan bereitet nur die **Capability-Idee** und **Erweiterungspunkte** vor. + +--- + +## 1. Leitprinzipien + +1. **Ein Mandant für Datenisolierung:** `club_id` ist die Grenze für „vereinsgeteilte“ Inhalte. Nur Ausnahmen: explizit **plattformweite/offizielle** Objekte und **privat**e Objekte des Erstellers. +2. **Ein Kontext pro HTTP-Request:** Mitgliedschaft und gewählter aktiver Verein werden einmal aufgelöst und validiert; Folgelogik nutzt nur noch dieses Objekt (kein verteiltes erneutes „Rates“ aus Headers). +3. **Eine fachliche Sichtbarkeits-Semantik** über alle Bibliotheks- und Planungsartefakte (Übungen, Vorlagen, Rahmenprogramme, …): gleiche Enums, gleiche Leseprüfung, angepasste Listenfilter. +4. **Sparte optional verschärfen:** `division_id` auf Objekten und später auf Rollenzuweisung ausgewertet – ohne Vereinsgrenze zu sprengen. +5. **Community später additive:** neue Freigabeebene oder Flags ergänzen die bestehende Semantik, ohne `club`-Isolation zu ersetzen. + +--- + +## 2. Architektur der Zugriffsschicht (Schichtenmodell) + +| Schicht | Verantwortung | +|---------|----------------| +| **Authentifizierung** | Session / Token → `profile_id`, globale `role` (`require_auth`, bestehend). | +| **TenantContext** (neu, zentral) | Aus `profile_id` + Header `X-Active-Club-Id` (optional) + DB `profiles.active_club_id`: effektive **`effective_club_id`** nur wenn Mitgliedschaft aktiv existiert; sonch klaren Fehler (403/400 nach Konvention). Optional: Cache der Mitgliedschaftszeilen/Rollen **einmal pro Request**. | +| **Governance / Objekt contra Account** | Für jedes geschützte Objekt: `visibility`, `club_id`, `division_id` (nullable), `created_by` → **eine** zentrale Entscheidung `can_read` / `can_write` (interne Module, keine Copy-Paste-Logik pro Router). | +| **Funktions-/Feature-Rechte** | „Darf dieser Nutzer im Verein X Trainergruppe anlegen?“ → Capability-Checks (`can_manage_club_org`, `can_plan_in_club`, …); später durch dynamische Rollen → gleiche Capability-Namen. | + +**Ziel:** Router werden dünn: laden Daten nur noch durch Hilfen, die **TenantContext** und **Governance** bereits berücksichtigen oder explizit prüfen. + +--- + +## 3. Scope- und Sichtbarkeitsmodell (einheitlich) + +Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`). + +**Zielbild (phasenweise DB/API-Anpassung):** + +| Wert | Lesende Regel (kurz) | +|------|----------------------| +| `private` | Nur `created_by` (+ Plattform-Admin nach Policy); keine Vereinsliste ohne Ownership. | +| `club` | Nur aktive Mitglieder des Objekt-`club_id`; **Cross-Verein nie**. | +| `division` *(optional neue Stufe oder `club` + Pflicht `division_id`)* | Nur Mitglieder, die dieser Sparte zugeordnet sind (Regeln gesondert spezifizieren: Mitgliedschaft vs. Gruppe vs. Rolle `division_lead`). | +| `official` | Plattform-weit lesbar (Superadmin publiziert/pflegt); weiterhin strikt von `club`-Daten getrennt. | +| `community` *(reserviert)* | Noch nicht implementieren; Design nur additive Felder/Enum-Einträge dokumentieren, wenn erste Stories starten. | + +**Trainer-Flow:** „Privat anlegen, dann im Verein teilen“ = Transition von `private` zu `club` (oder Kopie + neue Visibility – Produktentscheidung; technisch muss `club_id` gesetzt und Mitgliedschaft geprüft werden). + +--- + +## 4. Roadmap (verbindliche Reihenfolge) + +### Stufe A – Foundations & Audit + +- **Router-Inventar:** Liste aller Endpoints mit Zugriff auf `club_id`, `visibility`, organisationalem Bezug oder Listenfiltern (Excel/Markdown-Tabelle im Repo oder unter `.claude/docs/working/`). +- **Definition of Done je Endpoint:** „Default deny“ für tenant-sensitive Listen wenn Kontext fehlt/ungültig. +- TenantContext-Spezifikation festhalten (Feldnamen, HTTP-Fehlercodes, Superadmin-Ausnahmen). + +### Stufe B – TenantContext (Backend zentral) + +- FastAPI-Dependency z. B. `get_tenant_context(session, x_active_club_id)` → Objekt mit `profile_id`, `global_role`, `effective_club_id`, `club_memberships` (optional gekürzt). +- Alle neuen und bestehenden sicherheitsrelevanten Änderungen **nur** über diese Dependency oder darauf aufbauende Helfer. +- Abgleich mit Frontend: `X-Active-Club-Id` und Persistenz `active_club_id` (bereits vorhanden) als einzige variabler Vereinskontext-Quellen. + +### Stufe C – Governance vereinheitlichen + +- **Eine** interne API-Stilebene (auch wenn mehrere Python-Funktionen): + `content_readable(...)`, `content_writable(...)` oder zweischichtig „Governance“ vs „Org-Rechte“. +- Module angleichen: **Übungen**, **Trainingsplanung**, **Rahmenprogramme**, **Vorlagen** – gleiche Regeln für `club`/`official`/`private`; **`division`** dort einführen, wo fachlich nötig (einheitliche Filter-Chips in UI). +- Regressionstests: **zwei Vereine, zwei Nutzer**, kein Kreuzzugriff auf `club`-Objekte; Superadmin/Global-Path weiterhin getrennt testen. + +### Stufe D – Sparten-Durchsetzung + +- `division_lead` und optional `division_id` auf Mitgliedschaft/Rolle auswerten bei Schreib-/Lesevorgängen für Objekte mit `division_id`. +- Dokumentieren: Was gilt für Objekte mit `division_id=NULL` innerhalb eines Vereins (vereinsweit vs. nur „ohne Sparte“). + +### Stufe E – Capabilities dokumentieren (ohne UI für Custom Roles) + +- Markdown-Tabelle **Capability-Fingerprint**: Kennungen wie `content.share_club`, `planning.edit_unit`, `org.manage_members`, … mit Zuordnung zu den **heutigen** festen Vereinsrollen. +- Ziel: später `club_custom_roles` nur noch andere Kombination derselben Kennungen – keine zweite Philosophie. + +### Stufe F – Community (eigenes Epic) + +- Konzept: Freigabe **additiv** (Flag oder Enum), Moderation, Sichtbarkeit „öffentlich außerhalb meines Vereins“ ohne bestehende `club`-Isolation zu brechen. + +### Zurückgestellt – Vereinsabo / Limits + +- Wiederöffnen wenn ACCESS_LAYER Stufe C/D stabil; dann Enforcement vor ausgewählten Writes an einen Billing-Stripe binden. + +--- + +## 5. Drift vermeiden (Arbeitsdisziplin) + +| Mechanismus | Inhalt | +|-------------|--------| +| **Cursor / IDE** | Projektregel `.cursor/rules/access-layer.mdc` (Router); Agents sollen nicht auf „nur require_auth“ ausweichen. | +| **Heuristik-Check** | `python backend/scripts/check_access_layer_hints.py`; CI optional mit `ACCESS_LAYER_STRICT=1`. Optional danach: `cd backend && pytest tests/` (nach `pip install -r requirements-dev.txt`). | +| **PR-Checkliste** | Neuer/changed Endpoint: TenantContext verwendet? Governance für Listen + Detail? Tests für zweiten Verein? | +| **Single Source of Truth** | Sichtbarkeitsregeln nur in Zugriffsmodul(en), nicht in Routers dupliziert. | +| **Änderungen am Enum** | Nur zusammen mit Migration + Kurzbeschreibung in diesem Dokument (Datum/Changelog-Zeile). | +| **Beziehung zu MULTI_TENANCY-Doc** | Phasen 1–4 dort größtenteils umgesetzt; **Gap-Analyse §3** im alten Dokument historisch lesen – fachlicher Zielabgleich bleibt dort, **operative Reihenfolge** hier. | + +--- + +## 6. Nächste konkrete Artefakte (nach diesem Plan) + +1. **TenantContext-Spezifikation** (ein Abschnitt in diesem Dokument oder Kurz-ADR): Request-Lebenszyklus, Fehlerbilder, Superadmin. +2. **Endpoint-Audit-Tabelle** (Working-Dokument, bei jedem Merge pflegen bis Stufe C abgeschlossen). +3. **Testplan „Cross-Tenant“** (manuell oder pytest): Minimalsetup zweier Vereine — erste rein-funktionale Tests unter `backend/tests/test_access_layer.py` (ohne DB); Integration folgt. + +**Audit-Tabelle (fortlaufend):** `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md` + +--- + +## 7. Referenzen + +- `.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md` – übergeordnetes Zielbild & Begriffe. +- `backend/club_tenancy.py` – bestehende Bausteine (`assert_club_member`, `exercise_visible_to_profile`, …); Ziel ist Deren schrittweise Zusammenführung unter die neue Zugriffsschicht ohne Big-Bang. + +--- + +**Letzte Aktualisierung:** 2026-05-05 diff --git a/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md b/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md new file mode 100644 index 0000000..3f26dbf --- /dev/null +++ b/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md @@ -0,0 +1,225 @@ +# Multi-Tenancy, Vereins-Membership und Rollenmodell – Zielarchitektur & Umsetzungsplan + +**Status:** verbindliche Zielrichtung (Architekturpapier) +**Stand:** 2026-05-05 +**Bezug:** `functional/shinkan_anforderungsdokument_entwurf.md` §5, §17–18 · `functional/DOMAIN_MODEL.md` (Sichtbarkeit §5.5) · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR-004–008) + +**Operative Reihenfolge & einheitliche Zugriffsschicht:** [`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`](./ACCESS_LAYER_AND_GOVERNANCE_PLAN.md) – dort sind Stufen A–F, Drift-Prävention und die Zurückstellung von Vereinsabo/Limits festgehalten; dieses Dokument bleibt das übergeordnete **Zielbild**. + +--- + +## 1. Zweck + +Dieses Dokument fasst den **Soll-Zustand** für Mandantenfähigkeit (Verein = Mandant), **rollenbasierte Zugriffskontrolle auf Vereinsebene** und ein **Membership-/Limitsystem** zusammen – und definiert einen **phasenweisen Umsetzungsplan**, der mit dem bestehenden Governance-Kern (`visibility`, `club_id`, `created_by`) konsistent bleibt. + +--- + +## 2. Abgleich mit vorhandener Dokumentation + +| Quelle | Inhalt relevant für Tenancy/Rollen | Konsistenz mit Zielbild | +|--------|-----------------------------------|-------------------------| +| `shinkan_anforderungsdokument_entwurf.md` §5.4 | Rollen: Superadmin, Vereinsadmin, Trainer, Co-Trainer, Redakteur | Deckt sich; „Superadmin“ entspricht fachlich **Systemadmin** | +| §17.1 | Erweiterung: Systemadmin, Spartenadmin | Entspricht den gewünschten **Spartenverantwortlichen** | +| §5.5 / §17 | Sichtbarkeit: privat, Verein, Sparte, global, offiziell | DOMAIN_MODEL listet ähnliche Stufen; **technische Durchsetzung** ist noch lückenhaft | +| §18.5 | MVP: Datenmodell mandantenfähig, Rechte zunächst einfach | Bestätigt schrittweise Verschärfung | +| `DOMAIN_MODEL.md` §5.5 | Freigabeebenen inkl. Sparte | Zielbild; DB/API nutzen derzeit überwiegend `private` \| `club` \| `official` | +| `TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` | CURR-004–008: Governance-Kern, spätere Policy | Datenfelder vorbereitet; **Policy/Erzwingung** folgt | +| `working/SHINKAN_PROJECT_SETUP.md` §6 | „Multi-Tenant-Administration“ ausgeschlossen (MVP-Liste) | Historisch; **technische Mandanten** sind dennoch Ziel – UI-Komplexität kontrolliert einführen | + +**Fazit:** Die fachlichen Rollen und Sichtbarkeitsebenen sind **in den funktionalen Docs bereits skizziert**. Es fehlt die **stringente technische Schicht**: Vereinszugehörigkeit, aktiver Vereinskontext, effektive Berechtigungen pro Anfrage und konsequente Filterung bei `club`-sichtbaren Objekten. + +--- + +## 3. Ist-Stand im Code (Gap-Analyse) + +> **Hinweis:** Dieser Abschnitt beschreibt den Ausgangspunkt vor Ausbauschritten (**Mitgliedschaften, gefilterte Vereinsliste, Teilen von Governance für Übungen/Rahmen/Planung** sind bereits angegangen). Verbindliche **offene Arbeit und Reihenfolge** sind im Dokument [`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`](./ACCESS_LAYER_AND_GOVERNANCE_PLAN.md) festgehalten. + +### 3.1 Identität und Rollen + +- `profiles.role` ist eine **globale** Kennzeichnung (`admin`, `superadmin`, `trainer`, `user`, …). +- **Keine** Tabelle für Vereinsmitgliedschaft mit **Mehrfachrollen pro Verein**. +- Sessions liefern nur `profile_id` + globale `role` (`auth.py` → `get_session`). + +**Konsequenz:** Mehrere Vereine mit unterschiedlichen Rollen pro User sind **nicht modelliert**; ein „Vereinsadmin“ kann nicht sauber von einem reinen Trainer unterschieden werden, sobald beides nur über `profiles.role` laufen soll. + +### 3.2 Organisation & APIs + +- `clubs`, `divisions`, `training_groups` existieren (`002_organization.sql`). +- `GET /api/clubs` listiert **alle** Vereine für jeden eingeloggten Nutzer. +- `POST /api/clubs` erlaubt Anlage für `trainer` und `user` – **nicht** nur Systemadmin. +- Sparten/Gruppen: Schreibzugriff über globale `admin`/`superadmin`, nicht über **Vereinsadmin** im Kontext „sein Verein“. + +**Konsequenz:** Weder **Datenisolation** noch **Produktdifferenzierung** „nur Systemadmin legt Verein an“ sind umgesetzt. + +### 3.3 Trainingsplanung + +- Zugriff auf Einheiten gruppenbasiert: Trainer/Co-Trainer der `training_groups`, plus `lead_trainer_profile_id` (Migration/Pfad `training_planning`). +- `_assert_club_visible_for_trainer` bindet Vereinssicht für Teile der Planung an „aktive Gruppe als Trainer/Co im Verein“ – **kein** generelles Mitgliedschaftsmodell. + +**Konsequenz:** Planung ist **gruppenzentriert**, nicht **mitgliedschaftszentriert**; Vereinsweite Aufgaben des Vereinsadmins fehlen als konsistentes Recht. + +### 3.4 Governance / Sichtbarkeit (kritisch) + +- Übungen (`list_exercises`): Bedingung sinngemäß „official OR club OR created_by = ich“ – **`club` gilt für alle Mandanten**, ohne Prüfung `exercise.club_id` ∈ Vereine des Nutzers. +- Detailzugriff `private`: nur Owner – **ok**. +- Rahmenprogramme (`training_framework_programs`): Lesen fremder Rahmen über `visibility=club` ist in `_framework_access` **nicht** gelöst (faktisch stark creator-basiert für Nicht-Admins). + +**Konsequenz:** **Cross-Tenant-Leaks** bei als `club` markierten Bibliotheksobjekten sind möglich bzw. Leselogik ist inkonsistent zwischen Modulen. + +### 3.5 Frontend + +- **Stand 2026-05:** `GET /api/profiles/me` liefert `clubs[]`, `active_club_id`; Frontend setzt `X-Active-Club-Id`. Details und Pflicht zur serverseitigen **TenantContext**-Validierung siehe `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`. + +### 3.6 Membership (kommerziell/limits) + +- Mitai-artige Tabellen (`tiers`, `subscriptions`, `tier_limits`, …) sind **nutzerbezogen**, nicht **vereinsbezogen**. +- Kein konzipiertes **`club_subscription` / `club_plan`** im Schema. + +--- + +## 4. Zielarchitektur + +### 4.1 Begriffe + +| Begriff | Definition | +|---------|------------| +| **Mandant** | Immer ein **Verein** (`clubs.id`). | +| **Systemadmin** | Global (`profiles.role` oder dediziertes Flag); darf Plattform-weite Objekte und **Vereinslifecycle** (Anlegen, Zuweisen Hauptverwalter). | +| **Vereinskontext** | Pro Session gewählter aktiver Verein (`active_club_id`), wenn der User Mitglied ist. | +| **Vereinsmitgliedschaft** | Zeile in einer Junction: User ↔ Verein ↔ **eine oder mehrere Rollen**. | +| **Effektive Berechtigung** | Funktion aus: globale Rolle, Mitgliedschaft im aktiven Verein, optional Sparte/Gruppe, Sichtbarkeit des Objekts. | + +### 4.2 Rollenmodell (Schichten) + +**Schicht A – Plattform (global, kleine Menge)** + +- `system_admin` / `superadmin` (bestehende Semantik konsolidieren und benennen). + +**Schicht B – Verein (pro Mitgliedschaft, mehrere Rollen möglich)** + +- `club_admin` – Hauptverwalter:in (ein Verein **genau eine** „primäre“ Admin-Zuweisung empfohlen, siehe 4.4). +- `division_lead` – Spartenverantwortliche:r (Scope: `division_id` optional an Mitgliedschaft gebunden). +- `trainer` – Trainer/Übungsleitung (Abgrenzung zu Co-Trainer siehe Gruppe). +- `content_editor` – Redakteur:in / Inhaltsverantwortliche:r (fachlich wie Anforderungsdoc). + +**Schicht C – Abgeleitet aus Trainingsgruppe (bereits teilweise vorhanden)** + +- Haupttrainer / Co-Trainer über `training_groups.trainer_id` und `co_trainer_ids` (und ggf. `lead_trainer_profile_id` auf Einheit). + +**Mapping Alt → Neu:** Bestehendes `profiles.role` kann Übergangsweise als „Default-Rolle für Pilotverein“ dienen, soll aber mittelfristig **nicht** die einzige Quelle für Vereinsrechte sein. + +### 4.3 Mitgliedschaft und aktiver Verein + +- Neue Kernstruktur (konzeptionell): + **`club_members`** (`profile_id`, `club_id`, `status`, `created_at`, …) + **`club_member_roles`** (`club_member_id`, `role_code`, optional `division_id` für spartenbezogene Rollen). + +- **Aktiver Verein:** + - Persistenz: Nutzereinstellung (`profiles.default_club_id` oder eigene Tabelle `profile_preferences`). + - Pro Request: Header **`X-Active-Club-Id`** oder Query (einheitlich dokumentieren); Server validiert Mitgliedschaft. + +- **Tenant-Switch UI:** Bei Mitgliedschaft in >1 Verein Auswahl im Frontend; alle Listen/Aktionen verwenden aktiven Kontext. + +### 4.4 Vereins-Lebenszyklus + +- Neuer Verein: **nur Systemadmin**; Pflichtfelder: Name, initialer `club_admin` (bestehendes Profil zuweisen oder Einladungsflow später). +- Vereinsadmin verwaltet in „Mein Verein“: Sparten, Gruppen, Trainerzuordnungen, Einladungen (später), interne Sichtbarkeit – **ohne** andere Vereine zu sehen. + +### 4.5 Daten- und Funktionssicht + +| Datenklasse | Leseregel (Ziel) | +|-------------|------------------| +| Global offiziell | Alle authentifizierten (ggf. später thematisch eingeschränkt). | +| Verein (`visibility=club`, `club_id` gesetzt) | Nur Profile mit Mitgliedschaft in **diesem** `club_id`; optional zusätzlich Sparte, wenn `division_id` am Objekt gepflegt wird. | +| Privat | Nur `created_by` (und explizite Shares später). | +| Geplante Einheiten | Wie heute über Gruppe + Trainer/Co; zusätzlich Vereinskontext zur Navigation/Audit. | + +**Einheitlicher Governance-Kern** bleibt wie in CURR-005; Ergänzung: **`division_id`** auf Bibliotheksobjekten, wenn „Sparte“ technisch durchgesetzt werden soll (DOMAIN_MODEL / §17). + +### 4.6 Membership-System (Vereinsabo / Limits) – Konzept + +Ziel: **vereinszentrierte** Vertrags- und Limitlogik, analog zur bestehenden Tier-Infrastuktur, aber an **`club_id`** gebunden. + +**Empfohlene Bausteine:** + +1. **`club_plans`** – Produktdefinition (Name, Features, implizite Limits). +2. **`club_subscriptions`** – (`club_id`, `plan_id`, Status, Laufzeit). +3. **`club_usage_counters`** oder Ableitung aus DB – z. B. aktive Nutzer, aktive Trainingsgruppen (periodisch oder on-write). +4. **Enforcement-Schicht** – zentrale Funktion `assert_club_limit(club_id, metric)` vor `/groups`-POST, Einladungen, etc. + +**Offene Produktentscheidungen** (vor Implementierung festlegen): + +- Zählen „Nutzer“ alle Mitglieder oder nur aktive Trainer? +- Soft-Limits vs. Hard-Stops; Übergang für Pilotverein. + +--- + +## 5. Umsetzungsplan (Phasen) + +### Phase 0 – Foundations (kurz, risikoarm) + +- Begriffe und Enums in einem Ort dokumentieren (dieses Dokument + Eintrag in `DATABASE_SCHEMA.md` nach Migration). +- Audit-Liste aller Router mit `club_id` / `visibility` / Listen-Endpunkten. + +### Phase 1 – Datenmodell Mitgliedschaft & Hauptverwalter + +- Migration: `club_members`, `club_member_roles`; optional `clubs.primary_admin_profile_id` (oder Primär-Flag auf Mitgliedschaft). +- Backfill: bestehende Trainer aus `training_groups` → minimale Mitgliedschaft `trainer` im jeweiligen Verein (Skript/Migration mit dokumentierter Annahme CURR-008). +- **Breaking/API:** keine – nur erweiterte Datenbasis. + +### Phase 2 – Aktiver Vereinskontext & API-Kontrakte + +- Backend: Validierung `X-Active-Club-Id` gegen Mitgliedschaft; Hilfsfunktion `get_effective_club_context(session, header)`. +- `GET /api/clubs` für Nicht-Systemadmins: **nur Vereine mit Mitgliedschaft**. +- `POST /api/clubs`: nur Systemadmin; Vergabe `club_admin`-Mitgliedschaft. +- Frontend: Club-Switcher + Persistenz. + +### Phase 3 – Effektive Berechtigungen (RBAC) + +- **Mitgliederverwaltung per API (ohne UI):** `GET/POST /api/clubs/{club_id}/members`, `GET/PUT/DELETE /api/clubs/{club_id}/members/{profile_id}` — nur Plattform-Admin oder `club_admin` im Zielverein (Stand Code **0.8.16**). +- Zentrale Modulfunktion z. B. `authorization/club_permissions.py`: + `can(club_id, profile_id, permission, division_id=None)` — optional später; aktuell `club_tenancy.can_manage_club_org` / `has_club_role`. +- Router schrittweise umbinden: Sparten/Gruppen CRUD nach Rolle `club_admin` im Kontext; Systemadmin unverändert Vollzugriff. + +### Phase 4 – Sichtbarkeit & Leaks schließen + +- **Übungen:** `club`-Sichtbarkeit nur bei Übereinstimmung `exercise.club_id` mit Mitgliedschaft (und später `division`). +- **Trainingsplan-Vorlagen** (`training_plan_templates`) und **Rahmenprogramme** (`training_framework_programs`): gleiches Muster für Listen/GET (Stand **0.8.17**); Schreiben weiterhin nur Ersteller oder Plattform-Admin. +- Gleiches Muster für Progressionsgraphen, ggf. Medien (offen). +- Tests: zwei Vereine, zwei Nutzer, keine Kreuzzugriffe. + +### Phase 5 – Mitgliedschaft / Limits + +- Tabellen `club_plans`, `club_subscriptions`; Integration mit Enforcement vor relevanten Writes. +- UI „Mein Verein“: Kennzahlenteaser oder Hinweise bei Limit (minimal). + +### Phase 6 – Verfeinerung + +- Einladungsflow (E-Mail), Mehrfachrollen-UI, Audit-Log für Admin-Aktionen. +- Optionale thematische Sperren (Karate vs. Gewaltschutz) als eigene Policy-Schicht. + +--- + +## 6. Abhängigkeiten und Risiken + +- **Übergang:** Pilot mit einem Verein nutzt weiterhin einfache Defaults; Multi-Verein erfordert Pflicht **aktiver Kontext**. +- **Performance:** Mitgliedschaft und Rolle sollten **einmal pro Request** geladen und gecacht werden (Request-Scope). +- **Konsistenz mit Mitai:** Nutzer-Tiers können parallel bleiben; **vereinsbezogene** Limits sind die neue Quelle für Shinkan-spezifische Kaufmotive. + +--- + +## 7. Nächste konkrete Artefakte + +1. TenantContext-Spezifikation & Endpoint-Audit (siehe `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` §6). +2. Aktualisierung `DATABASE_SCHEMA.md` bei neuen Governance-/Scope-Feldern. +3. Sicherheits-Review der `list_*`-Endpunkte mit `club`-Visibility (fortlaufend bis Governance vereinheitlicht). + +--- + +## 8. Verwandtes Dokument + +- **`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`** – verbindliche Umsetzungsstufen A–F, einheitliche Zugriffsschicht, Scope-Erweiterung (`division`, später Community), Capability-Vorbereitung ohne Custom-Rollen-UI; Vereinsabo explizit zurückgestellt. + +--- + +**Letzte Aktualisierung:** 2026-05-05 diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md new file mode 100644 index 0000000..87ac058 --- /dev/null +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -0,0 +1,33 @@ +# Endpoint-Audit: Mandanten & Governance + +Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. + +| Router / Bereich | Beispiel-Endpunkt | tenant-relevant | `Depends(get_tenant_context)` / Kontext | Governance geprüft (Liste+Detail) | Notizen | +|------------------|-------------------|-----------------|----------------------------------------|-------------------------------------|---------| +| profiles | `GET /api/profiles/me` | ja | `resolve_tenant_context` inline (`invalid_header_policy=ignore`) | teils | + `effective_club_id`; veralteter Header bricht Refresh nicht | +| profiles | `PUT /api/profiles/{id}`, `PUT /api/profile` | ja | `get_tenant_context` | `active_club_id` Mitgliedschaft | Validiert `X-Active-Club-Id` konsistent zu Mitgliedschaft | +| clubs | geschützte `/api/clubs*`, `/divisions*`, `/groups*` | ja | `get_tenant_context` | Mitgliedschaft / `can_manage_*` | Öffentlich: `/clubs/public-directory` ohne Auth | +| club_memberships | `/clubs/{id}/members*` | ja | `get_tenant_context` | ja | | +| club_join_requests | `/me/club-join-requests`, `/clubs/{id}/join-requests*` | ja | `get_tenant_context` | ja | | +| exercises | `PATCH /api/exercises/bulk-metadata` | ja | `get_tenant_context` | ja | Liste: UI-Mehrfachwahl; bis 500 IDs; nur Ersteller oder Plattform-Admin | +| exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | | +| exercise_progression_graphs | `/api/exercise-progression-graphs*` | ja | `get_tenant_context` | Liste wie Bibliothek; Schreiben Ersteller/Plattform-Admin | Kanten: Lesen wenn Graph lesbar | +| training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage Default club_id | +| training_framework_programs | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Liste + POST Default club_id | +| admin_users | `GET /api/admin/users` | Plattform | `require_auth` | Admin-Rolle | EXEMPT `check_access_layer_hints.py` | +| auth | `/api/auth/*` | nein | — | Login/Session | EXEMPT | +| catalogs | Katalog-CRUD | nein (global) | `require_auth` | Admin/Trainer je Endpoint | EXEMPT; bei späterem `club_id` nachziehen | +| skills | `/api/skills*` | nein (global) | `require_auth` | je Endpoint | EXEMPT | +| maturity_models | Admin-Matrix | nein (global) | `require_auth` | Admin | EXEMPT | +| matrix_stack_bundle | Export/Import Bundles | Plattform/Test | `require_auth` | Admin | EXEMPT | +| import_wiki / import_wiki_admin | Wiki-Import | Werkzeug | `require_auth`/Admin | Admin | EXEMPT | + +**Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen. + +Letzte Änderung: 2026-05-05 — Cursor-Regel + Architektur-/Coding-Pflicht + Script `backend/scripts/check_access_layer_hints.py`; Katalog-Router im Audit als global dokumentiert. + +--- + +### Hinweis `GET /training-units` + +Kein impliziter Filter nach `effective_club_id` (Multi-Verein-Kalender); bei Bedarf `club_id` Query setzen. diff --git a/.claude/rules/ARCHITECTURE.md b/.claude/rules/ARCHITECTURE.md index d5e4e3b..066dbd3 100644 --- a/.claude/rules/ARCHITECTURE.md +++ b/.claude/rules/ARCHITECTURE.md @@ -55,6 +55,21 @@ return {"error": "not found"} return {"message": "Fehler", "success": False} ``` +### 1.4 Mandanten & Zugriffsschicht (Shinkan / ACCESS_LAYER) + +**Verbindlicher Rahmen:** `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` +**Fortlaufendes Inventar:** `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md` + +**Definition of Done für neue oder geänderte geschützte APIs**, sobald Daten **Verein**, **Sichtbarkeit** oder **mandantenbezogene Listen** betreffen: + +1. Request-Kontext: `Depends(get_tenant_context)` (oder dokumentierte Ausnahme mit Kommentar `# ACCESS_LAYER exempt:` + Audit). +2. Lesen: gleiche Sichtbarkeitslogik wie vergleichbare Bibliotheks-/Planungsartefakte (`library_content_visibility_sql`, `exercise_visible_to_profile` / zentrale Helfer — nicht ad-hoc „alles aus der Tabelle“). +3. Schreiben: `assert_valid_governance_visibility` wo `visibility` / `club_id` gesetzt oder geändert wird. +4. Audit-Tabelle und bei Bedarf die EXEMPT-Liste im Script `backend/scripts/check_access_layer_hints.py` aktualisieren. +5. Optional vor Commit: `python backend/scripts/check_access_layer_hints.py` (mit `ACCESS_LAYER_STRICT=1` schlägt bei neuen Verstößen fehl). + +Router ohne Vereinsbezug (z. B. globale Kataloge, Auth-Login) bleiben bewusst ohne `get_tenant_context`; sie stehen im Script auf der **EXEMPT**-Liste. + --- ## 2. Versionskontrollsystem diff --git a/.claude/rules/CODING_RULES.md b/.claude/rules/CODING_RULES.md index bc219fe..3bdc1d2 100644 --- a/.claude/rules/CODING_RULES.md +++ b/.claude/rules/CODING_RULES.md @@ -4,17 +4,32 @@ Diese Regeln IMMER befolgen. Sie basieren auf Erfahrungen aus der Entwicklung. ## Backend -### 1. Auth auf jeden Endpoint +### 1. Auth und Mandantenkontext (Shinkan) + +**Jeder geschützte Endpoint braucht Auth.** Sofern der Endpoint **Vereinsdaten**, **visibility/club_id** oder **mandanten-gefilterte Listen** betrifft, zusätzlich **`TenantContext`** — nicht nur `require_auth` allein. + ```python -# Jeder neue Endpoint braucht Auth: -@router.get("/neuer-endpoint") -def neuer_endpoint(session: dict = Depends(require_auth)): - pid = session['profile_id'] +from tenant_context import TenantContext, get_tenant_context + +@router.get("/beispiel") +def beispiel(tenant: TenantContext = Depends(get_tenant_context)): + pid = tenant.profile_id + role = tenant.global_role + club_ctx = tenant.effective_club_id # kann None sein (z. B. Plattform-Admin) ``` -### 2. Profile-ID aus Session – nie aus Header +- **Bibliotheks-/Planungslisten:** Filter wie bestehende Module (`library_content_visibility_sql` oder gleiche Leseprüfung); keine vollständige Tabelle für normale Nutzer. +- **Schreiben:** `assert_valid_governance_visibility` aus `club_tenancy`, wenn `visibility` / `club_id` gesetzt werden. +- **Dokumentation:** Änderungen in `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md` festhalten. +- **Ausnahmen** (z. B. reiner Login, globale Kataloge): Kommentar `# ACCESS_LAYER exempt: …` und ggf. Eintrag in `backend/scripts/check_access_layer_hints.py`. + +Reine Plattform-Admin-Router (ohne Vereinskontext) können bei Bedarf weiter `Depends(require_auth)` nutzen — dann im Audit als „Plattform“ kennzeichnen. + +### 2. Profile-ID aus TenantContext oder Session — nie aus freiem Header + ```python -pid = session['profile_id'] # ✅ +pid = tenant.profile_id # ✅ bei Depends(get_tenant_context) +# oder session['profile_id'] nur wenn Endpoint ausdrücklich ohne TenantContext (Ausnahme dokumentieren) # Nicht: request.headers.get('X-Profile-Id') ❌ ``` diff --git a/.cursor/rules/access-layer.mdc b/.cursor/rules/access-layer.mdc new file mode 100644 index 0000000..d563d04 --- /dev/null +++ b/.cursor/rules/access-layer.mdc @@ -0,0 +1,36 @@ +--- +description: Mandanten & Governance — TenantContext, Sichtbarkeit, keine Schnellpfade +globs: backend/routers/*.py +alwaysApply: false +--- + +# Zugriffsschicht (Shinkan) + +Vor neuen oder geänderten Endpoints in `backend/routers/` kurz prüfen: + +1. **Pflichtlektüre** (bei inhaltsbezogenen APIs): `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` und `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md`. + +2. **Auth**: kein geschützter Endpoint ohne `Depends(require_auth)` bzw. eingebettet in `Depends(get_tenant_context)` (der holt die Session bereits). + +3. **Verein / Sichtbarkeit** (`visibility`, `club_id`, Mitglieder-Inhalte): + `tenant: TenantContext = Depends(get_tenant_context)` verwenden; Profile-ID aus `tenant.profile_id`, Rolle aus `tenant.global_role`. + +4. **Bibliothekslisten** (Übungen, Vorlagen, Rahmenprogramme, Progressionsgraphen, gleiche Semantik): Filter über `library_content_visibility_sql(...)` bzw. gleiche Leseregel wie bestehende Module — nicht „alle Zeilen aus SELECT“. + +5. **Schreiben mit Governance**: bei `visibility`/`club_id`-Änderungen `assert_valid_governance_visibility`; bei `club` ohne `club_id` im Body → `tenant.effective_club_id` oder klare 400-Hinweise (wie bei Übungen). + +6. **Ausnahmen** nur mit kurzem Kommentar im Code: `# ACCESS_LAYER exempt: …` und Eintrag im Audit oder in `backend/scripts/check_access_layer_hints.py` (EXEMPT-Liste). + +7. **Nach Merge**: eine Zeile `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md` anpassen, wenn sich Tenant oder Governance ändert. + +```python +# ❌ Schnellpfad: nur Session, obwohl Vereinsdaten betroffen +@router.get("/foo") +def foo(session=Depends(require_auth)): + ... + +# ✅ Konsistent zu clubs/exercises/planning +@router.get("/foo") +def foo(tenant: TenantContext = Depends(get_tenant_context)): + ... +``` diff --git a/CLAUDE.md b/CLAUDE.md index fa53f8f..85026c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,6 +5,9 @@ > VOR jeder Implementierung lesen: > | Architektur-Regeln | `.claude/rules/ARCHITECTURE.md` | > | Coding-Regeln | `.claude/rules/CODING_RULES.md` | +> | Zugriffsschicht (Multi-Tenancy, Governance) | `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` | +> | Endpoint-Audit (Tenant/Governance) | `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md` | +> | Cursor-Regel Zugriffsschicht | `.cursor/rules/access-layer.mdc` | > | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` | > | Setup-Dokument | `.claude/docs/working/SHINKAN_PROJECT_SETUP.md` | > | Anforderungen | `.claude/docs/functional/SHINKAN_REQUIREMENTS.md` | diff --git a/backend/club_tenancy.py b/backend/club_tenancy.py new file mode 100644 index 0000000..e81e374 --- /dev/null +++ b/backend/club_tenancy.py @@ -0,0 +1,173 @@ +""" +Vereins-Mandanten: Mitgliedschaften, aktiver Vereinskontext, einfache Berechtigungen. + +Siehe .claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md +""" +from typing import Any, Dict, List, Optional, Set + +from fastapi import HTTPException + +def is_platform_admin(role: Optional[str]) -> bool: + return (role or "").lower() in ("admin", "superadmin") + + +def club_ids_for_profile(cur, profile_id: int) -> Set[int]: + cur.execute( + """ + SELECT club_id FROM club_members + WHERE profile_id = %s AND status = 'active' + """, + (profile_id,), + ) + return {r["club_id"] for r in cur.fetchall()} + + +def assert_club_member(cur, profile_id: int, club_id: int) -> None: + cur.execute( + """ + SELECT 1 FROM club_members + WHERE profile_id = %s AND club_id = %s AND status = 'active' + LIMIT 1 + """, + (profile_id, club_id), + ) + if not cur.fetchone(): + raise HTTPException(status_code=403, detail="Keine Mitgliedschaft in diesem Verein") + + +def has_club_role(cur, profile_id: int, club_id: int, *role_codes: str) -> bool: + if not role_codes: + return False + cur.execute( + """ + SELECT 1 + FROM club_members cm + INNER JOIN club_member_roles r ON r.club_member_id = cm.id + WHERE cm.profile_id = %s AND cm.club_id = %s AND cm.status = 'active' + AND r.role_code IN ({ph}) + LIMIT 1 + """.format(ph=",".join(["%s"] * len(role_codes))), + (profile_id, club_id, *role_codes), + ) + return cur.fetchone() is not None + + +def can_manage_club_org(cur, profile_id: int, club_id: int, global_role: Optional[str]) -> bool: + """Sparten/Gruppen/Struktur: Vereinsadmin oder Plattform-Admin.""" + if is_platform_admin(global_role): + return True + return has_club_role(cur, profile_id, club_id, "club_admin") + + +def can_plan_in_club(cur, profile_id: int, club_id: int, global_role: Optional[str]) -> bool: + """Trainingsgruppen anlegen / planen: Admin-Rollen im Verein oder Plattform.""" + if is_platform_admin(global_role): + return True + return has_club_role( + cur, profile_id, club_id, "club_admin", "trainer", "content_editor", "division_lead" + ) + + +def memberships_with_roles(cur, profile_id: int, active_only: bool = True) -> List[Dict[str, Any]]: + status_filter = "AND cm.status = 'active'" if active_only else "" + cur.execute( + f""" + SELECT c.id, c.name, c.abbreviation, c.status, + cm.status AS membership_status, + COALESCE( + ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL), + ARRAY[]::varchar[] + ) AS roles + FROM club_members cm + INNER JOIN clubs c ON c.id = cm.club_id + LEFT JOIN club_member_roles r ON r.club_member_id = cm.id + WHERE cm.profile_id = %s {status_filter} + GROUP BY c.id, c.name, c.abbreviation, c.status, cm.status + ORDER BY c.name + """, + (profile_id,), + ) + out: List[Dict[str, Any]] = [] + for row in cur.fetchall(): + d = dict(row) + roles = d.get("roles") or [] + if hasattr(roles, "tolist"): + roles = roles.tolist() + d["roles"] = list(roles) + out.append(d) + return out + + +_GOVERNANCE_VISIBILITY = frozenset({"private", "club", "official"}) + + +def assert_valid_governance_visibility( + cur, + profile_id: int, + role: Optional[str], + visibility: str, + club_id: Optional[int], +) -> None: + """Pflicht club_id bei visibility=club; Mitgliedschaft außer Plattform-Admin; official nur Plattform-Admin.""" + if visibility not in _GOVERNANCE_VISIBILITY: + raise HTTPException(status_code=400, detail="Ungültige visibility") + if visibility == "official" and not is_platform_admin(role): + raise HTTPException( + status_code=403, + detail="Nur Plattform-Admins dürfen offizielle Inhalte setzen", + ) + if visibility == "club": + if club_id is None: + raise HTTPException(status_code=400, detail="club_id ist bei visibility=club erforderlich") + if is_platform_admin(role): + cur.execute("SELECT 1 FROM clubs WHERE id = %s", (club_id,)) + if not cur.fetchone(): + raise HTTPException(status_code=400, detail="Verein nicht gefunden") + else: + assert_club_member(cur, profile_id, club_id) + + +def library_content_visible_to_profile( + cur, + profile_id: int, + visibility: str, + content_club_id: Optional[int], + created_by: Optional[int], + global_role: Optional[str], +) -> bool: + """Leserechte wie Übungen für alle Objekte mit visibility/club_id/created_by (Bibliothek & Co.).""" + return exercise_visible_to_profile( + cur, profile_id, visibility, content_club_id, created_by, global_role + ) + + +def exercise_visible_to_profile( + cur, + profile_id: int, + visibility: str, + exercise_club_id: Optional[int], + created_by: Optional[int], + global_role: Optional[str], +) -> bool: + """Leserechte einer Übung. Für neue Codepfade lieber `library_content_visible_to_profile` verwenden.""" + if is_platform_admin(global_role): + return True + if visibility == "official": + return True + if created_by is not None and created_by == profile_id: + return True + if visibility == "private": + return False + if visibility == "club": + if exercise_club_id is None: + return False + cur.execute( + """ + SELECT 1 FROM club_members + WHERE profile_id = %s AND club_id = %s AND status = 'active' + LIMIT 1 + """, + (profile_id, exercise_club_id), + ) + return cur.fetchone() is not None + return False diff --git a/backend/db_init.py b/backend/db_init.py index 6714613..af6b9c9 100644 --- a/backend/db_init.py +++ b/backend/db_init.py @@ -32,13 +32,13 @@ def wait_for_postgres(max_retries=30): try: conn = get_connection() conn.close() - print("✓ PostgreSQL ready") + print("[OK] PostgreSQL ready") return True except OperationalError: print(f" Waiting for PostgreSQL... (attempt {i}/{max_retries})") time.sleep(2) - print(f"✗ PostgreSQL not ready after {max_retries} attempts") + print(f"[FAIL] PostgreSQL not ready after {max_retries} attempts") return False def check_table_exists(table_name="profiles"): @@ -71,10 +71,10 @@ def load_schema(schema_file="/app/schema.sql"): conn.commit() cur.close() conn.close() - print("✓ Schema loaded from schema.sql") + print("[OK] Schema loaded from schema.sql") return True except Exception as e: - print(f"✗ Error loading schema: {e}") + print(f"[FAIL] Error loading schema: {e}") return False def get_profile_count(): @@ -146,10 +146,10 @@ def apply_migration(filepath, filename): conn.commit() cur.close() conn.close() - print(f" ✓ Applied: {filename}") + print(f" [OK] Applied: {filename}") return True except Exception as e: - print(f" ✗ Failed to apply {filename}: {e}") + print(f" [FAIL] Failed to apply {filename}: {e}") return False def run_migrations(migrations_dir="/app/migrations"): @@ -158,7 +158,7 @@ def run_migrations(migrations_dir="/app/migrations"): import re if not os.path.exists(migrations_dir): - print("✓ No migrations directory found") + print("[OK] No migrations directory found") return True # Ensure migration tracking table exists @@ -174,7 +174,7 @@ def run_migrations(migrations_dir="/app/migrations"): migration_files = [f for f in all_files if migration_pattern.match(os.path.basename(f))] if not migration_files: - print("✓ No migration files found") + print("[OK] No migration files found") return True # Apply pending migrations @@ -185,7 +185,7 @@ def run_migrations(migrations_dir="/app/migrations"): pending.append((filepath, filename)) if not pending: - print(f"✓ All {len(applied)} migrations already applied") + print(f"[OK] All {len(applied)} migrations already applied") return True print(f" Found {len(pending)} pending migration(s)...") @@ -211,12 +211,12 @@ if __name__ == "__main__": if not load_schema(): sys.exit(1) else: - print("✓ Schema already exists") + print("[OK] Schema already exists") # Run migrations print("\nRunning database migrations...") if not run_migrations(): - print("✗ Migration failed") + print("[FAIL] Migration failed") sys.exit(1) # Check for migration @@ -232,14 +232,14 @@ if __name__ == "__main__": from migrate_to_postgres import main as migrate migrate() except Exception as e: - print(f"✗ Migration failed: {e}") + print(f"[FAIL] Migration failed: {e}") sys.exit(1) elif os.path.exists(sqlite_db) and profile_count > 0: - print(f"⚠ SQLite DB exists but PostgreSQL already has {profile_count} profiles") + print(f"[WARN] SQLite DB exists but PostgreSQL already has {profile_count} profiles") print(" Skipping migration (already migrated)") elif not os.path.exists(sqlite_db): - print("✓ No SQLite database found (fresh install or already migrated)") + print("[OK] No SQLite database found (fresh install or already migrated)") else: - print("✓ No migration needed") + print("[OK] No migration needed") - print("\n✓ Database initialization complete") + print("\n[OK] Database initialization complete") diff --git a/backend/main.py b/backend/main.py index ad11a2e..2699427 100644 --- a/backend/main.py +++ b/backend/main.py @@ -21,20 +21,20 @@ from version import APP_VERSION, BUILD_DATE, DB_SCHEMA_VERSION, MODULE_VERSIONS # Run database migrations before API start — halbes Schema ist schlimmer als kein Start # Lokal ohne DB / nur Tests: SKIP_DB_MIGRATE=1 if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() in ("1", "true", "yes"): - print("⚠ SKIP_DB_MIGRATE=1 — Migrationen wurden übersprungen (nur für Entwicklung ohne DB)") + print("[SKIP_DB_MIGRATE] Migrationen uebersprungen (nur fuer Entwicklung ohne DB)") else: try: import run_migrations rc = run_migrations.main() if rc != 0: - print(f"✗ Datenbank-Migration fehlgeschlagen (Exit-Code {rc}). Start abgebrochen.") + print(f"[FAIL] Datenbank-Migration fehlgeschlagen (Exit-Code {rc}). Start abgebrochen.") sys.exit(1) - print("✓ Database migrations completed") + print("[OK] Database migrations completed") except SystemExit: raise except Exception as e: - print(f"✗ Migration-Laufzeitfehler: {e}") + print(f"[FAIL] Migration-Laufzeitfehler: {e}") sys.exit(1) from routers.auth import limiter as auth_rate_limiter @@ -99,6 +99,8 @@ def health_ready(): "skill_categories", "maturity_models", "sessions", + "club_members", + "club_member_roles", ) tables: dict = {} err: Optional[str] = None @@ -152,13 +154,16 @@ def read_root(): } # Register routers -from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin +from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin app.include_router(auth.router) app.include_router(profiles.router) app.include_router(exercises.router) app.include_router(exercise_progression_graphs.router) app.include_router(clubs.router) +app.include_router(club_memberships.router) +app.include_router(club_join_requests.router) +app.include_router(admin_users.router) app.include_router(skills.router) app.include_router(training_planning.router) app.include_router(training_framework_programs.router) diff --git a/backend/migrations/039_club_membership_rbac.sql b/backend/migrations/039_club_membership_rbac.sql new file mode 100644 index 0000000..7c9ae34 --- /dev/null +++ b/backend/migrations/039_club_membership_rbac.sql @@ -0,0 +1,119 @@ +-- Migration 039: Vereins-Mitgliedschaft, Rollen pro Verein, aktiver Vereinskontext (Multi-Tenancy Phase 1) +-- Erstellt: 2026-05-05 + +-- Mitgliedschaft Profil ↔ Verein +CREATE TABLE club_members ( + id SERIAL PRIMARY KEY, + profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(profile_id, club_id) +); + +CREATE INDEX idx_club_members_profile ON club_members(profile_id); +CREATE INDEX idx_club_members_club ON club_members(club_id); +CREATE INDEX idx_club_members_status ON club_members(status); + +-- Rollen pro Mitgliedschaft (role_code: club_admin, trainer, division_lead, content_editor) +CREATE TABLE club_member_roles ( + id SERIAL PRIMARY KEY, + club_member_id INT NOT NULL REFERENCES club_members(id) ON DELETE CASCADE, + role_code VARCHAR(50) NOT NULL, + division_id INT REFERENCES divisions(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE (club_member_id, role_code) +); + +CREATE INDEX idx_club_member_roles_member ON club_member_roles(club_member_id); +CREATE INDEX idx_club_member_roles_code ON club_member_roles(role_code); + +-- Hauptverwalter:in (fachliche Referenz; Rechte über club_member_roles.club_admin) +ALTER TABLE clubs ADD COLUMN IF NOT EXISTS primary_admin_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL; + +CREATE INDEX idx_clubs_primary_admin ON clubs(primary_admin_profile_id); + +-- Persistenz gewählter aktiver Verein (UI); Request-Header kann überschreiben +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS active_club_id INT REFERENCES clubs(id) ON DELETE SET NULL; + +CREATE INDEX idx_profiles_active_club ON profiles(active_club_id); + +-- ── Backfill: Trainer/Co-Trainer aus Trainingsgruppen → Mitgliedschaft + Rolle trainer +INSERT INTO club_members (profile_id, club_id, status) +SELECT DISTINCT t.trainer_id, t.club_id, 'active' +FROM training_groups t +WHERE t.trainer_id IS NOT NULL +ON CONFLICT (profile_id, club_id) DO NOTHING; + +INSERT INTO club_members (profile_id, club_id, status) +SELECT DISTINCT elem::int, x.club_id, 'active' +FROM ( + SELECT club_id, co_trainer_ids + FROM training_groups + WHERE CASE WHEN jsonb_typeof(co_trainer_ids) = 'array' + THEN jsonb_array_length(co_trainer_ids) + ELSE 0 END > 0 +) x, +LATERAL jsonb_array_elements_text(x.co_trainer_ids) AS elem +ON CONFLICT (profile_id, club_id) DO NOTHING; + +INSERT INTO club_member_roles (club_member_id, role_code) +SELECT cm.id, 'trainer' +FROM club_members cm +WHERE NOT EXISTS ( + SELECT 1 FROM club_member_roles r + WHERE r.club_member_id = cm.id AND r.role_code = 'trainer' +); + +-- Pro Verein: kleinste trainer_id einer Gruppe als primärer Admin (Pilot / CURR-008-Analog) +UPDATE clubs c +SET primary_admin_profile_id = sub.pid +FROM ( + SELECT DISTINCT ON (club_id) + club_id, + trainer_id AS pid + FROM training_groups + WHERE trainer_id IS NOT NULL + ORDER BY club_id, id ASC +) sub +WHERE c.id = sub.club_id + AND c.primary_admin_profile_id IS NULL; + +INSERT INTO club_member_roles (club_member_id, role_code) +SELECT cm.id, 'club_admin' +FROM club_members cm +INNER JOIN clubs cl ON cl.id = cm.club_id AND cl.primary_admin_profile_id = cm.profile_id +WHERE NOT EXISTS ( + SELECT 1 FROM club_member_roles r + WHERE r.club_member_id = cm.id AND r.role_code = 'club_admin' +); + +-- Globale Admins: in jedem bestehenden Verein als club_admin spiegeln (volle Plattform-Sicht) +INSERT INTO club_members (profile_id, club_id, status) +SELECT p.id, c.id, 'active' +FROM profiles p +CROSS JOIN clubs c +WHERE p.role IN ('admin', 'superadmin') +ON CONFLICT (profile_id, club_id) DO NOTHING; + +INSERT INTO club_member_roles (club_member_id, role_code) +SELECT cm.id, 'club_admin' +FROM club_members cm +INNER JOIN profiles p ON p.id = cm.profile_id AND p.role IN ('admin', 'superadmin') +WHERE NOT EXISTS ( + SELECT 1 FROM club_member_roles r + WHERE r.club_member_id = cm.id AND r.role_code = 'club_admin' +); + +-- Default aktiver Verein: einziger Verein des Nutzers +UPDATE profiles p +SET active_club_id = sub.only_club +FROM ( + SELECT profile_id, MIN(club_id) AS only_club + FROM club_members + WHERE status = 'active' + GROUP BY profile_id + HAVING COUNT(DISTINCT club_id) = 1 +) sub +WHERE p.id = sub.profile_id AND (p.active_club_id IS NULL); diff --git a/backend/migrations/040_club_membership_requests.sql b/backend/migrations/040_club_membership_requests.sql new file mode 100644 index 0000000..976981a --- /dev/null +++ b/backend/migrations/040_club_membership_requests.sql @@ -0,0 +1,29 @@ +-- Migration 040: Antrag auf Vereinsbeitritt (pending → accept/reject durch Vereins-/Plattform-Admin) + +CREATE TABLE IF NOT EXISTS club_membership_requests ( + id SERIAL PRIMARY KEY, + profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'accepted', 'rejected', 'withdrawn')), + message TEXT, + decided_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL, + decided_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_club_membership_requests_pending + ON club_membership_requests (profile_id, club_id) + WHERE status = 'pending'; + +CREATE INDEX IF NOT EXISTS idx_club_membership_requests_club_status + ON club_membership_requests (club_id, status); + +CREATE INDEX IF NOT EXISTS idx_club_membership_requests_profile + ON club_membership_requests (profile_id); + +DROP TRIGGER IF EXISTS club_membership_requests_update ON club_membership_requests; +CREATE TRIGGER club_membership_requests_update + BEFORE UPDATE ON club_membership_requests + FOR EACH ROW EXECUTE FUNCTION update_timestamp(); diff --git a/backend/migrations/041_bootstrap_superadmin.sql b/backend/migrations/041_bootstrap_superadmin.sql new file mode 100644 index 0000000..82c6bcb --- /dev/null +++ b/backend/migrations/041_bootstrap_superadmin.sql @@ -0,0 +1,20 @@ +-- Migration 041: Super-Admin für bestehende Installationen +-- Bisheriger Bootstrap (registration_role) setzte nur 'admin'. Viele Endpunkte +-- (z. B. Verein löschen, Super-Rolle vergeben) verlangen 'superadmin'. +-- Wenn noch kein Super-Admin existiert: den ältesten Nutzer mit role = admin hochstufen. + +UPDATE profiles p +SET role = 'superadmin', updated_at = NOW() +FROM ( + SELECT id + FROM profiles + WHERE lower(trim(COALESCE(role, ''))) = 'admin' + ORDER BY id ASC + LIMIT 1 +) sub +WHERE p.id = sub.id + AND NOT EXISTS ( + SELECT 1 + FROM profiles + WHERE lower(trim(COALESCE(role, ''))) = 'superadmin' + ); diff --git a/backend/models.py b/backend/models.py index e2563e8..881bfa6 100644 --- a/backend/models.py +++ b/backend/models.py @@ -19,6 +19,7 @@ class RegisterRequest(BaseModel): email: EmailStr password: str name: Optional[str] = None + requested_club_id: Optional[int] = Field(default=None, ge=1) class PasswordResetRequest(BaseModel): email: str @@ -34,6 +35,12 @@ class ProfileCreate(BaseModel): class ProfileUpdate(BaseModel): name: Optional[str] = None email: Optional[str] = None + active_club_id: Optional[int] = None + role: Optional[str] = Field( + default=None, + description="Portal-Rolle: user, trainer, admin, superadmin (nur Plattform-Admin)", + ) + tier: Optional[str] = Field(default=None, max_length=50) class ProfileResponse(BaseModel): id: int diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..4584de7 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +pythonpath = . diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..784f297 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,3 @@ +# Entwicklung / CI — zusätzlich zu backend/requirements.txt installieren: +# pip install -r backend/requirements.txt -r backend/requirements-dev.txt +pytest>=8.0,<9 diff --git a/backend/routers/admin_users.py b/backend/routers/admin_users.py new file mode 100644 index 0000000..2d32f41 --- /dev/null +++ b/backend/routers/admin_users.py @@ -0,0 +1,41 @@ +""" +Plattform-Admin: Übersicht aller Nutzer inkl. Vereinsmitgliedschaften (ohne Passwort-Hashes). +""" +from typing import Any, Dict, List + +from fastapi import APIRouter, Depends, HTTPException + +from auth import require_auth +from club_tenancy import is_platform_admin, memberships_with_roles +from db import get_db, get_cursor, r2d + +router = APIRouter(prefix="/api/admin", tags=["admin_users"]) + +_SAFE_PROFILE_COLS = """ + id, name, email, role, tier, email_verified, active_club_id, + created_at, updated_at, auth_type +""" + + +@router.get("/users") +def list_platform_users(session: dict = Depends(require_auth)): + """Alle Profile mit Vereinen/Rollen — nur Portal-Admin (admin oder superadmin).""" + role = (session.get("role") or "").lower() + if not is_platform_admin(role): + raise HTTPException(status_code=403, detail="Nur Portal-Administratoren") + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + f""" + SELECT {_SAFE_PROFILE_COLS.strip()} + FROM profiles + ORDER BY COALESCE(lower(trim(email)), ''), id + """ + ) + rows: List[Dict[str, Any]] = [] + for r in cur.fetchall(): + d = r2d(r) + d["clubs"] = memberships_with_roles(cur, int(d["id"]), active_only=False) + rows.append(d) + return rows diff --git a/backend/routers/auth.py b/backend/routers/auth.py index b740576..cb8bcc8 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -26,7 +26,7 @@ limiter = Limiter(key_func=get_remote_address) @router.post("/login") -@limiter.limit("5/minute") +@limiter.limit("30/minute") async def login(req: LoginRequest, request: Request): """Login with email + password.""" with get_db() as conn: @@ -78,8 +78,8 @@ def get_me(session: dict=Depends(require_auth)): """Get current user info.""" pid = session['profile_id'] # Import here to avoid circular dependency - from routers.profiles import get_profile - return get_profile(pid, session) + from routers.profiles import profile_document + return profile_document(pid) @router.get("/status") @@ -187,9 +187,10 @@ def verification_link(token: str) -> str: def registration_role(cur, email_lower: str) -> str: """ - bootstrap: erste Registrierung in leerer DB → admin, + bootstrap: erste Registrierung in leerer DB → superadmin, oder E-Mail ∈ ADMIN_BOOTSTRAP_EMAILS (kommasepariert, Groß/Klein egal). + superadmin deckt alle Portal-Rechte ab (inkl. löschen / andere Super-Admins). Um alle Self-Regs als Trainer zu haben: AUTO_ADMIN_FIRST_USER=false und keine ADMIN_BOOTSTRAP_EMAILS. """ bootstrap = { @@ -198,7 +199,7 @@ def registration_role(cur, email_lower: str) -> str: if x.strip() } if email_lower in bootstrap: - return "admin" + return "superadmin" if os.getenv("AUTO_ADMIN_FIRST_USER", "true").strip().lower() not in ("1", "true", "yes"): return "trainer" cur.execute("SELECT COUNT(*) AS n FROM profiles") @@ -207,7 +208,7 @@ def registration_role(cur, email_lower: str) -> str: n = int(row["n"]) if row is not None else 0 except (KeyError, TypeError, ValueError): n = 0 - return "admin" if n == 0 else "trainer" + return "superadmin" if n == 0 else "trainer" # ── Helper: Send Email ──────────────────────────────────────────────────────── @@ -289,7 +290,7 @@ async def register(req: RegisterRequest, request: Request): verification_token = secrets.token_urlsafe(32) verification_expires = datetime.now(timezone.utc) + timedelta(hours=24) - # Rolle: erster Nutzer oder ADMIN_BOOTSTRAP_EMAILS → admin + # Rolle: erster Nutzer oder ADMIN_BOOTSTRAP_EMAILS → superadmin role = registration_role(cur, email) # Create profile (inactive until verified) — profiles.id ist SERIAL (INT), keine String-IDs einfügen. @@ -302,7 +303,33 @@ async def register(req: RegisterRequest, request: Request): email_verified, verification_token, verification_expires, trial_ends_at, created_at ) VALUES (%s, %s, %s, 'email', %s, 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP) + RETURNING id """, (name, email, pin_hash, role, verification_token, verification_expires, trial_ends)) + new_profile_id = cur.fetchone()["id"] + + req_club = req.requested_club_id + if req_club is not None: + cur.execute( + "SELECT id FROM clubs WHERE id = %s AND status = 'active'", + (int(req_club),), + ) + if cur.fetchone(): + cur.execute( + """ + SELECT id FROM club_membership_requests + WHERE profile_id = %s AND club_id = %s AND status = 'pending' + LIMIT 1 + """, + (new_profile_id, int(req_club)), + ) + if not cur.fetchone(): + cur.execute( + """ + INSERT INTO club_membership_requests (profile_id, club_id, status, message) + VALUES (%s, %s, 'pending', NULL) + """, + (new_profile_id, int(req_club)), + ) verify_url = verification_link(verification_token) diff --git a/backend/routers/club_join_requests.py b/backend/routers/club_join_requests.py new file mode 100644 index 0000000..dae7767 --- /dev/null +++ b/backend/routers/club_join_requests.py @@ -0,0 +1,281 @@ +""" +Anträge auf Vereinsbeitritt: Nutzer stellt Antrag, Vereins-/Plattform-Admin nimmt an oder lehnt ab. +""" +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from club_tenancy import can_manage_club_org +from db import get_db, get_cursor, r2d +from tenant_context import TenantContext, get_tenant_context + +router = APIRouter(prefix="/api", tags=["club_join_requests"]) + +_ALLOWED_MEMBER_ROLES = frozenset({"club_admin", "trainer", "division_lead", "content_editor"}) + + +def _normalize_roles(raw: List[str]) -> List[str]: + out: List[str] = [] + seen = set() + for r in raw: + if not isinstance(r, str): + raise HTTPException(status_code=400, detail="Rollen müssen Strings sein") + code = r.strip().lower() + if not code or code not in _ALLOWED_MEMBER_ROLES: + raise HTTPException(status_code=400, detail=f"Unbekannte Rolle: {code}") + if code not in seen: + seen.add(code) + out.append(code) + return out + + +def _club_active(cur, club_id: int) -> bool: + cur.execute("SELECT 1 FROM clubs WHERE id = %s AND status = 'active'", (club_id,)) + return cur.fetchone() is not None + + +def _assert_manage_club(cur, tenant: TenantContext, club_id: int) -> None: + pid = tenant.profile_id + role = tenant.global_role + if not can_manage_club_org(cur, pid, club_id, role): + raise HTTPException(status_code=403, detail="Keine Berechtigung für Mitglieder-Verwaltung in diesem Verein") + + +def _is_active_member(cur, profile_id: int, club_id: int) -> bool: + cur.execute( + """ + SELECT 1 FROM club_members + WHERE profile_id = %s AND club_id = %s AND status = 'active' + LIMIT 1 + """, + (profile_id, club_id), + ) + return cur.fetchone() is not None + + +def _upsert_active_member_with_roles(cur, club_id: int, profile_id: int, roles: List[str]) -> None: + roles_n = _normalize_roles(roles) + if not roles_n: + raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben") + cur.execute( + """ + INSERT INTO club_members (profile_id, club_id, status) + VALUES (%s, %s, 'active') + ON CONFLICT (profile_id, club_id) + DO UPDATE SET status = 'active', updated_at = NOW() + RETURNING id + """, + (profile_id, club_id), + ) + cm_id = cur.fetchone()["id"] + cur.execute("DELETE FROM club_member_roles WHERE club_member_id = %s", (cm_id,)) + for rc in roles_n: + cur.execute( + """ + INSERT INTO club_member_roles (club_member_id, role_code) + VALUES (%s, %s) + ON CONFLICT (club_member_id, role_code) DO NOTHING + """, + (cm_id, rc), + ) + + +class JoinRequestCreate(BaseModel): + club_id: int = Field(..., ge=1) + message: Optional[str] = Field(None, max_length=2000) + + +class JoinRequestAccept(BaseModel): + roles: List[str] = Field(default_factory=lambda: ["trainer"]) + + +def _response_one(cur, req_id: int, viewer_profile_id: int) -> Dict[str, Any]: + cur.execute( + """ + SELECT r.*, c.name AS club_name, c.abbreviation AS club_abbreviation + FROM club_membership_requests r + INNER JOIN clubs c ON c.id = r.club_id + WHERE r.id = %s AND r.profile_id = %s + """, + (req_id, viewer_profile_id), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Antrag nicht gefunden") + return r2d(row) + + +@router.get("/me/club-join-requests") +def get_my_join_requests(tenant: TenantContext = Depends(get_tenant_context)): + pid = tenant.profile_id + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT r.*, c.name AS club_name, c.abbreviation AS club_abbreviation + FROM club_membership_requests r + INNER JOIN clubs c ON c.id = r.club_id + WHERE r.profile_id = %s + ORDER BY r.created_at DESC + LIMIT 100 + """, + (pid,), + ) + return [r2d(r) for r in cur.fetchall()] + + +@router.post("/me/club-join-requests", status_code=201) +def create_my_join_request(body: JoinRequestCreate, tenant: TenantContext = Depends(get_tenant_context)): + """Antrag stellen (nicht möglich wenn bereits aktives Mitglied).""" + pid = tenant.profile_id + msg = (body.message or "").strip() or None + cid = body.club_id + + with get_db() as conn: + cur = get_cursor(conn) + if not _club_active(cur, cid): + raise HTTPException(status_code=404, detail="Verein nicht gefunden oder nicht aktiv") + + if _is_active_member(cur, pid, cid): + raise HTTPException(status_code=400, detail="Du bist bereits Mitglied in diesem Verein") + + cur.execute( + """ + SELECT id FROM club_membership_requests + WHERE profile_id = %s AND club_id = %s AND status = 'pending' + LIMIT 1 + """, + (pid, cid), + ) + if cur.fetchone(): + raise HTTPException(status_code=409, detail="Für diesen Verein liegt bereits ein offener Antrag vor") + + cur.execute( + """ + INSERT INTO club_membership_requests (profile_id, club_id, status, message) + VALUES (%s, %s, 'pending', %s) + RETURNING id + """, + (pid, cid, msg), + ) + rid = cur.fetchone()["id"] + conn.commit() + + with get_db() as conn: + cur = get_cursor(conn) + return _response_one(cur, rid, pid) + + +@router.delete("/me/club-join-requests/{request_id}") +def withdraw_my_join_request(request_id: int, tenant: TenantContext = Depends(get_tenant_context)): + pid = tenant.profile_id + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + UPDATE club_membership_requests + SET status = 'withdrawn', updated_at = NOW() + WHERE id = %s AND profile_id = %s AND status = 'pending' + RETURNING id + """, + (request_id, pid), + ) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Offener Antrag nicht gefunden") + conn.commit() + return {"ok": True} + + +@router.get("/clubs/{club_id}/join-requests") +def list_club_join_requests(club_id: int, tenant: TenantContext = Depends(get_tenant_context)): + """Offene Anträge für einen Verein (Vereins-/Plattform-Admin).""" + with get_db() as conn: + cur = get_cursor(conn) + _assert_manage_club(cur, tenant, club_id) + cur.execute( + """ + SELECT r.*, p.email AS applicant_email, p.name AS applicant_name + FROM club_membership_requests r + INNER JOIN profiles p ON p.id = r.profile_id + WHERE r.club_id = %s AND r.status = 'pending' + ORDER BY r.created_at ASC + """, + (club_id,), + ) + return [r2d(r) for r in cur.fetchall()] + + +@router.post("/clubs/{club_id}/join-requests/{request_id}/accept") +def accept_club_join_request( + club_id: int, + request_id: int, + body: JoinRequestAccept, + tenant: TenantContext = Depends(get_tenant_context), +): + admin_pid = tenant.profile_id + roles = _normalize_roles(body.roles) + + with get_db() as conn: + cur = get_cursor(conn) + _assert_manage_club(cur, tenant, club_id) + + cur.execute( + """ + SELECT id, profile_id, status FROM club_membership_requests + WHERE id = %s AND club_id = %s + """, + (request_id, club_id), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Antrag nicht gefunden") + if row["status"] != "pending": + raise HTTPException(status_code=400, detail="Antrag ist nicht mehr offen") + + applicant_id = row["profile_id"] + + cur.execute( + """ + UPDATE club_membership_requests + SET status = 'accepted', + decided_by_profile_id = %s, + decided_at = NOW(), + updated_at = NOW() + WHERE id = %s AND club_id = %s AND status = 'pending' + RETURNING id + """, + (admin_pid, request_id, club_id), + ) + if not cur.fetchone(): + raise HTTPException(status_code=409, detail="Antrag konnte nicht angenommen werden") + + _upsert_active_member_with_roles(cur, club_id, applicant_id, roles) + conn.commit() + + return {"ok": True, "profile_id": applicant_id, "club_id": club_id} + + +@router.post("/clubs/{club_id}/join-requests/{request_id}/reject") +def reject_club_join_request(club_id: int, request_id: int, tenant: TenantContext = Depends(get_tenant_context)): + admin_pid = tenant.profile_id + with get_db() as conn: + cur = get_cursor(conn) + _assert_manage_club(cur, tenant, club_id) + + cur.execute( + """ + UPDATE club_membership_requests + SET status = 'rejected', + decided_by_profile_id = %s, + decided_at = NOW(), + updated_at = NOW() + WHERE id = %s AND club_id = %s AND status = 'pending' + RETURNING id + """, + (admin_pid, request_id, club_id), + ) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Offener Antrag nicht gefunden") + conn.commit() + return {"ok": True} diff --git a/backend/routers/club_memberships.py b/backend/routers/club_memberships.py new file mode 100644 index 0000000..d1a4a6a --- /dev/null +++ b/backend/routers/club_memberships.py @@ -0,0 +1,268 @@ +""" +Vereins-Mitgliedschaften und Rollen (ohne UI nutzbar für Admin/Automatisierung). + +Berechtigung: Plattform-Admin (admin/superadmin) oder Vereinsadmin (club_admin) im Zielverein. +""" +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field + +from club_tenancy import can_manage_club_org +from db import get_db, get_cursor, r2d +from tenant_context import TenantContext, get_tenant_context + +router = APIRouter(prefix="/api", tags=["club_memberships"]) + +_ALLOWED_ROLES = frozenset({"club_admin", "trainer", "division_lead", "content_editor"}) +_ALLOWED_STATUS = frozenset({"active", "inactive"}) + + +def _normalize_roles(raw: List[str]) -> List[str]: + out: List[str] = [] + seen = set() + for r in raw: + if not isinstance(r, str): + raise HTTPException(status_code=400, detail="Rollen müssen Strings sein") + code = r.strip().lower() + if not code: + continue + if code not in _ALLOWED_ROLES: + raise HTTPException( + status_code=400, + detail=f"Unbekannte Rolle: {code}. Erlaubt: {', '.join(sorted(_ALLOWED_ROLES))}", + ) + if code not in seen: + seen.add(code) + out.append(code) + return out + + +def _assert_manage(cur, tenant: TenantContext, club_id: int) -> None: + pid = tenant.profile_id + role = tenant.global_role + if not can_manage_club_org(cur, pid, club_id, role): + raise HTTPException(status_code=403, detail="Keine Berechtigung zur Mitglieder-Verwaltung in diesem Verein") + + +def _club_exists(cur, club_id: int) -> bool: + cur.execute("SELECT 1 FROM clubs WHERE id = %s", (club_id,)) + return cur.fetchone() is not None + + +class ClubMemberUpsert(BaseModel): + profile_id: int = Field(..., ge=1) + roles: List[str] = Field(default_factory=list, description="Mindestens eine Vereinsrolle") + + +class ClubMemberPatch(BaseModel): + roles: Optional[List[str]] = None + status: Optional[str] = Field(None, description="active oder inactive") + + +@router.get("/clubs/{club_id}/members") +def list_club_members( + club_id: int, + include_inactive: bool = Query(False), + tenant: TenantContext = Depends(get_tenant_context), +): + """Alle Mitglieder eines Vereins mit Rollen (nur Vereins-/Plattform-Admin).""" + with get_db() as conn: + cur = get_cursor(conn) + if not _club_exists(cur, club_id): + raise HTTPException(status_code=404, detail="Verein nicht gefunden") + _assert_manage(cur, tenant, club_id) + + status_clause = "" if include_inactive else "AND cm.status = 'active'" + cur.execute( + f""" + SELECT cm.id AS membership_id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, + p.email, p.name, + COALESCE( + ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL), + ARRAY[]::varchar[] + ) AS roles + FROM club_members cm + INNER JOIN profiles p ON p.id = cm.profile_id + LEFT JOIN club_member_roles r ON r.club_member_id = cm.id + WHERE cm.club_id = %s {status_clause} + GROUP BY cm.id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name + ORDER BY p.name NULLS LAST, p.email + """, + (club_id,), + ) + rows = [] + for row in cur.fetchall(): + d = r2d(row) + roles = d.get("roles") or [] + if hasattr(roles, "tolist"): + roles = roles.tolist() + d["roles"] = list(roles) + rows.append(d) + return rows + + +@router.post("/clubs/{club_id}/members", status_code=201) +def upsert_club_member( + club_id: int, + body: ClubMemberUpsert, + tenant: TenantContext = Depends(get_tenant_context), +): + """Mitglied anlegen oder aktivieren; Rollen werden vollständig ersetzt.""" + roles = _normalize_roles(body.roles) + if not roles: + raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben") + + with get_db() as conn: + cur = get_cursor(conn) + if not _club_exists(cur, club_id): + raise HTTPException(status_code=404, detail="Verein nicht gefunden") + _assert_manage(cur, tenant, club_id) + + cur.execute("SELECT id FROM profiles WHERE id = %s", (body.profile_id,)) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Profil nicht gefunden") + + cur.execute( + """ + INSERT INTO club_members (profile_id, club_id, status) + VALUES (%s, %s, 'active') + ON CONFLICT (profile_id, club_id) + DO UPDATE SET status = 'active', updated_at = NOW() + RETURNING id + """, + (body.profile_id, club_id), + ) + cm_id = cur.fetchone()["id"] + + cur.execute("DELETE FROM club_member_roles WHERE club_member_id = %s", (cm_id,)) + for rc in roles: + cur.execute( + """ + INSERT INTO club_member_roles (club_member_id, role_code) + VALUES (%s, %s) + ON CONFLICT (club_member_id, role_code) DO NOTHING + """, + (cm_id, rc), + ) + conn.commit() + return _one_member(cur, club_id, body.profile_id) + + +def _one_member(cur, club_id: int, profile_id: int) -> Dict[str, Any]: + cur.execute( + """ + SELECT cm.id AS membership_id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, + p.email, p.name, + COALESCE( + ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL), + ARRAY[]::varchar[] + ) AS roles + FROM club_members cm + INNER JOIN profiles p ON p.id = cm.profile_id + LEFT JOIN club_member_roles r ON r.club_member_id = cm.id + WHERE cm.club_id = %s AND cm.profile_id = %s + GROUP BY cm.id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name + """, + (club_id, profile_id), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Mitgliedschaft nicht gefunden") + d = r2d(row) + roles = d.get("roles") or [] + if hasattr(roles, "tolist"): + roles = roles.tolist() + d["roles"] = list(roles) + return d + + +@router.get("/clubs/{club_id}/members/{profile_id}") +def get_club_member( + club_id: int, + profile_id: int, + tenant: TenantContext = Depends(get_tenant_context), +): + with get_db() as conn: + cur = get_cursor(conn) + if not _club_exists(cur, club_id): + raise HTTPException(status_code=404, detail="Verein nicht gefunden") + _assert_manage(cur, tenant, club_id) + return _one_member(cur, club_id, profile_id) + + +@router.put("/clubs/{club_id}/members/{profile_id}") +def update_club_member( + club_id: int, + profile_id: int, + body: ClubMemberPatch, + tenant: TenantContext = Depends(get_tenant_context), +): + """Rollen ersetzen und/oder Status setzen.""" + with get_db() as conn: + cur = get_cursor(conn) + if not _club_exists(cur, club_id): + raise HTTPException(status_code=404, detail="Verein nicht gefunden") + _assert_manage(cur, tenant, club_id) + + cur.execute( + "SELECT id FROM club_members WHERE club_id = %s AND profile_id = %s", + (club_id, profile_id), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Mitgliedschaft nicht gefunden") + cm_id = row["id"] + + if body.roles is None and body.status is None: + return _one_member(cur, club_id, profile_id) + + if body.status is not None: + st = body.status.strip().lower() + if st not in _ALLOWED_STATUS: + raise HTTPException(status_code=400, detail="status muss active oder inactive sein") + cur.execute( + "UPDATE club_members SET status = %s, updated_at = NOW() WHERE id = %s", + (st, cm_id), + ) + + if body.roles is not None: + roles = _normalize_roles(body.roles) + if not roles: + raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben") + cur.execute("DELETE FROM club_member_roles WHERE club_member_id = %s", (cm_id,)) + for rc in roles: + cur.execute( + """ + INSERT INTO club_member_roles (club_member_id, role_code) + VALUES (%s, %s) + ON CONFLICT (club_member_id, role_code) DO NOTHING + """, + (cm_id, rc), + ) + + conn.commit() + return _one_member(cur, club_id, profile_id) + + +@router.delete("/clubs/{club_id}/members/{profile_id}") +def delete_club_member( + club_id: int, + profile_id: int, + tenant: TenantContext = Depends(get_tenant_context), +): + """Mitgliedschaft löschen (Rollen per CASCADE).""" + with get_db() as conn: + cur = get_cursor(conn) + if not _club_exists(cur, club_id): + raise HTTPException(status_code=404, detail="Verein nicht gefunden") + _assert_manage(cur, tenant, club_id) + + cur.execute( + "DELETE FROM club_members WHERE club_id = %s AND profile_id = %s RETURNING id", + (club_id, profile_id), + ) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Mitgliedschaft nicht gefunden") + conn.commit() + return {"ok": True} diff --git a/backend/routers/clubs.py b/backend/routers/clubs.py index 9a630a0..b949ff0 100644 --- a/backend/routers/clubs.py +++ b/backend/routers/clubs.py @@ -3,11 +3,18 @@ Club & Organization Management Endpoints for Shinkan Jinkendo Handles CRUD operations for clubs, divisions, and training groups. """ -from typing import Optional +from typing import Any, List, Optional from fastapi import APIRouter, HTTPException, Depends, Query from db import get_db, get_cursor, r2d -from auth import require_auth +from tenant_context import TenantContext, get_tenant_context +from club_tenancy import ( + assert_club_member, + can_manage_club_org, + can_plan_in_club, + club_ids_for_profile, + is_platform_admin, +) router = APIRouter(prefix="/api", tags=["clubs"]) @@ -16,24 +23,33 @@ router = APIRouter(prefix="/api", tags=["clubs"]) @router.get("/clubs") def list_clubs( status: Optional[str] = Query(default=None), - session=Depends(require_auth) + tenant: TenantContext = Depends(get_tenant_context), ): """ - List all clubs (public for authenticated users). - - Filters: - - status: active, inactive + Vereine: für normale Nutzer nur Mitgliedschaft-Vereine; Plattform-Admins sehen alle. """ + role = tenant.global_role + profile_id = tenant.profile_id with get_db() as conn: cur = get_cursor(conn) - query = "SELECT * FROM clubs" - params = [] + params: List[Any] = [] + conds = [] + + if not is_platform_admin(role): + cids = club_ids_for_profile(cur, profile_id) + if not cids: + return [] + conds.append("id IN (" + ",".join(["%s"] * len(cids)) + ")") + params.extend(sorted(cids)) if status: - query += " WHERE status = %s" + conds.append("status = %s") params.append(status) + if conds: + query += " WHERE " + " AND ".join(conds) + query += " ORDER BY name" cur.execute(query, params) @@ -41,12 +57,58 @@ def list_clubs( return [r2d(r) for r in rows] -# ── Get Club ────────────────────────────────────────────────────────── -@router.get("/clubs/{club_id}") -def get_club(club_id: int, session=Depends(require_auth)): - """Get club by ID with divisions and groups.""" +# ── Öffentliches Vereinsverzeichnis (Registrierung / Antrag ohne Mitgliedschaft) ── +@router.get("/clubs/public-directory") +def public_club_directory(): + """Aktive Vereine zur Auswahl bei Registrierung oder Beitrittsantrag (nur id, name, Kürzel).""" with get_db() as conn: cur = get_cursor(conn) + cur.execute( + """ + SELECT id, name, abbreviation + FROM clubs + WHERE status = 'active' + ORDER BY name + """ + ) + return [r2d(r) for r in cur.fetchall()] + + +# ── Aktive Mitglieder für Trainer-/Co-Trainer-Auswahl (jeder Vereinsmitglied) ── +@router.get("/clubs/{club_id}/members/directory") +def club_members_directory(club_id: int, tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role + with get_db() as conn: + cur = get_cursor(conn) + if not is_platform_admin(role): + assert_club_member(cur, profile_id, club_id) + cur.execute("SELECT 1 FROM clubs WHERE id = %s", (club_id,)) + if not cur.fetchone(): + raise HTTPException(404, "Verein nicht gefunden") + cur.execute( + """ + SELECT p.id, p.name, p.email + FROM club_members cm + INNER JOIN profiles p ON p.id = cm.profile_id + WHERE cm.club_id = %s AND cm.status = 'active' + ORDER BY COALESCE(p.name, ''), p.email + """, + (club_id,), + ) + return [r2d(r) for r in cur.fetchall()] + + +# ── Get Club ────────────────────────────────────────────────────────── +@router.get("/clubs/{club_id}") +def get_club(club_id: int, tenant: TenantContext = Depends(get_tenant_context)): + """Get club by ID with divisions and groups – nur Mitglied oder Plattform-Admin.""" + profile_id = tenant.profile_id + role = tenant.global_role + with get_db() as conn: + cur = get_cursor(conn) + if not is_platform_admin(role): + assert_club_member(cur, profile_id, club_id) # Get club cur.execute("SELECT * FROM clubs WHERE id = %s", (club_id,)) @@ -58,66 +120,108 @@ def get_club(club_id: int, session=Depends(require_auth)): club = r2d(club) # Get divisions - cur.execute(""" + cur.execute( + """ SELECT * FROM divisions WHERE club_id = %s ORDER BY name - """, (club_id,)) - club['divisions'] = [r2d(r) for r in cur.fetchall()] + """, + (club_id,), + ) + club["divisions"] = [r2d(r) for r in cur.fetchall()] # Get training groups - cur.execute(""" + cur.execute( + """ SELECT g.*, p.name as trainer_name FROM training_groups g LEFT JOIN profiles p ON g.trainer_id = p.id WHERE g.club_id = %s ORDER BY g.weekday, g.time_start - """, (club_id,)) - club['training_groups'] = [r2d(r) for r in cur.fetchall()] + """, + (club_id,), + ) + club["training_groups"] = [r2d(r) for r in cur.fetchall()] return club # ── Create Club ─────────────────────────────────────────────────────── @router.post("/clubs") -def create_club(data: dict, session=Depends(require_auth)): - """Create new club (Admin oder Trainer — MVP ohne separates Vereins-Onboarding).""" - role = session.get('role') - if role not in ['admin', 'superadmin', 'trainer', 'user']: - raise HTTPException(403, "Keine Berechtigung, Vereine anzulegen") +def create_club(data: dict, tenant: TenantContext = Depends(get_tenant_context)): + """Neuen Verein anlegen – nur Plattform-Admin; Pflicht: primary_admin_profile_id (Hauptverwalter:in).""" + role = tenant.global_role + if not is_platform_admin(role): + raise HTTPException(403, "Nur Plattform-Administratoren dürfen neue Vereine anlegen") - name = data.get('name') + name = data.get("name") + primary_admin_profile_id = data.get("primary_admin_profile_id") if not name: raise HTTPException(400, "Name ist Pflichtfeld") + if not primary_admin_profile_id: + raise HTTPException(400, "primary_admin_profile_id ist Pflichtfeld (Hauptverwalter:in)") + + try: + aid = int(primary_admin_profile_id) + except (TypeError, ValueError): + raise HTTPException(400, "primary_admin_profile_id ungültig") with get_db() as conn: cur = get_cursor(conn) + cur.execute("SELECT id FROM profiles WHERE id = %s", (aid,)) + if not cur.fetchone(): + raise HTTPException(404, "Profil für Hauptverwalter nicht gefunden") - cur.execute(""" - INSERT INTO clubs (name, abbreviation, description, status) - VALUES (%s, %s, %s, %s) + cur.execute( + """ + INSERT INTO clubs (name, abbreviation, description, status, primary_admin_profile_id) + VALUES (%s, %s, %s, %s, %s) RETURNING id - """, ( - name, - data.get('abbreviation'), - data.get('description'), - data.get('status', 'active') - )) + """, + ( + name, + data.get("abbreviation"), + data.get("description"), + data.get("status", "active"), + aid, + ), + ) + + club_id = cur.fetchone()["id"] + + cur.execute( + """ + INSERT INTO club_members (profile_id, club_id, status) + VALUES (%s, %s, 'active') + ON CONFLICT (profile_id, club_id) + DO UPDATE SET status = 'active', updated_at = NOW() + RETURNING id + """, + (aid, club_id), + ) + cm_id = cur.fetchone()["id"] + for rc in ("club_admin", "trainer"): + cur.execute( + """ + INSERT INTO club_member_roles (club_member_id, role_code) + VALUES (%s, %s) + ON CONFLICT (club_member_id, role_code) DO NOTHING + """, + (cm_id, rc), + ) - club_id = cur.fetchone()['id'] conn.commit() - return get_club(club_id, session) + return get_club(club_id, tenant) # ── Update Club ─────────────────────────────────────────────────────── @router.put("/clubs/{club_id}") -def update_club(club_id: int, data: dict, session=Depends(require_auth)): - """Update club (admin only).""" - role = session.get('role') - if role not in ['admin', 'superadmin']: - raise HTTPException(403, "Nur Admins dürfen Vereine bearbeiten") +def update_club(club_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)): + """Verein bearbeiten – Plattform-Admin oder Vereinsadmin.""" + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) @@ -127,33 +231,46 @@ def update_club(club_id: int, data: dict, session=Depends(require_auth)): if not cur.fetchone(): raise HTTPException(404, "Verein nicht gefunden") + if not can_manage_club_org(cur, profile_id, club_id, role): + raise HTTPException(403, "Keine Berechtigung für diesen Verein") + + # Nur Plattform-Admin darf primary_admin_profile_id ändern + if "primary_admin_profile_id" in data and data["primary_admin_profile_id"] is not None: + if not is_platform_admin(role): + raise HTTPException(403, "Nur Plattform-Admins dürfen den Hauptverwalter ändern") + # Update - cur.execute(""" + cur.execute( + """ UPDATE clubs SET - name = %s, - abbreviation = %s, - description = %s, - status = %s, + name = COALESCE(%s, name), + abbreviation = COALESCE(%s, abbreviation), + description = COALESCE(%s, description), + status = COALESCE(%s, status), + primary_admin_profile_id = COALESCE(%s, primary_admin_profile_id), updated_at = NOW() WHERE id = %s - """, ( - data.get('name'), - data.get('abbreviation'), - data.get('description'), - data.get('status'), - club_id - )) + """, + ( + data.get("name"), + data.get("abbreviation"), + data.get("description"), + data.get("status"), + data.get("primary_admin_profile_id"), + club_id, + ), + ) conn.commit() - return get_club(club_id, session) + return get_club(club_id, tenant) # ── Delete Club ─────────────────────────────────────────────────────── @router.delete("/clubs/{club_id}") -def delete_club(club_id: int, session=Depends(require_auth)): +def delete_club(club_id: int, tenant: TenantContext = Depends(get_tenant_context)): """Delete club (superadmin only).""" - role = session.get('role') + role = tenant.global_role if role != 'superadmin': raise HTTPException(403, "Nur Superadmins dürfen Vereine löschen") @@ -176,22 +293,41 @@ def delete_club(club_id: int, session=Depends(require_auth)): @router.get("/divisions") def list_divisions( club_id: Optional[int] = Query(default=None), - session=Depends(require_auth) + tenant: TenantContext = Depends(get_tenant_context), ): - """List divisions (optional filter by club).""" + """Sparten – ohne Admin-Rechte nur in eigenen Vereinen sichtbar.""" + profile_id = tenant.profile_id + role = tenant.global_role + with get_db() as conn: cur = get_cursor(conn) + mine = club_ids_for_profile(cur, profile_id) + if not is_platform_admin(role) and not mine: + return [] + query = """ SELECT d.*, c.name as club_name FROM divisions d LEFT JOIN clubs c ON d.club_id = c.id """ + where = [] params = [] - if club_id: - query += " WHERE d.club_id = %s" + if club_id is not None: + where.append("d.club_id = %s") params.append(club_id) + if not is_platform_admin(role) and club_id not in mine: + return [] + + if not is_platform_admin(role): + where.append( + "d.club_id IN (" + ",".join(["%s"] * len(mine)) + ")" + ) + params.extend(sorted(mine)) + + if where: + query += " WHERE " + " AND ".join(where) query += " ORDER BY d.name" @@ -202,14 +338,13 @@ def list_divisions( # ── Create Division ─────────────────────────────────────────────────── @router.post("/divisions") -def create_division(data: dict, session=Depends(require_auth)): - """Create new division (admin only).""" - role = session.get('role') - if role not in ['admin', 'superadmin']: - raise HTTPException(403, "Nur Admins dürfen Sparten erstellen") +def create_division(data: dict, tenant: TenantContext = Depends(get_tenant_context)): + """Create new division – Vereinsadmin / Plattform-Admin.""" + profile_id = tenant.profile_id + role = tenant.global_role - club_id = data.get('club_id') - name = data.get('name') + club_id = data.get("club_id") + name = data.get("name") if not club_id or not name: raise HTTPException(400, "club_id und name sind Pflichtfelder") @@ -217,93 +352,109 @@ def create_division(data: dict, session=Depends(require_auth)): with get_db() as conn: cur = get_cursor(conn) + if not can_manage_club_org(cur, profile_id, int(club_id), role): + raise HTTPException(403, "Keine Berechtigung, Sparten in diesem Verein anzulegen") + # Check club exists cur.execute("SELECT id FROM clubs WHERE id = %s", (club_id,)) if not cur.fetchone(): raise HTTPException(404, "Verein nicht gefunden") # Insert - cur.execute(""" + cur.execute( + """ INSERT INTO divisions (club_id, name, focus_area) VALUES (%s, %s, %s) RETURNING id - """, ( - club_id, - name, - data.get('focus_area') - )) + """, + ( + club_id, + name, + data.get("focus_area"), + ), + ) - division_id = cur.fetchone()['id'] + division_id = cur.fetchone()["id"] conn.commit() # Return created division - cur.execute(""" + cur.execute( + """ SELECT d.*, c.name as club_name FROM divisions d LEFT JOIN clubs c ON d.club_id = c.id WHERE d.id = %s - """, (division_id,)) + """, + (division_id,), + ) return r2d(cur.fetchone()) # ── Update Division ─────────────────────────────────────────────────── @router.put("/divisions/{division_id}") -def update_division(division_id: int, data: dict, session=Depends(require_auth)): - """Update division (admin only).""" - role = session.get('role') - if role not in ['admin', 'superadmin']: - raise HTTPException(403, "Nur Admins dürfen Sparten bearbeiten") +def update_division(division_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)): + """Update division – Vereinsadmin / Plattform-Admin.""" + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) - # Check existence - cur.execute("SELECT id FROM divisions WHERE id = %s", (division_id,)) - if not cur.fetchone(): + cur.execute("SELECT id, club_id FROM divisions WHERE id = %s", (division_id,)) + div = cur.fetchone() + if not div: raise HTTPException(404, "Sparte nicht gefunden") - # Update - cur.execute(""" + if not can_manage_club_org(cur, profile_id, div["club_id"], role): + raise HTTPException(403, "Keine Berechtigung") + + cur.execute( + """ UPDATE divisions SET name = %s, focus_area = %s, updated_at = NOW() WHERE id = %s - """, ( - data.get('name'), - data.get('focus_area'), - division_id - )) + """, + ( + data.get("name"), + data.get("focus_area"), + division_id, + ), + ) conn.commit() - # Return updated division - cur.execute(""" + cur.execute( + """ SELECT d.*, c.name as club_name FROM divisions d LEFT JOIN clubs c ON d.club_id = c.id WHERE d.id = %s - """, (division_id,)) + """, + (division_id,), + ) return r2d(cur.fetchone()) # ── Delete Division ─────────────────────────────────────────────────── @router.delete("/divisions/{division_id}") -def delete_division(division_id: int, session=Depends(require_auth)): - """Delete division (admin only).""" - role = session.get('role') - if role not in ['admin', 'superadmin']: - raise HTTPException(403, "Nur Admins dürfen Sparten löschen") +def delete_division(division_id: int, tenant: TenantContext = Depends(get_tenant_context)): + """Delete division – Vereinsadmin / Plattform-Admin.""" + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) - # Check existence - cur.execute("SELECT id FROM divisions WHERE id = %s", (division_id,)) - if not cur.fetchone(): + cur.execute("SELECT id, club_id FROM divisions WHERE id = %s", (division_id,)) + div = cur.fetchone() + if not div: raise HTTPException(404, "Sparte nicht gefunden") - # Delete + if not can_manage_club_org(cur, profile_id, div["club_id"], role): + raise HTTPException(403, "Keine Berechtigung") + cur.execute("DELETE FROM divisions WHERE id = %s", (division_id,)) conn.commit() @@ -316,19 +467,21 @@ def list_training_groups( club_id: Optional[int] = Query(default=None), division_id: Optional[int] = Query(default=None), status: Optional[str] = Query(default=None), - session=Depends(require_auth) + tenant: TenantContext = Depends(get_tenant_context), ): """ - List training groups with optional filters. - - Filters: - - club_id: Filter by club - - division_id: Filter by division - - status: active, inactive + Trainingsgruppen – ohne Plattform-Admin nur in eigenen Vereinen. """ + profile_id = tenant.profile_id + role = tenant.global_role + with get_db() as conn: cur = get_cursor(conn) + mine = club_ids_for_profile(cur, profile_id) + if not is_platform_admin(role) and not mine: + return [] + query = """ SELECT g.*, c.name as club_name, @@ -343,11 +496,13 @@ def list_training_groups( where = [] params = [] - if club_id: + if club_id is not None: where.append("g.club_id = %s") params.append(club_id) + if not is_platform_admin(role) and club_id not in mine: + return [] - if division_id: + if division_id is not None: where.append("g.division_id = %s") params.append(division_id) @@ -355,6 +510,10 @@ def list_training_groups( where.append("g.status = %s") params.append(status) + if not is_platform_admin(role): + where.append("g.club_id IN (" + ",".join(["%s"] * len(mine)) + ")") + params.extend(sorted(mine)) + if where: query += " WHERE " + " AND ".join(where) @@ -367,12 +526,15 @@ def list_training_groups( # ── Get Training Group ──────────────────────────────────────────────── @router.get("/groups/{group_id}") -def get_training_group(group_id: int, session=Depends(require_auth)): - """Get training group by ID.""" +def get_training_group(group_id: int, tenant: TenantContext = Depends(get_tenant_context)): + """Trainingsgruppe – nur mit Vereinszugriff.""" + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) - cur.execute(""" + cur.execute( + """ SELECT g.*, c.name as club_name, d.name as division_name, @@ -382,45 +544,55 @@ def get_training_group(group_id: int, session=Depends(require_auth)): LEFT JOIN divisions d ON g.division_id = d.id LEFT JOIN profiles p ON g.trainer_id = p.id WHERE g.id = %s - """, (group_id,)) + """, + (group_id,), + ) group = cur.fetchone() if not group: raise HTTPException(404, "Trainingsgruppe nicht gefunden") + cid = group["club_id"] + if not is_platform_admin(role): + assert_club_member(cur, profile_id, cid) + return r2d(group) # ── Create Training Group ───────────────────────────────────────────── @router.post("/groups") -def create_training_group(data: dict, session=Depends(require_auth)): - """Create new training group (admin, trainer oder normaler Nutzer mit Vereinsbezug).""" - profile_id = session["profile_id"] - role = session.get('role') - if role not in ['admin', 'superadmin', 'trainer', 'user']: +def create_training_group(data: dict, tenant: TenantContext = Depends(get_tenant_context)): + """Trainingsgruppe anlegen – Mitglied mit Planungs-/Admin-Rolle im Verein.""" + profile_id = tenant.profile_id + role = tenant.global_role + if role not in ("admin", "superadmin", "trainer", "user"): raise HTTPException(403, "Keine Berechtigung, Trainingsgruppen anzulegen") - club_id = data.get('club_id') - name = data.get('name') + club_id = data.get("club_id") + name = data.get("name") if not club_id or not name: raise HTTPException(400, "club_id und name sind Pflichtfelder") - trainer_id = data.get('trainer_id') + trainer_id = data.get("trainer_id") if trainer_id in (None, "", 0) and role in ("trainer", "user"): trainer_id = profile_id with get_db() as conn: cur = get_cursor(conn) + if not can_plan_in_club(cur, profile_id, int(club_id), role): + raise HTTPException(403, "Keine Berechtigung für diesen Verein") + # Check club exists cur.execute("SELECT id FROM clubs WHERE id = %s", (club_id,)) if not cur.fetchone(): raise HTTPException(404, "Verein nicht gefunden") # Insert - cur.execute(""" + cur.execute( + """ INSERT INTO training_groups ( club_id, division_id, name, focus, level, age_group, weekday, time_start, time_end, location, @@ -430,50 +602,62 @@ def create_training_group(data: dict, session=Depends(require_auth)): %s, %s, %s, %s, %s, %s, %s ) RETURNING id - """, ( - club_id, - data.get('division_id'), - name, - data.get('focus'), - data.get('level'), - data.get('age_group'), - data.get('weekday'), - data.get('time_start'), - data.get('time_end'), - data.get('location'), - trainer_id, - data.get('co_trainer_ids'), - data.get('status', 'active') - )) + """, + ( + club_id, + data.get("division_id"), + name, + data.get("focus"), + data.get("level"), + data.get("age_group"), + data.get("weekday"), + data.get("time_start"), + data.get("time_end"), + data.get("location"), + trainer_id, + data.get("co_trainer_ids"), + data.get("status", "active"), + ), + ) - group_id = cur.fetchone()['id'] + group_id = cur.fetchone()["id"] conn.commit() - return get_training_group(group_id, session) + return get_training_group(group_id, tenant) # ── Update Training Group ───────────────────────────────────────────── @router.put("/groups/{group_id}") -def update_training_group(group_id: int, data: dict, session=Depends(require_auth)): - """Update training group (admin or assigned trainer).""" - profile_id = session['profile_id'] - role = session.get('role') +def update_training_group(group_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)): + """Update training group – Vereinsadmin, Plattform-Admin oder zugewiesene Trainer.""" + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) - # Check existence and ownership - cur.execute("SELECT trainer_id FROM training_groups WHERE id = %s", (group_id,)) + cur.execute( + "SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s", + (group_id,), + ) row = cur.fetchone() if not row: raise HTTPException(404, "Trainingsgruppe nicht gefunden") - # Only admin or assigned trainer can update - if role not in ['admin', 'superadmin'] and row['trainer_id'] != profile_id: + co_trainers = row["co_trainer_ids"] or [] + club_id = row["club_id"] + + allowed = role in ("admin", "superadmin") + if not allowed: + allowed = can_manage_club_org(cur, profile_id, club_id, role) + if not allowed: + allowed = row["trainer_id"] == profile_id or profile_id in co_trainers + + if not allowed: raise HTTPException(403, "Keine Berechtigung") - # Update - cur.execute(""" + cur.execute( + """ UPDATE training_groups SET name = %s, division_id = %s, @@ -489,44 +673,47 @@ def update_training_group(group_id: int, data: dict, session=Depends(require_aut status = %s, updated_at = NOW() WHERE id = %s - """, ( - data.get('name'), - data.get('division_id'), - data.get('focus'), - data.get('level'), - data.get('age_group'), - data.get('weekday'), - data.get('time_start'), - data.get('time_end'), - data.get('location'), - data.get('trainer_id'), - data.get('co_trainer_ids'), - data.get('status'), - group_id - )) + """, + ( + data.get("name"), + data.get("division_id"), + data.get("focus"), + data.get("level"), + data.get("age_group"), + data.get("weekday"), + data.get("time_start"), + data.get("time_end"), + data.get("location"), + data.get("trainer_id"), + data.get("co_trainer_ids"), + data.get("status"), + group_id, + ), + ) conn.commit() - return get_training_group(group_id, session) + return get_training_group(group_id, tenant) # ── Delete Training Group ───────────────────────────────────────────── @router.delete("/groups/{group_id}") -def delete_training_group(group_id: int, session=Depends(require_auth)): - """Delete training group (admin only).""" - role = session.get('role') - if role not in ['admin', 'superadmin']: - raise HTTPException(403, "Nur Admins dürfen Gruppen löschen") +def delete_training_group(group_id: int, tenant: TenantContext = Depends(get_tenant_context)): + """Delete training group – Vereinsadmin oder Plattform-Admin.""" + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) - # Check existence - cur.execute("SELECT id FROM training_groups WHERE id = %s", (group_id,)) - if not cur.fetchone(): + cur.execute("SELECT id, club_id FROM training_groups WHERE id = %s", (group_id,)) + row = cur.fetchone() + if not row: raise HTTPException(404, "Trainingsgruppe nicht gefunden") - # Delete + if not can_manage_club_org(cur, profile_id, row["club_id"], role): + raise HTTPException(403, "Keine Berechtigung") + cur.execute("DELETE FROM training_groups WHERE id = %s", (group_id,)) conn.commit() diff --git a/backend/routers/exercise_progression_graphs.py b/backend/routers/exercise_progression_graphs.py index 13326aa..124b681 100644 --- a/backend/routers/exercise_progression_graphs.py +++ b/backend/routers/exercise_progression_graphs.py @@ -9,8 +9,13 @@ from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field, model_validator from psycopg2 import IntegrityError -from auth import require_auth from db import get_db, get_cursor, r2d +from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql +from club_tenancy import ( + assert_valid_governance_visibility, + is_platform_admin, + library_content_visible_to_profile, +) from routers.training_planning import _has_planning_role @@ -82,7 +87,7 @@ _EDGE_SELECT = """ """ -def _graph_access(cur, graph_id: int, profile_id: int, role: str) -> dict: +def _graph_row(cur, graph_id: int) -> dict: cur.execute( "SELECT * FROM exercise_progression_graphs WHERE id = %s", (graph_id,), @@ -90,11 +95,40 @@ def _graph_access(cur, graph_id: int, profile_id: int, role: str) -> dict: r = cur.fetchone() if not r: raise HTTPException(status_code=404, detail="Progressionsgraph nicht gefunden") - row = r2d(r) - if role in ("admin", "superadmin"): - return row - if row.get("created_by") != profile_id: + return r2d(r) + + +def _assert_graph_readable(cur, row: dict, profile_id: int, role: str) -> None: + vis = (row.get("visibility") or "private").strip().lower() + cid = row.get("club_id") + if cid is not None: + cid = int(cid) + cr = row.get("created_by") + if cr is not None: + cr = int(cr) + if not library_content_visible_to_profile(cur, profile_id, vis, cid, cr, role): raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Progressionsgraph") + + +def _assert_graph_writable(cur, row: dict, profile_id: int, role: str) -> None: + if is_platform_admin(role): + return + created_by = row.get("created_by") + if created_by is not None: + created_by = int(created_by) + if created_by != profile_id: + raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Progressionsgraph") + + +def _require_graph_read(cur, graph_id: int, profile_id: int, role: str) -> dict: + row = _graph_row(cur, graph_id) + _assert_graph_readable(cur, row, profile_id, role) + return row + + +def _require_graph_write(cur, graph_id: int, profile_id: int, role: str) -> dict: + row = _graph_row(cur, graph_id) + _assert_graph_writable(cur, row, profile_id, role) return row @@ -167,12 +201,12 @@ def _insert_edge_row( @router.get("/exercise-progression-graphs") -def list_progression_graphs(session: dict = Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def list_progression_graphs(tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) - if role in ("admin", "superadmin"): + if is_platform_admin(role): cur.execute( """ SELECT g.*, @@ -182,15 +216,21 @@ def list_progression_graphs(session: dict = Depends(require_auth)): """ ) else: + vis_sql, vis_params = library_content_visibility_sql( + alias="g", + profile_id=profile_id, + role=role, + effective_club_id=tenant.effective_club_id, + ) cur.execute( - """ + f""" SELECT g.*, (SELECT COUNT(*) FROM exercise_progression_edges e WHERE e.graph_id = g.id) AS edges_count FROM exercise_progression_graphs g - WHERE g.created_by = %s + WHERE ({vis_sql}) ORDER BY g.updated_at DESC NULLS LAST, g.name """, - (profile_id,), + vis_params, ) return [r2d(r) for r in cur.fetchall()] @@ -199,13 +239,13 @@ def list_progression_graphs(session: dict = Depends(require_auth)): def get_progression_graph( graph_id: int, include_edges: bool = Query(default=False), - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) - row = _graph_access(cur, graph_id, profile_id, role) + row = _require_graph_read(cur, graph_id, profile_id, role) if include_edges: cur.execute( _EDGE_SELECT + " WHERE e.graph_id = %s ORDER BY e.id", @@ -218,10 +258,10 @@ def get_progression_graph( @router.post("/exercise-progression-graphs", status_code=201) def create_progression_graph( body: ProgressionGraphCreate, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role if not _has_planning_role(role): raise HTTPException(status_code=403, detail="Keine Berechtigung zum Anlegen von Progressionsgraphen") @@ -229,55 +269,105 @@ def create_progression_graph( if not name: raise HTTPException(status_code=400, detail="name ist Pflicht") + vis = (body.visibility or "private").strip().lower() + cid = body.club_id + if vis == "club": + if cid is None: + cid = tenant.effective_club_id + if cid is None: + raise HTTPException( + status_code=400, + detail="Vereins-Graph: club_id angeben oder aktiven Verein wählen (X-Active-Club-Id).", + ) + + gov_club = cid if vis == "club" else None + with get_db() as conn: cur = get_cursor(conn) + assert_valid_governance_visibility(cur, profile_id, role, vis, gov_club) cur.execute( """ INSERT INTO exercise_progression_graphs (name, description, visibility, club_id, created_by) VALUES (%s, %s, %s, %s, %s) RETURNING id """, - (name, body.description, body.visibility, body.club_id, profile_id), + (name, body.description, vis, cid if vis == "club" else None, profile_id), ) gid = cur.fetchone()["id"] conn.commit() - return get_progression_graph(gid, include_edges=False, session=session) + return get_progression_graph(gid, include_edges=False, tenant=tenant) @router.put("/exercise-progression-graphs/{graph_id}") def update_progression_graph( graph_id: int, body: ProgressionGraphUpdate, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): - profile_id = session["profile_id"] - role = session.get("role") - data = body.model_dump(exclude_unset=True) - if not data: + profile_id = tenant.profile_id + role = tenant.global_role + original = body.model_dump(exclude_unset=True) + if not original: raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren") with get_db() as conn: cur = get_cursor(conn) - _graph_access(cur, graph_id, profile_id, role) + row = _require_graph_write(cur, graph_id, profile_id, role) + + ex_vis = (row.get("visibility") or "private").strip().lower() + ex_cid_raw = row.get("club_id") + ex_cid = int(ex_cid_raw) if ex_cid_raw is not None else None + + next_vis = ex_vis + if "visibility" in original and original["visibility"] is not None: + v_raw = str(original["visibility"]).strip().lower() + if v_raw: + next_vis = v_raw + + next_club = ex_cid + if "club_id" in original: + raw_c = original["club_id"] + if raw_c in (None, "", []): + next_club = None + else: + next_club = int(raw_c) + + if next_vis == "club": + if next_club is None: + next_club = tenant.effective_club_id + if next_club is None: + raise HTTPException( + status_code=400, + detail="Vereins-Graph: club_id angeben oder aktiven Verein wählen (X-Active-Club-Id).", + ) + + gov_club = next_club if next_vis == "club" else None + assert_valid_governance_visibility(cur, profile_id, role, next_vis, gov_club) fields: List[str] = [] params: List[Any] = [] - if "name" in data: - n = (data["name"] or "").strip() + if "name" in original: + n = (original["name"] or "").strip() if not n: raise HTTPException(status_code=400, detail="name ist Pflicht") fields.append("name = %s") params.append(n) - if "description" in data: + if "description" in original: fields.append("description = %s") - params.append(data["description"]) - if "visibility" in data: + params.append(original["description"]) + + vis_changed = next_vis != ex_vis + if "visibility" in original or vis_changed: fields.append("visibility = %s") - params.append(data["visibility"]) - if "club_id" in data: + params.append(next_vis) + + if "club_id" in original or vis_changed: fields.append("club_id = %s") - params.append(data["club_id"]) + params.append(next_club if next_vis == "club" else None) + + if not fields: + return get_progression_graph(graph_id, include_edges=False, tenant=tenant) fields.append("updated_at = NOW()") params.append(graph_id) @@ -287,16 +377,16 @@ def update_progression_graph( ) conn.commit() - return get_progression_graph(graph_id, include_edges=False, session=session) + return get_progression_graph(graph_id, include_edges=False, tenant=tenant) @router.delete("/exercise-progression-graphs/{graph_id}") -def delete_progression_graph(graph_id: int, session: dict = Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def delete_progression_graph(graph_id: int, tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) - _graph_access(cur, graph_id, profile_id, role) + _require_graph_write(cur, graph_id, profile_id, role) cur.execute("DELETE FROM exercise_progression_graphs WHERE id = %s", (graph_id,)) conn.commit() return {"ok": True} @@ -307,13 +397,13 @@ def list_progression_edges( graph_id: int, from_exercise_id: Optional[int] = Query(default=None), to_exercise_id: Optional[int] = Query(default=None), - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) - _graph_access(cur, graph_id, profile_id, role) + _require_graph_read(cur, graph_id, profile_id, role) q = _EDGE_SELECT + " WHERE e.graph_id = %s" params: List[Any] = [graph_id] if from_exercise_id is not None: @@ -331,14 +421,14 @@ def list_progression_edges( def create_progression_edge( graph_id: int, body: ProgressionEdgeCreate, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) - _graph_access(cur, graph_id, profile_id, role) + _require_graph_write(cur, graph_id, profile_id, role) _assert_exercises_exist(cur, body.from_exercise_id, body.to_exercise_id) fv = body.from_exercise_variant_id tv = body.to_exercise_variant_id @@ -372,11 +462,11 @@ def create_progression_edge( def create_progression_sequence( graph_id: int, body: ProgressionSequenceCreate, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): """Legt n−1 Nachfolger-Kanten (next_exercise) für eine geordnete Schrittliste an.""" - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role steps = body.steps n_seg = len(steps) - 1 seg_notes = body.segment_notes @@ -384,7 +474,7 @@ def create_progression_sequence( created: List[dict] = [] with get_db() as conn: cur = get_cursor(conn) - _graph_access(cur, graph_id, profile_id, role) + _require_graph_write(cur, graph_id, profile_id, role) ex_ids = [s.exercise_id for s in steps] _assert_exercises_exist(cur, *ex_ids) @@ -425,17 +515,17 @@ def update_progression_edge( graph_id: int, edge_id: int, body: ProgressionEdgeUpdate, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role data = body.model_dump(exclude_unset=True) if not data: raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren") with get_db() as conn: cur = get_cursor(conn) - _graph_access(cur, graph_id, profile_id, role) + _require_graph_write(cur, graph_id, profile_id, role) cur.execute( "SELECT id FROM exercise_progression_edges WHERE id = %s AND graph_id = %s", (edge_id, graph_id), @@ -459,13 +549,13 @@ def update_progression_edge( def delete_progression_edge( graph_id: int, edge_id: int, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) - _graph_access(cur, graph_id, profile_id, role) + _require_graph_write(cur, graph_id, profile_id, role) cur.execute( """ DELETE FROM exercise_progression_edges @@ -484,17 +574,17 @@ def delete_progression_edge( def delete_progression_edges_batch( graph_id: int, body: EdgeIdsBatch, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): """Löscht mehrere Kanten (z. B. eine zusammenhängende Kette in einem Schritt).""" - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role ids = body.edge_ids clean_ids = list(dict.fromkeys(ids)) with get_db() as conn: cur = get_cursor(conn) - _graph_access(cur, graph_id, profile_id, role) + _require_graph_write(cur, graph_id, profile_id, role) cur.execute( f""" DELETE FROM exercise_progression_edges diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 9dbfa77..e5b0e2b 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -9,13 +9,18 @@ import json import logging import os from pathlib import Path -from typing import Optional +from typing import Any, Dict, List, Optional from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File, Form from pydantic import BaseModel, Field, model_validator from db import get_db, get_cursor, r2d -from auth import require_auth +from club_tenancy import ( + assert_valid_governance_visibility, + is_platform_admin, + library_content_visible_to_profile, +) +from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql logger = logging.getLogger(__name__) @@ -80,8 +85,8 @@ ALLOWED_UPLOAD_MIMES = frozenset( ) -def _upload_limit_bytes(session: dict) -> int: - role = session.get("role") or "" +def _upload_limit_bytes(tenant: TenantContext) -> int: + role = tenant.global_role or "" if role in ("admin", "superadmin"): return MAX_UPLOAD_BYTES_ADMIN return MAX_UPLOAD_BYTES_USER @@ -208,6 +213,25 @@ class ExerciseVariantsReorder(BaseModel): variant_ids: list[int] +_VALID_EXERCISE_STATUS_BULK = frozenset({"draft", "in_review", "approved", "archived"}) +_MAX_BULK_METADATA_IDS = 500 + + +class ExerciseBulkMetadataPatch(BaseModel): + """Massenänderung von Sichtbarkeit und/oder Status (z. B. Private → Verein).""" + + exercise_ids: list[int] = Field(..., min_length=1, max_length=_MAX_BULK_METADATA_IDS) + visibility: Optional[str] = Field(None, pattern="^(private|club|official)$") + status: Optional[str] = None + club_id: Optional[int] = Field(default=None, ge=1) + + @model_validator(mode="after") + def at_least_visibility_or_status(self): + if self.visibility is None and self.status is None: + raise ValueError("Mindestens eines der Felder visibility oder status angeben") + return self + + # ============================================================================ # Helper Functions # ============================================================================ @@ -547,6 +571,127 @@ def _merge_str_any(multi: list[str], single: Optional[str]) -> list[str]: return out +@router.patch("/exercises/bulk-metadata") +def bulk_patch_exercises_metadata( + body: ExerciseBulkMetadataPatch, + tenant: TenantContext = Depends(get_tenant_context), +): + """ + Ändert Sichtbarkeit und/oder Status für viele Übungen auf einmal. + Berechtigt: Ersteller der jeweiligen Übung oder Plattform-Admin (admin/superadmin). + Governance wie bei Einzel-PUT (official nur Plattform-Admin; club mit Mitgliedschaft bzw. Admin). + """ + profile_id = tenant.profile_id + role = tenant.global_role + + unique_ids = sorted({int(x) for x in body.exercise_ids if x is not None and int(x) > 0}) + if not unique_ids: + raise HTTPException(status_code=400, detail="Keine gültigen Übungs-IDs") + if len(unique_ids) > _MAX_BULK_METADATA_IDS: + raise HTTPException( + status_code=400, + detail=f"Maximal {_MAX_BULK_METADATA_IDS} Übungen pro Anfrage", + ) + + status_val: Optional[str] = None + if body.status is not None: + st = str(body.status).strip().lower() + if st not in _VALID_EXERCISE_STATUS_BULK: + raise HTTPException(status_code=400, detail="Ungültiger Status") + status_val = st + + patch_visibility = body.visibility is not None + patch_status = status_val is not None + + updated: List[int] = [] + failed: List[Dict[str, Any]] = [] + + def _fail_msg(he: HTTPException) -> str: + d = he.detail + return d if isinstance(d, str) else str(d) + + with get_db() as conn: + cur = get_cursor(conn) + for ex_id in unique_ids: + cur.execute( + "SELECT id, created_by, visibility, club_id FROM exercises WHERE id = %s", + (ex_id,), + ) + row = cur.fetchone() + if not row: + failed.append({"id": ex_id, "detail": "Übung nicht gefunden"}) + continue + rowd = r2d(row) + owner = rowd.get("created_by") + if owner is not None: + owner = int(owner) + if owner != profile_id and not is_platform_admin(role): + failed.append( + { + "id": ex_id, + "detail": "Keine Berechtigung (nur Ersteller oder Plattform-Admin)", + } + ) + continue + + ex_vis = (rowd.get("visibility") or "private").strip().lower() + ex_cid_raw = rowd.get("club_id") + ex_cid = int(ex_cid_raw) if ex_cid_raw is not None else None + + next_vis = ex_vis + if patch_visibility: + next_vis = str(body.visibility).strip().lower() + + next_club = ex_cid + if patch_visibility and body.club_id is not None: + next_club = int(body.club_id) + + if patch_visibility: + if next_vis == "club": + if next_club is None: + next_club = tenant.effective_club_id + if next_club is None: + failed.append( + { + "id": ex_id, + "detail": "Vereins-Übung: club_id angeben oder aktiven Verein wählen (X-Active-Club-Id).", + } + ) + continue + gov_club = next_club if next_vis == "club" else None + try: + assert_valid_governance_visibility(cur, profile_id, role, next_vis, gov_club) + except HTTPException as he: + failed.append({"id": ex_id, "detail": _fail_msg(he)}) + continue + + sets: List[str] = [] + vals: List[Any] = [] + if patch_visibility: + sets.extend(["visibility = %s", "club_id = %s"]) + cid_out = next_club if next_vis == "club" else None + vals.extend([next_vis, cid_out]) + if patch_status: + sets.append("status = %s") + vals.append(status_val) + + sets.append("updated_at = NOW()") + vals.append(ex_id) + cur.execute( + f"UPDATE exercises SET {', '.join(sets)} WHERE id = %s", + tuple(vals), + ) + updated.append(ex_id) + conn.commit() + + return { + "updated": updated, + "failed": failed, + "updated_count": len(updated), + "failed_count": len(failed), + } + + @router.get("/exercises") def list_exercises( focus_area_ids: list[int] = Query(default=[], description="ODER: mind. einer dieser Fokusbereiche"), @@ -576,14 +721,14 @@ def list_exercises( default=False, description="Wenn true: Feld variants[] pro Übung (id, variant_name, sequence_order) für Planung/UI", ), - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): """ Liste aller Übungen mit Filtern. Lightweight Response (ohne M:N Details, nur IDs und Namen). Optional include_variants für Variantenauswahl in der Trainingsplanung. """ - profile_id = session["profile_id"] + profile_id = tenant.profile_id with get_db() as conn: cur = get_cursor(conn) @@ -592,9 +737,16 @@ def list_exercises( where = ["1=1"] params = [] - # Visibility Filter (private nur für Owner) - where.append("(e.visibility = 'official' OR e.visibility = 'club' OR e.created_by = %s)") - params.append(profile_id) + role = tenant.global_role + if not is_platform_admin(role): + vis_sql, vis_params = library_content_visibility_sql( + alias="e", + profile_id=profile_id, + role=role, + effective_club_id=tenant.effective_club_id, + ) + where.append(vis_sql) + params.extend(vis_params) vis_list = _merge_str_any(visibility_any, visibility) if vis_list: @@ -743,23 +895,29 @@ def list_exercises( @router.get("/exercises/{exercise_id}") def get_exercise( exercise_id: int, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): """ Exercise Detail mit allen M:N Relations (vollständig enriched). """ - profile_id = session["profile_id"] + profile_id = tenant.profile_id with get_db() as conn: cur = get_cursor(conn) exercise = enrich_exercise_detail(exercise_id, cur) - if not exercise: - raise HTTPException(status_code=404, detail="Übung nicht gefunden") + if not exercise: + raise HTTPException(status_code=404, detail="Übung nicht gefunden") - # Permission Check (private nur für Owner) - if exercise["visibility"] == "private" and exercise["created_by"] != profile_id: - raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Übung") + if not library_content_visible_to_profile( + cur, + profile_id, + exercise["visibility"], + exercise.get("club_id"), + exercise.get("created_by"), + tenant.global_role, + ): + raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Übung") return exercise @@ -767,12 +925,12 @@ def get_exercise( @router.post("/exercises", status_code=201) def create_exercise( body: ExerciseCreate, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): """ Erstellt eine neue Übung mit allen M:N Relations. """ - profile_id = session["profile_id"] + profile_id = tenant.profile_id # Validierung if body.status not in ("draft", "in_review", "approved", "archived"): @@ -780,8 +938,15 @@ def create_exercise( if body.visibility not in ("private", "club", "official"): raise HTTPException(status_code=400, detail="Ungültige Visibility") + club_id = body.club_id + if body.visibility == "club" and club_id is None: + club_id = tenant.effective_club_id + with get_db() as conn: cur = get_cursor(conn) + assert_valid_governance_visibility( + cur, profile_id, tenant.global_role, body.visibility, club_id + ) # Equipment als JSONB equipment_json = json.dumps(body.equipment) if body.equipment else None @@ -800,7 +965,7 @@ def create_exercise( body.duration_min, body.duration_max, body.group_size_min, body.group_size_max, equipment_json, - body.visibility, body.status, profile_id, body.club_id, + body.visibility, body.status, profile_id, club_id, ) ) row = cur.fetchone() @@ -821,34 +986,70 @@ def create_exercise( def update_exercise( exercise_id: int, body: ExerciseUpdate, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): """ Aktualisiert eine Übung (Partial Update). Nur Owner darf editieren. """ - profile_id = session["profile_id"] + profile_id = tenant.profile_id with get_db() as conn: cur = get_cursor(conn) - # Existiert die Übung? - cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,)) + cur.execute( + "SELECT created_by, visibility, club_id FROM exercises WHERE id = %s", + (exercise_id,), + ) row = cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Übung nicht gefunden") - # Permission Check if _row_created_by(row) != profile_id: raise HTTPException(status_code=403, detail="Nur der Ersteller darf editieren") - # UPDATE (nur gesetzte Felder) - fields = [] - params = [] + ex_vis = (row.get("visibility") or "private").strip().lower() + ex_cid = row.get("club_id") + if ex_cid is not None: + ex_cid = int(ex_cid) data = body.dict(exclude_unset=True) - # Basis-Felder + next_vis = ex_vis + if "visibility" in data and data["visibility"] is not None: + v_raw = str(data["visibility"]).strip().lower() + if v_raw: + next_vis = v_raw + + next_club = ex_cid + if "club_id" in data: + raw_c = data["club_id"] + if raw_c in (None, "", []): + next_club = None + else: + next_club = int(raw_c) + + if next_vis == "club": + if next_club is None: + next_club = tenant.effective_club_id + if next_club is None: + raise HTTPException( + status_code=400, + detail="Vereins-Übung: club_id angeben oder aktiven Verein wählen (X-Active-Club-Id).", + ) + data["club_id"] = next_club + + if next_vis != ex_vis: + data["visibility"] = next_vis + + gov_club = next_club if next_vis == "club" else None + assert_valid_governance_visibility( + cur, profile_id, tenant.global_role, next_vis, gov_club + ) + + fields = [] + params = [] + for field in ["title", "summary", "goal", "execution", "preparation", "trainer_notes", "duration_min", "duration_max", "group_size_min", "group_size_max", "visibility", "status", "club_id"]: @@ -856,12 +1057,10 @@ def update_exercise( fields.append(f"{field} = %s") params.append(data[field]) - # Equipment (JSONB) if "equipment" in data: fields.append("equipment = %s") params.append(json.dumps(data["equipment"]) if data["equipment"] else None) - # UPDATE ausführen (wenn Basis-Felder geändert wurden) if fields: fields.append("updated_at = NOW()") params.append(exercise_id) @@ -869,10 +1068,8 @@ def update_exercise( cur.execute(query, params) conn.commit() - # M:N Relations aktualisieren (wenn angegeben) assign_exercise_relations(cur, conn, exercise_id, data) - # Vollständiges Objekt zurückgeben exercise = enrich_exercise_detail(exercise_id, cur) return exercise @@ -881,14 +1078,14 @@ def update_exercise( @router.delete("/exercises/{exercise_id}") def delete_exercise( exercise_id: int, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): """ Löscht eine Übung. Nur Owner oder Admin darf löschen. """ - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) @@ -900,7 +1097,7 @@ def delete_exercise( raise HTTPException(status_code=404, detail="Übung nicht gefunden") # Permission Check - if _row_created_by(row) != profile_id and role not in ("admin", "superadmin"): + if _row_created_by(row) != profile_id and not is_platform_admin(role): raise HTTPException(status_code=403, detail="Nur Ersteller oder Admin darf löschen") # Prüfen ob Übung in Block-Items verwendet wird @@ -930,9 +1127,9 @@ def delete_exercise( def reorder_exercise_variants( exercise_id: int, body: ExerciseVariantsReorder, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): - profile_id = session["profile_id"] + profile_id = tenant.profile_id if len(body.variant_ids) != len(set(body.variant_ids)): raise HTTPException(status_code=400, detail="variant_ids dürfen keine Duplikate enthalten") @@ -967,9 +1164,9 @@ def reorder_exercise_variants( def create_exercise_variant( exercise_id: int, body: ExerciseVariantCreate, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): - profile_id = session["profile_id"] + profile_id = tenant.profile_id with get_db() as conn: cur = get_cursor(conn) @@ -1023,9 +1220,9 @@ def update_exercise_variant( exercise_id: int, variant_id: int, body: ExerciseVariantUpdate, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): - profile_id = session["profile_id"] + profile_id = tenant.profile_id data = body.dict(exclude_unset=True) if not data: @@ -1096,12 +1293,15 @@ def update_exercise_variant( conn.commit() return row + + +@router.delete("/exercises/{exercise_id}/variants/{variant_id}") def delete_exercise_variant( exercise_id: int, variant_id: int, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): - profile_id = session["profile_id"] + profile_id = tenant.profile_id with get_db() as conn: cur = get_cursor(conn) @@ -1145,7 +1345,7 @@ def _fetch_media_row(cur, exercise_id: int, media_id: int) -> Optional[dict]: @router.post("/exercises/{exercise_id}/media", status_code=201) async def upload_exercise_media( exercise_id: int, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), file: Optional[UploadFile] = File(None), embed_url: Optional[str] = Form(None), media_type: str = Form(...), @@ -1154,7 +1354,7 @@ async def upload_exercise_media( context: str = Form("ablauf"), is_primary: bool = Form(False), ): - profile_id = session["profile_id"] + profile_id = tenant.profile_id if media_type not in ("image", "video", "document", "sketch"): raise HTTPException(status_code=400, detail="Ungültiger media_type") if context not in ("ablauf", "detail", "trainer_hint"): @@ -1212,7 +1412,7 @@ async def upload_exercise_media( ) else: raw = await file.read() - max_upload = _upload_limit_bytes(session) + max_upload = _upload_limit_bytes(tenant) if len(raw) > max_upload: raise HTTPException( status_code=413, @@ -1267,9 +1467,9 @@ async def upload_exercise_media( def reorder_exercise_media( exercise_id: int, body: ExerciseMediaReorder, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): - profile_id = session["profile_id"] + profile_id = tenant.profile_id ids = body.media_ids with get_db() as conn: cur = get_cursor(conn) @@ -1298,9 +1498,9 @@ def update_exercise_media( exercise_id: int, media_id: int, body: ExerciseMediaUpdate, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): - profile_id = session["profile_id"] + profile_id = tenant.profile_id data = body.dict(exclude_unset=True) if not data: raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren") @@ -1340,9 +1540,9 @@ def update_exercise_media( def delete_exercise_media( exercise_id: int, media_id: int, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): - profile_id = session["profile_id"] + profile_id = tenant.profile_id with get_db() as conn: cur = get_cursor(conn) _assert_can_edit_exercise(cur, exercise_id, profile_id) diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py index c79b185..b0d8f82 100644 --- a/backend/routers/profiles.py +++ b/backend/routers/profiles.py @@ -11,10 +11,14 @@ from fastapi import APIRouter, HTTPException, Header, Depends from db import get_db, get_cursor, r2d from auth import require_auth +from club_tenancy import assert_club_member, memberships_with_roles, is_platform_admin +from tenant_context import resolve_tenant_context, TenantContext, get_tenant_context from models import ProfileCreate, ProfileUpdate router = APIRouter(prefix="/api", tags=["profiles"]) +_ALLOWED_PORTAL_ROLES = frozenset({"user", "trainer", "admin", "superadmin"}) + # ── Helper ──────────────────────────────────────────────────────────────────── def get_pid(x_profile_id: Optional[str] = Header(default=None)) -> str: @@ -31,27 +35,54 @@ def get_pid(x_profile_id: Optional[str] = Header(default=None)) -> str: # ── Current User Profile ────────────────────────────────────────────────────── @router.get("/profiles/me") -def get_current_profile(session=Depends(require_auth)): - """Get current user's profile (for auth check on refresh).""" - profile_id = session['profile_id'] +def get_current_profile( + session=Depends(require_auth), + x_active_club_id: Optional[str] = Header(default=None, alias="X-Active-Club-Id"), +): + """Profil inkl. Vereinsmitgliedschaften; effective_club_id = aufgelöster Request-Kontext (Header vor Profilfeld).""" + profile_id = session["profile_id"] with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT * FROM profiles WHERE id=%s", (profile_id,)) row = cur.fetchone() if not row: raise HTTPException(404, "Profil nicht gefunden") - return r2d(row) + data = r2d(row) + data.pop("pin_hash", None) + clubs = memberships_with_roles(cur, profile_id) + data["clubs"] = clubs + ac_raw = data.get("active_club_id") + stored_ac = int(ac_raw) if ac_raw is not None and ac_raw != "" else None + tenant = resolve_tenant_context( + cur, + profile_id=int(profile_id), + global_role=session.get("role") or "", + header_raw=x_active_club_id, + memberships=clubs, + stored_active_club_id=stored_ac, + invalid_header_policy="ignore", + ) + data["effective_club_id"] = tenant.effective_club_id + return data # ── Admin Profile Management ────────────────────────────────────────────────── @router.get("/profiles") def list_profiles(session=Depends(require_auth)): - """List all profiles (admin).""" + """Liste aller Profile (nur Plattform-Admin).""" + role = (session.get("role") or "").lower() + if not is_platform_admin(role): + raise HTTPException(status_code=403, detail="Nur Plattform-Administratoren dürfen alle Profile einsehen") with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT * FROM profiles ORDER BY created") rows = cur.fetchall() - return [r2d(r) for r in rows] + out = [] + for r in rows: + d = r2d(r) + d.pop("pin_hash", None) + out.append(d) + return out @router.post("/profiles") @@ -69,24 +100,29 @@ def create_profile(p: ProfileCreate, session=Depends(require_auth)): return r2d(cur.fetchone()) -@router.get("/profiles/{pid}") -def get_profile(pid: str, session=Depends(require_auth)): - """Get profile by ID.""" +def profile_document(pid: str) -> dict: + """Profil ohne PIN — für Routen und interne Aufrufe (z. B. /auth/me).""" with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) row = cur.fetchone() - if not row: raise HTTPException(404, "Profil nicht gefunden") - return r2d(row) + if not row: + raise HTTPException(404, "Profil nicht gefunden") + d = r2d(row) + d.pop("pin_hash", None) + return d -@router.put("/profiles/{pid}") -def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)): - """Update profile — nur eigenes Profil oder Admin.""" - sess_pid = session.get('profile_id') - role = (session.get('role') or '').lower() - if str(sess_pid) != str(pid) and role not in ('admin', 'superadmin'): - raise HTTPException(403, 'Keine Berechtigung für dieses Profil') +@router.get("/profiles/{pid}") +def get_profile(pid: str, _session=Depends(require_auth)): + """Get profile by ID.""" + return profile_document(pid) +def _run_profile_update(pid: str, p: ProfileUpdate, tenant: TenantContext) -> dict: + """Gemeinsame PUT-Logik für /profiles/{id} und Legacy /profile.""" + sess_pid = tenant.profile_id + role = tenant.global_role + if str(sess_pid) != str(pid) and role not in ("admin", "superadmin"): + raise HTTPException(403, "Keine Berechtigung für dieses Profil") with get_db() as conn: cur = get_cursor(conn) @@ -100,6 +136,51 @@ def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)): patch = p.model_dump(exclude_unset=True) data = {} + if "role" in patch or "tier" in patch: + if not is_platform_admin(role): + raise HTTPException( + status_code=403, + detail="Nur Portal-Admins dürfen Rolle oder Tier ändern", + ) + + if "role" in patch: + new_role = (patch["role"] or "").strip().lower() + if new_role not in _ALLOWED_PORTAL_ROLES: + raise HTTPException( + status_code=400, + detail=f"Ungültige Portal-Rolle. Erlaubt: {', '.join(sorted(_ALLOWED_PORTAL_ROLES))}", + ) + if new_role == "superadmin" and role != "superadmin": + raise HTTPException( + status_code=403, + detail="Nur Super-Admins dürfen die Rolle Super-Admin vergeben", + ) + old_r = (rowd.get("role") or "user").strip().lower() + cur.execute( + """ + SELECT COUNT(*)::int AS c FROM profiles + WHERE lower(trim(role)) IN ('admin','superadmin') + """ + ) + admin_cnt = int(cur.fetchone()["c"]) + if old_r in ("admin", "superadmin") and new_role not in ("admin", "superadmin"): + if admin_cnt <= 1: + raise HTTPException( + status_code=400, + detail="Der letzte Portal-Administrator kann nicht zurückgestuft werden", + ) + data["role"] = new_role + del patch["role"] + + if "tier" in patch: + tv = patch["tier"] + if tv is None: + data["tier"] = "free" + else: + ts = str(tv).strip() + data["tier"] = (ts or "free")[:50] + del patch["tier"] + if "email" in patch: ev = patch["email"] if ev is None or (isinstance(ev, str) and ev.strip() == ""): @@ -127,23 +208,43 @@ def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)): data["verification_token"] = None data["verification_expires"] = None + if "active_club_id" in patch: + ac = patch["active_club_id"] + if ac is None: + data["active_club_id"] = None + else: + try: + cid = int(ac) + except (TypeError, ValueError): + raise HTTPException(400, "active_club_id ungültig") + assert_club_member(cur, int(pid), cid) + data["active_club_id"] = cid + nullable_keys = {"goal_weight", "goal_bf_pct", "dob"} for k, v in patch.items(): if k == "email": continue + if k == "active_club_id": + continue if v is None and k in nullable_keys: data[k] = None elif v is not None: data[k] = v if not data: - return get_profile(pid, session) + return profile_document(pid) data["updated_at"] = datetime.now() cols = ", ".join(f"{k}=%s" for k in data) vals = list(data.values()) + [pid] cur.execute(f"UPDATE profiles SET {cols} WHERE id=%s", vals) - return get_profile(pid, session) + return profile_document(pid) + + +@router.put("/profiles/{pid}") +def update_profile(pid: str, p: ProfileUpdate, tenant: TenantContext = Depends(get_tenant_context)): + """Update profile — nur eigenes Profil oder Admin; TenantContext validiert X-Active-Club-Id.""" + return _run_profile_update(pid, p, tenant) @router.delete("/profiles/{pid}") @@ -165,11 +266,15 @@ def delete_profile(pid: str, session=Depends(require_auth)): def get_active_profile(x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth)): """Legacy endpoint – returns active profile.""" pid = get_pid(x_profile_id) - return get_profile(pid, session) + return profile_document(pid) @router.put("/profile") -def update_active_profile(p: ProfileUpdate, x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth)): +def update_active_profile( + p: ProfileUpdate, + x_profile_id: Optional[str] = Header(default=None), + tenant: TenantContext = Depends(get_tenant_context), +): """Update current user's profile.""" pid = get_pid(x_profile_id) - return update_profile(pid, p, session) + return _run_profile_update(pid, p, tenant) diff --git a/backend/routers/training_framework_programs.py b/backend/routers/training_framework_programs.py index 459f7f1..cc8a367 100644 --- a/backend/routers/training_framework_programs.py +++ b/backend/routers/training_framework_programs.py @@ -3,13 +3,17 @@ Trainingsrahmenprogramm — wiederverwendbare Vorlage (Bibliothek) über mehrere Zuordnung zu Trainingsgruppen / konkreten Einheiten erfolgt aus der Planung (Kopie + Lineage), nicht über group_id oder training_unit_id am Rahmen. -AuthZ wie Planungs-Vorlagen: Admin sieht alle, sonst nur eigene Artefakte; Schreibzugriff mit Planungsrolle. +Lesen wie Übungen (official / private / club); Schreiben nur Ersteller oder Plattform-Admin. """ from typing import Any, Dict, List, Optional, Sequence from fastapi import APIRouter, Depends, HTTPException -from auth import require_auth +from club_tenancy import ( + assert_valid_governance_visibility, + is_platform_admin, + library_content_visible_to_profile, +) from db import get_db, get_cursor, r2d from routers.training_planning import ( @@ -21,23 +25,57 @@ from routers.training_planning import ( _validate_variant_for_exercise, ) +from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql + router = APIRouter(prefix="/api", tags=["training_framework_programs"]) _VALID_VISIBILITY = frozenset({"private", "club", "official"}) -def _framework_access(cur, framework_id: int, profile_id: int, role: str) -> Dict[str, Any]: +def _fetch_framework_row(cur, framework_id: int) -> Dict[str, Any]: cur.execute("SELECT * FROM training_framework_programs WHERE id = %s", (framework_id,)) r = cur.fetchone() if not r: raise HTTPException(status_code=404, detail="Trainingsrahmen nicht gefunden") - row = r2d(r) - if role in ("admin", "superadmin"): - return row + return r2d(r) + + +def _framework_assert_readable( + cur, row: Dict[str, Any], profile_id: int, role: Optional[str] +) -> None: + if is_platform_admin(role): + return + if not library_content_visible_to_profile( + cur, + profile_id, + row.get("visibility") or "private", + row.get("club_id"), + row.get("created_by"), + role, + ): + raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Trainingsrahmen") + + +def _framework_assert_writable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None: + if is_platform_admin(role): + return if row.get("created_by") != profile_id: raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Trainingsrahmen") + + +def _framework_access(cur, framework_id: int, profile_id: int, role: str) -> Dict[str, Any]: + row = _fetch_framework_row(cur, framework_id) + _framework_assert_readable(cur, row, profile_id, role) return row +def _response_framework_detail(framework_id: int, profile_id: int, role: str) -> Dict[str, Any]: + """Einzelabruf nach Schreiboperation (ohne FastAPI-Depends-Schleife).""" + with get_db() as conn: + cur = get_cursor(conn) + row = _framework_access(cur, framework_id, profile_id, role) + return _hydrate_framework(cur, row) + + def _training_type_ids(cur, framework_id: int) -> List[int]: cur.execute( """ @@ -279,9 +317,9 @@ def _insert_slots_and_blueprints( @router.get("/training-framework-programs") -def list_training_framework_programs(session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def list_training_framework_programs(tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) base_sel = """ @@ -312,30 +350,39 @@ def list_training_framework_programs(session=Depends(require_auth)): LEFT JOIN focus_areas fa ON fa.id = fp.focus_area_id LEFT JOIN style_directions sd ON sd.id = fp.style_direction_id """ - if role in ("admin", "superadmin"): + if is_platform_admin(role): cur.execute(base_sel + " ORDER BY fp.updated_at DESC NULLS LAST, fp.title") else: + vis_clause, vis_params = library_content_visibility_sql( + alias="fp", + profile_id=profile_id, + role=role, + effective_club_id=tenant.effective_club_id, + ) cur.execute( - base_sel + " WHERE fp.created_by = %s ORDER BY fp.updated_at DESC NULLS LAST, fp.title", - (profile_id,), + base_sel + + f""" WHERE ({vis_clause}) + ORDER BY fp.updated_at DESC NULLS LAST, fp.title""", + vis_params, ) return [r2d(r) for r in cur.fetchall()] @router.get("/training-framework-programs/{framework_id}") -def get_training_framework_program(framework_id: int, session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") - with get_db() as conn: - cur = get_cursor(conn) - row = _framework_access(cur, framework_id, profile_id, role) - return _hydrate_framework(cur, row) +def get_training_framework_program( + framework_id: int, tenant: TenantContext = Depends(get_tenant_context) +): + profile_id = tenant.profile_id + role = tenant.global_role + return _response_framework_detail(framework_id, profile_id, role) @router.post("/training-framework-programs") -def create_training_framework_program(data: dict, session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def create_training_framework_program( + data: dict, tenant: TenantContext = Depends(get_tenant_context) +): + profile_id = tenant.profile_id + role = tenant.global_role if not _has_planning_role(role): raise HTTPException(status_code=403, detail="Nur Planungsberechtigte dürfen Rahmenprogramme anlegen") @@ -346,6 +393,11 @@ def create_training_framework_program(data: dict, session=Depends(require_auth)) vis = data.get("visibility") or "private" vis = _assert_visibility(vis) club_id = data.get("club_id") + if club_id in ("", []): + club_id = None + if vis == "club" and club_id is None: + club_id = tenant.effective_club_id + goals_in = data.get("goals") slots_in = data.get("slots") if not isinstance(goals_in, list) or not goals_in: @@ -358,6 +410,7 @@ def create_training_framework_program(data: dict, session=Depends(require_auth)) with get_db() as conn: cur = get_cursor(conn) + assert_valid_governance_visibility(cur, profile_id, role, vis, club_id) cur.execute( """ INSERT INTO training_framework_programs ( @@ -387,19 +440,36 @@ def create_training_framework_program(data: dict, session=Depends(require_auth)) _replace_target_groups(cur, fid, tg_ids) conn.commit() - return get_training_framework_program(fid, session) + return _response_framework_detail(fid, profile_id, role) @router.put("/training-framework-programs/{framework_id}") -def update_training_framework_program(framework_id: int, data: dict, session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def update_training_framework_program( + framework_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context) +): + profile_id = tenant.profile_id + role = tenant.global_role if not _has_planning_role(role): raise HTTPException(status_code=403, detail="Keine Berechtigung") with get_db() as conn: cur = get_cursor(conn) - _framework_access(cur, framework_id, profile_id, role) + row_prev = _fetch_framework_row(cur, framework_id) + _framework_assert_writable(cur, row_prev, profile_id, role) + + merged_vis = row_prev.get("visibility") or "private" + merged_club = row_prev.get("club_id") + if "visibility" in data: + v_m = _assert_visibility(data.get("visibility")) + if v_m is None: + raise HTTPException(status_code=400, detail="visibility fehlt") + merged_vis = v_m + if "club_id" in data: + merged_club = data.get("club_id") + if merged_club in ("", []): + merged_club = None + if "visibility" in data or "club_id" in data: + assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club) header_fields = [] header_params: List[Any] = [] @@ -422,14 +492,11 @@ def update_training_framework_program(framework_id: int, data: dict, session=Dep header_params.append(data.get("planned_period_end")) if "visibility" in data: - v = _assert_visibility(data.get("visibility")) - if v is None: - raise HTTPException(status_code=400, detail="visibility fehlt") header_fields.append("visibility = %s") - header_params.append(v) + header_params.append(merged_vis) if "club_id" in data: header_fields.append("club_id = %s") - header_params.append(data.get("club_id")) + header_params.append(merged_club) if "focus_area_id" in data: fidv = data.get("focus_area_id") @@ -489,16 +556,19 @@ def update_training_framework_program(framework_id: int, data: dict, session=Dep conn.commit() - return get_training_framework_program(framework_id, session) + return _response_framework_detail(framework_id, profile_id, role) @router.delete("/training-framework-programs/{framework_id}") -def delete_training_framework_program(framework_id: int, session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def delete_training_framework_program( + framework_id: int, tenant: TenantContext = Depends(get_tenant_context) +): + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) - _framework_access(cur, framework_id, profile_id, role) + row_fw = _fetch_framework_row(cur, framework_id) + _framework_assert_writable(cur, row_fw, profile_id, role) cur.execute( "DELETE FROM training_framework_programs WHERE id = %s", (framework_id,), diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index 2cd7f03..e664209 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -2,14 +2,19 @@ Training Planning – Trainingseinheiten, strukturierter Ablauf (Sektionen + Übungen/Notizen) und wiederverwendbare Trainingsvorlagen (Sektions-Gliederung). -Governance (Vorlagen-rechte über Vereine/„offiziell“) kann später nachgezogen werden. +Governance: Sichtbarkeit wie Übungen (private / club / official); Schreiben nur Ersteller oder Plattform-Admin. """ from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, Query from db import get_db, get_cursor, r2d -from auth import require_auth +from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql +from club_tenancy import ( + assert_valid_governance_visibility, + is_platform_admin, + library_content_visible_to_profile, +) router = APIRouter(prefix="/api", tags=["training_planning"]) @@ -515,23 +520,39 @@ def _instantiate_from_template(cur, unit_id: int, template_id: int): ) -def _template_access(cur, tid: int, profile_id: int, role: str) -> Dict[str, Any]: - cur.execute( - """ - SELECT * - FROM training_plan_templates - WHERE id = %s - """, - (tid,), - ) +def _fetch_training_plan_template_row(cur, tid: int) -> Dict[str, Any]: + cur.execute("SELECT * FROM training_plan_templates WHERE id = %s", (tid,)) r = cur.fetchone() if not r: raise HTTPException(status_code=404, detail="Trainingsvorlage nicht gefunden") - row = r2d(r) - if role in ["admin", "superadmin"]: - return row - if row["created_by"] != profile_id: + return r2d(r) + + +def _template_assert_readable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None: + if is_platform_admin(role): + return + if not library_content_visible_to_profile( + cur, + profile_id, + row.get("visibility") or "club", + row.get("club_id"), + row.get("created_by"), + role, + ): raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Vorlage") + + +def _template_assert_writable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None: + if is_platform_admin(role): + return + if row.get("created_by") != profile_id: + raise HTTPException(status_code=403, detail="Nur der Ersteller darf diese Vorlage ändern") + + +def _template_access(cur, tid: int, profile_id: int, role: str) -> Dict[str, Any]: + """Lesender Zugriff (Liste der Vorlage für Einheit); Schreiben: _template_assert_writable.""" + row = _fetch_training_plan_template_row(cur, tid) + _template_assert_readable(cur, row, profile_id, role) return row @@ -539,12 +560,12 @@ def _template_access(cur, tid: int, profile_id: int, role: str) -> Dict[str, Any @router.get("/training-plan-templates") -def list_training_plan_templates(session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def list_training_plan_templates(tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) - if role in ["admin", "superadmin"]: + if is_platform_admin(role): cur.execute( """ SELECT t.*, @@ -555,24 +576,30 @@ def list_training_plan_templates(session=Depends(require_auth)): """ ) else: + vis_clause, vis_params = library_content_visibility_sql( + alias="t", + profile_id=profile_id, + role=role, + effective_club_id=tenant.effective_club_id, + ) cur.execute( - """ + f""" SELECT t.*, (SELECT COUNT(*) FROM training_plan_template_sections s WHERE s.template_id = t.id) AS sections_count FROM training_plan_templates t - WHERE t.created_by = %s + WHERE ({vis_clause}) ORDER BY t.updated_at DESC NULLS LAST, t.name """, - (profile_id,), + vis_params, ) return [r2d(r) for r in cur.fetchall()] @router.get("/training-plan-templates/{template_id}") -def get_training_plan_template(template_id: int, session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def get_training_plan_template(template_id: int, tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) row = _template_access(cur, template_id, profile_id, role) @@ -590,26 +617,33 @@ def get_training_plan_template(template_id: int, session=Depends(require_auth)): @router.post("/training-plan-templates") -def create_training_plan_template(data: dict, session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def create_training_plan_template(data: dict, tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role if not _has_planning_role(role): raise HTTPException(status_code=403, detail="Nur Trainer dürfen Vorlagen anlegen") name = (data.get("name") or "").strip() if not name: raise HTTPException(status_code=400, detail="name ist Pflicht") + vis_raw = data.get("visibility") + visibility = (vis_raw if isinstance(vis_raw, str) else "club").strip() or "club" club_id = data.get("club_id") + if club_id in ("", []): + club_id = None + if visibility == "club" and club_id is None: + club_id = tenant.effective_club_id sections_in = data.get("sections") or [] with get_db() as conn: cur = get_cursor(conn) + assert_valid_governance_visibility(cur, profile_id, role, visibility, club_id) cur.execute( """ - INSERT INTO training_plan_templates (club_id, created_by, name, description) - VALUES (%s, %s, %s, %s) + INSERT INTO training_plan_templates (club_id, created_by, name, description, visibility) + VALUES (%s, %s, %s, %s, %s) RETURNING id """, - (club_id, profile_id, name, data.get("description")), + (club_id, profile_id, name, data.get("description"), visibility), ) tid = cur.fetchone()["id"] for si, sec in enumerate(sections_in): @@ -625,16 +659,30 @@ def create_training_plan_template(data: dict, session=Depends(require_auth)): (tid, order_ix, title, sec.get("guidance_text")), ) conn.commit() - return get_training_plan_template(tid, session) + return get_training_plan_template(tid, tenant) @router.put("/training-plan-templates/{template_id}") -def update_training_plan_template(template_id: int, data: dict, session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def update_training_plan_template(template_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) - _template_access(cur, template_id, profile_id, role) + row_prev = _fetch_training_plan_template_row(cur, template_id) + _template_assert_writable(cur, row_prev, profile_id, role) + merged_vis = row_prev.get("visibility") or "club" + merged_club = row_prev.get("club_id") + if "visibility" in data: + v_in = data.get("visibility") + if not isinstance(v_in, str) or v_in not in ("private", "club", "official"): + raise HTTPException(status_code=400, detail="visibility ungültig") + merged_vis = v_in + if "club_id" in data: + merged_club = data.get("club_id") + if merged_club in ("", []): + merged_club = None + if "visibility" in data or "club_id" in data: + assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club) fields = [] params: List[Any] = [] if "name" in data: @@ -649,7 +697,10 @@ def update_training_plan_template(template_id: int, data: dict, session=Depends( params.append(data.get("description")) if "club_id" in data: fields.append("club_id = %s") - params.append(data.get("club_id")) + params.append(merged_club) + if "visibility" in data: + fields.append("visibility = %s") + params.append(merged_vis) fields.append("updated_at = NOW()") params.append(template_id) cur.execute( @@ -677,16 +728,17 @@ def update_training_plan_template(template_id: int, data: dict, session=Depends( (template_id, order_ix, title, sec.get("guidance_text")), ) conn.commit() - return get_training_plan_template(template_id, session) + return get_training_plan_template(template_id, tenant) @router.delete("/training-plan-templates/{template_id}") -def delete_training_plan_template(template_id: int, session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def delete_training_plan_template(template_id: int, tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) - _template_access(cur, template_id, profile_id, role) + row_del = _fetch_training_plan_template_row(cur, template_id) + _template_assert_writable(cur, row_del, profile_id, role) cur.execute("DELETE FROM training_plan_templates WHERE id = %s", (template_id,)) conn.commit() return {"ok": True} @@ -705,10 +757,10 @@ def list_training_units( assigned_to_me: bool = Query(default=False), sort: str = Query(default="desc"), limit: Optional[int] = Query(default=None), - session=Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role gid = _optional_positive_int(group_id, "group_id") if group_id else None cid = _optional_positive_int(club_id, "club_id") if club_id else None @@ -818,9 +870,9 @@ def list_training_units( @router.get("/training-units/{unit_id}") -def get_training_unit(unit_id: int, session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def get_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) @@ -882,9 +934,9 @@ def get_training_unit(unit_id: int, session=Depends(require_auth)): @router.post("/training-units") -def create_training_unit(data: dict, session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role group_id = data.get("group_id") planned_date = data.get("planned_date") @@ -941,13 +993,13 @@ def create_training_unit(data: dict, session=Depends(require_auth)): conn.commit() - return get_training_unit(unit_id, session) + return get_training_unit(unit_id, tenant) @router.put("/training-units/{unit_id}") -def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) @@ -1088,13 +1140,13 @@ def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth) conn.commit() - return get_training_unit(unit_id, session) + return get_training_unit(unit_id, tenant) @router.delete("/training-units/{unit_id}") -def delete_training_unit(unit_id: int, session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def delete_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) @@ -1124,10 +1176,10 @@ def delete_training_unit(unit_id: int, session=Depends(require_auth)): @router.post("/training-units/from-framework-slot") -def create_training_unit_from_framework_slot(data: dict, session=Depends(require_auth)): +def create_training_unit_from_framework_slot(data: dict, tenant: TenantContext = Depends(get_tenant_context)): """Geplante Einheit aus Rahmen-Slot-Blueprint kopieren (Lineage über origin_framework_slot_id).""" - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role if not _has_planning_role(role): raise HTTPException(status_code=403, detail="Nur Planungsberechtigte dürfen Trainingseinheiten erstellen") @@ -1188,12 +1240,12 @@ def create_training_unit_from_framework_slot(data: dict, session=Depends(require conn.commit() - return get_training_unit(new_id, session) + return get_training_unit(new_id, tenant) @router.post("/training-units/quick-create") -def quick_create_training_unit(data: dict, session=Depends(require_auth)): - profile_id = session["profile_id"] +def quick_create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id group_id = data.get("group_id") planned_date = data.get("planned_date") @@ -1219,7 +1271,7 @@ def quick_create_training_unit(data: dict, session=Depends(require_auth)): if not group: raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden") - role = session.get("role") + role = tenant.global_role co_trainers = group["co_trainer_ids"] or [] if not _has_planning_role(role): @@ -1261,5 +1313,5 @@ def quick_create_training_unit(data: dict, session=Depends(require_auth)): conn.commit() - return get_training_unit(unit_id, session) + return get_training_unit(unit_id, tenant) diff --git a/backend/run_migrations.py b/backend/run_migrations.py index bd5f5d8..b68d5a5 100644 --- a/backend/run_migrations.py +++ b/backend/run_migrations.py @@ -49,14 +49,14 @@ def get_db_connection(): password=p["password"], ) conn.autocommit = False - print(f"✓ Connected to database: {p['dbname']}") + print(f"[OK] Connected to database: {p['dbname']}") return conn except psycopg2.OperationalError: if i < max_retries - 1: print(f"Waiting for database... ({i+1}/{max_retries})") time.sleep(2) else: - print(f"✗ Failed to connect to database after {max_retries} attempts") + print(f"[FAIL] Failed to connect to database after {max_retries} attempts") raise @@ -72,7 +72,7 @@ def init_migrations_table(conn): """ ) conn.commit() - print("✓ schema_migrations initialisiert") + print("[OK] schema_migrations initialisiert") _LEADING_DIGITS = re.compile(r"^(\d+)") @@ -190,7 +190,7 @@ def run_migration(conn, migration_name: str, filepath: str) -> bool: if shutil.which("psql"): ok, diag = _run_file_with_psql(filepath) if not ok: - print(f" ✗ psql fehlgeschlagen:\n{diag or '(kein Output)'}") + print(f" [FAIL] psql fehlgeschlagen:\n{diag or '(kein Output)'}") conn.rollback() return False detail_suffix = "(psql -1)" @@ -199,7 +199,7 @@ def run_migration(conn, migration_name: str, filepath: str) -> bool: with open(filepath, "r", encoding="utf-8") as fh: body = fh.read() except OSError as e: - print(f" ✗ kann Datei nicht lesen: {e}") + print(f" [FAIL] kann Datei nicht lesen: {e}") conn.rollback() return False @@ -207,7 +207,7 @@ def run_migration(conn, migration_name: str, filepath: str) -> bool: with conn.cursor() as cur: if not statements: print( - f" ⚠ keine ausführbaren Statements (leer?) — " + f" [WARN] keine ausführbaren Statements (leer?) — " f"Eintrag trotzdem: {migration_name}" ) else: @@ -217,12 +217,12 @@ def run_migration(conn, migration_name: str, filepath: str) -> bool: _record_migration(conn, migration_name) conn.commit() - print(f" ✓ {migration_name} erfolgreich {detail_suffix}") + print(f" [OK] {migration_name} erfolgreich {detail_suffix}") return True except Exception as e: conn.rollback() - print(f" ✗ {migration_name}: {e}") + print(f" [FAIL] {migration_name}: {e}") return False @@ -243,7 +243,7 @@ def main(): pending = get_pending(conn, migrations_dir) if not pending: - print("✓ Keine ausstehenden Migrationen — Schema aktuell.") + print("[OK] Keine ausstehenden Migrationen — Schema aktuell.") conn.close() return 0 @@ -262,17 +262,17 @@ def main(): print("\n" + "=" * 60) if failed: - print(f"✗ Abbruch nach: {failed}") + print(f"[FAIL] Abbruch nach: {failed}") print(" (Bereits erfolgreiche Dateien dieser Session sind committed.)") print("=" * 60) return 1 - print(f"✓ {len(pending)} Migration(s) angewendet — Schema aktuell.") + print(f"[OK] {len(pending)} Migration(s) angewendet — Schema aktuell.") print("=" * 60) return 0 except Exception as e: - print(f"\n✗ Fehler: {e}") + print(f"\n[FAIL] Fehler: {e}") return 1 diff --git a/backend/scripts/check_access_layer_hints.py b/backend/scripts/check_access_layer_hints.py new file mode 100644 index 0000000..858b664 --- /dev/null +++ b/backend/scripts/check_access_layer_hints.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +""" +Heuristik-Check: Router mit FastAPI-Routen und Auth sollten bei mandantenrelevanten APIs +get_tenant_context nutzen — siehe ACCESS_LAYER_AND_GOVERNANCE_PLAN.md. + +Lauf aus Repo-Root: + python backend/scripts/check_access_layer_hints.py + +Mit Fehler-Exit bei Verstößen (z. B. CI): + set ACCESS_LAYER_STRICT=1 # Windows: set ACCESS_LAYER_STRICT=1 +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + +# Router, die bewusst keinen TenantContext verwenden (global / Auth-only / Admin-Tools). +# Neuen Eintrag nur mit kurzer Begründung im Commit/Audit ergänzen. +EXEMPT_ROUTERS: frozenset[str] = frozenset( + { + "auth.py", + "admin_users.py", + "catalogs.py", + "skills.py", + "maturity_models.py", + "matrix_stack_bundle.py", + "import_wiki.py", + "import_wiki_admin.py", + } +) + + +def main() -> int: + strict = os.environ.get("ACCESS_LAYER_STRICT", "").strip().lower() in ("1", "true", "yes") + root = Path(__file__).resolve().parents[1] + routers = root / "routers" + if not routers.is_dir(): + print("check_access_layer_hints: routers/ nicht gefunden", file=sys.stderr) + return 1 + + issues: list[str] = [] + for path in sorted(routers.glob("*.py")): + name = path.name + text = path.read_text(encoding="utf-8") + if "@router." not in text: + continue + if name in EXEMPT_ROUTERS: + continue + if "get_tenant_context" in text: + continue + if "require_auth" not in text: + continue + issues.append( + f" {path.relative_to(root.parent)}: Routen + require_auth, " + f"aber kein get_tenant_context — TenantContext ergänzen oder in EXEMPT_ROUTERS eintragen " + f"(mit Begründung / Audit)." + ) + + if not issues: + print("check_access_layer_hints: OK (keine auffälligen Router außerhalb EXEMPT).") + return 0 + + print("check_access_layer_hints: mögliche ACCESS_LAYER-Abweichungen:\n", file=sys.stderr) + for line in issues: + print(line, file=sys.stderr) + print( + "\nHinweis: Heuristik — false positives möglich. " + "Bei echter Ausnahme Datei zu EXEMPT_ROUTERS hinzufügen.", + file=sys.stderr, + ) + return 1 if strict else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/tenant_context.py b/backend/tenant_context.py new file mode 100644 index 0000000..7181a5a --- /dev/null +++ b/backend/tenant_context.py @@ -0,0 +1,206 @@ +""" +Request-weiter Mandanten-Kontext (ACCESS_LAYER_AND_GOVERNANCE_PLAN.md, Stufe B). + +Zielt auf einheitliche Auflösung aus Session + Header X-Active-Club-Id + Profilfeld active_club_id. +Router können Depends(get_tenant_context) nutzen oder resolve_tenant_context mit bereits geladenen Mitgliedschaften (ein DB-Block). +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +from fastapi import Depends, Header, HTTPException + +from auth import require_auth +from club_tenancy import is_platform_admin, memberships_with_roles +from db import get_db, get_cursor + + +def _club_exists(cur, club_id: int) -> bool: + cur.execute("SELECT 1 FROM clubs WHERE id = %s", (club_id,)) + return cur.fetchone() is not None + + +def parse_active_club_header(raw: Optional[str]) -> Optional[int]: + """Parst X-Active-Club-Id; leer → None. Ungültig → HTTP 400.""" + if raw is None: + return None + s = str(raw).strip() + if not s: + return None + try: + v = int(s) + except ValueError: + raise HTTPException(status_code=400, detail="X-Active-Club-Id ungültig") + if v < 1: + raise HTTPException(status_code=400, detail="X-Active-Club-Id ungültig") + return v + + +def library_content_visibility_sql( + *, + alias: str, + profile_id: int, + role: str, + effective_club_id: Optional[int], +) -> tuple[str, List[Any]]: + """ + WHERE-Baustein für Bibliothekslisten (Übungen, Vorlagen, Rahmenprogramme): + official, eigene private, club nur im aktiven Vereinskontext (effective_club_id). + Plattform-Admin: keine Einschränkung (TRUE). + Ohne effective_club_id: kein club-Zweig (nur official + private). + """ + if is_platform_admin(role): + return "TRUE", [] + + parts: List[str] = [ + f"{alias}.visibility = 'official'", + f"({alias}.visibility = 'private' AND {alias}.created_by = %s)", + ] + params: List[Any] = [profile_id] + + if effective_club_id is not None: + parts.append( + f"""( + {alias}.visibility = 'club' + AND {alias}.club_id IS NOT NULL + AND {alias}.club_id = %s + AND EXISTS ( + SELECT 1 FROM club_members cm + WHERE cm.profile_id = %s AND cm.club_id = {alias}.club_id AND cm.status = 'active' + ) + )""" + ) + params.extend([effective_club_id, profile_id]) + + return "(" + " OR ".join(parts) + ")", params + + +@dataclass +class TenantContext: + profile_id: int + global_role: str + # Header > gespeichertes Profil > Fallback; Plattform-Admin ohne Header oft None + effective_club_id: Optional[int] + club_ids: frozenset[int] + memberships: List[Dict[str, Any]] + + +def resolve_tenant_context( + cur, + *, + profile_id: int, + global_role: str, + header_raw: Optional[str], + memberships: Optional[List[Dict[str, Any]]] = None, + stored_active_club_id: Optional[int] = None, + invalid_header_policy: str = "reject", +) -> TenantContext: + """ + Mitgliedschaften: wenn nicht übergeben, wird aus der DB geladen (aktive Mitgliedschaften). + + Auflösung effective_club_id: + - Plattform-Admin: Header setzt beliebigen existierenden Verein; ohne Header → None. + - Sonst: gültiger Header zwingend Mitgliedschaft — bei ``reject`` sonst 403, bei ``ignore`` wie ohne Header. + Ohne gültigen Header: gespeichertes active_club_id wenn Mitglied; sonst einziger Verein; + bei mehreren ohne gültige Vorgabe → min(club_ids) (Fallback). + """ + role_lc = (global_role or "").lower() + header_cid = parse_active_club_header(header_raw) + + if memberships is None: + memberships = memberships_with_roles(cur, profile_id, active_only=True) + + club_ids = frozenset(int(r["id"]) for r in memberships if r.get("id") is not None) + + if is_platform_admin(role_lc): + if header_cid is not None: + if not _club_exists(cur, header_cid): + raise HTTPException(status_code=400, detail="Verein nicht gefunden") + effective = header_cid + else: + effective = None + return TenantContext( + profile_id=profile_id, + global_role=role_lc, + effective_club_id=effective, + club_ids=club_ids, + memberships=memberships, + ) + + chosen_header = header_cid + if chosen_header is not None and chosen_header not in club_ids: + if invalid_header_policy == "reject": + raise HTTPException( + status_code=403, + detail="Keine Mitgliedschaft im gewählten Verein", + ) + chosen_header = None + + if chosen_header is not None: + effective = chosen_header + elif stored_active_club_id is not None and stored_active_club_id in club_ids: + effective = stored_active_club_id + elif len(club_ids) == 1: + effective = next(iter(club_ids)) + elif len(club_ids) == 0: + effective = None + else: + effective = min(club_ids) + + return TenantContext( + profile_id=profile_id, + global_role=role_lc, + effective_club_id=effective, + club_ids=club_ids, + memberships=memberships, + ) + + +def get_tenant_context( + session: dict = Depends(require_auth), + x_active_club_id: Optional[str] = Header(default=None, alias="X-Active-Club-Id"), +) -> TenantContext: + """FastAPI-Dependency: öffnet eine DB-Verbindung und liefert TenantContext.""" + pid = int(session["profile_id"]) + role = session.get("role") or "" + stored: Optional[int] = None + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT active_club_id FROM profiles WHERE id = %s", (pid,)) + row = cur.fetchone() + if row is not None: + ac = row.get("active_club_id") + if ac is not None: + stored = int(ac) + return resolve_tenant_context( + cur, + profile_id=pid, + global_role=role, + header_raw=x_active_club_id, + memberships=None, + stored_active_club_id=stored, + ) + + +def tenant_context_from_session_only( + cur, + session: dict, + header_raw: Optional[str], + *, + memberships: Optional[List[Dict[str, Any]]] = None, + stored_active_club_id: Optional[int] = None, + invalid_header_policy: str = "reject", +) -> TenantContext: + """Variante ohne FastAPI (Tests / gemeinsamer Cursor mit anderen Queries).""" + pid = int(session["profile_id"]) + role = session.get("role") or "" + return resolve_tenant_context( + cur, + profile_id=pid, + global_role=role, + header_raw=header_raw, + memberships=memberships, + stored_active_club_id=stored_active_club_id, + invalid_header_policy=invalid_header_policy, + ) diff --git a/backend/tests/test_access_layer.py b/backend/tests/test_access_layer.py new file mode 100644 index 0000000..11654b3 --- /dev/null +++ b/backend/tests/test_access_layer.py @@ -0,0 +1,76 @@ +"""Unit tests ohne Datenbank für die Zugriffsschicht (Visibility-SQL, Header-Parsing).""" +import pytest +from fastapi import HTTPException + +from tenant_context import library_content_visibility_sql, parse_active_club_header + + +def test_library_visibility_sql_platform_admin_no_filter(): + sql, params = library_content_visibility_sql( + alias="e", + profile_id=1, + role="admin", + effective_club_id=None, + ) + assert sql == "TRUE" + assert params == [] + + +def test_library_visibility_sql_superadmin(): + sql, params = library_content_visibility_sql( + alias="fp", + profile_id=2, + role="superadmin", + effective_club_id=100, + ) + assert sql == "TRUE" + assert params == [] + + +def test_library_visibility_sql_trainer_without_active_club_no_shared_club_branch(): + sql, params = library_content_visibility_sql( + alias="g", + profile_id=42, + role="trainer", + effective_club_id=None, + ) + assert "official" in sql + assert "private" in sql + assert "visibility = 'club'" not in sql + assert params == [42] + + +def test_library_visibility_sql_user_with_active_club_includes_club_branch(): + sql, params = library_content_visibility_sql( + alias="t", + profile_id=7, + role="user", + effective_club_id=99, + ) + assert "visibility = 'club'" in sql + assert "club_members" in sql + assert params[0] == 7 # private branch created_by + assert 99 in params + assert params.count(7) >= 2 # private + EXISTS membership + + +def test_parse_active_club_header_none_and_empty(): + assert parse_active_club_header(None) is None + assert parse_active_club_header("") is None + assert parse_active_club_header(" ") is None + + +def test_parse_active_club_header_valid(): + assert parse_active_club_header("12") == 12 + + +def test_parse_active_club_header_invalid(): + with pytest.raises(HTTPException) as exc: + parse_active_club_header("not-int") + assert exc.value.status_code == 400 + + +def test_parse_active_club_header_non_positive(): + with pytest.raises(HTTPException) as exc: + parse_active_club_header("0") + assert exc.value.status_code == 400 diff --git a/backend/version.py b/backend/version.py index 00b7e14..82613d5 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,20 +1,24 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.11" +APP_VERSION = "0.8.29" BUILD_DATE = "2026-05-05" -DB_SCHEMA_VERSION = "20260505038" +DB_SCHEMA_VERSION = "20260505041" MODULE_VERSIONS = { - "auth": "1.0.0", - "profiles": "1.0.0", - "clubs": "0.1.0", + "auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute) + "profiles": "1.4.1", # PUT /profiles*, Legacy /profile: Depends(get_tenant_context); profile_document für internes Laden + "tenant_context": "1.0.3", # pytest: backend/tests/test_access_layer.py (Visibility-SQL, Header) + "clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext + "club_memberships": "1.0.1", # Depends(get_tenant_context) + "club_join_requests": "1.0.1", # Depends(get_tenant_context) + "admin_users": "1.0.0", # GET /api/admin/users "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", - "exercises": "2.3.0", # Progressionsgraph: Sequenz-API, Varianten-Knoten, UX Ketten (Migration 034) + "exercises": "2.7.0", # PATCH /exercises/bulk-metadata — Massenänderung Sichtbarkeit/Status "training_units": "0.1.0", "training_programs": "0.1.0", - "planning": "0.6.0", + "planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile "import_wiki": "1.0.0", "admin": "1.0.0", "membership": "1.0.0", @@ -23,6 +27,150 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.29", + "date": "2026-05-05", + "changes": [ + "Übungen: PATCH /api/exercises/bulk-metadata (bis 500 IDs) — Sichtbarkeit und/oder Status; Ersteller oder Plattform-Admin; Governance wie Einzel-PUT", + "Übungsliste: Mehrfachauswahl, Alle auf dieser Seite, Dialog Massenänderung (Verein/offiziell nur Admins)", + ], + }, + { + "version": "0.8.28", + "date": "2026-05-05", + "changes": [ + "ACCESS_LAYER: Übungen-, Trainingsplanung-, Rahmenprogramme-Detail nutzen library_content_visible_to_profile (einheitliche Leseprüfung)", + "pytest: backend/requirements-dev.txt, pytest.ini, backend/tests/test_access_layer.py — ohne DB (library_content_visibility_sql, parse_active_club_header)", + ], + }, + { + "version": "0.8.27", + "date": "2026-05-05", + "changes": [ + "ACCESS_LAYER Governance-Disziplin: .cursor/rules/access-layer.mdc; ARCHITECTURE 1.4 + CODING_RULES Tenant-Pfad; CLAUDE Pflichtlektüre Zugriffsschicht", + "backend/scripts/check_access_layer_hints.py — Router ohne get_tenant_context außerhalb EXEMPT melden (optional ACCESS_LAYER_STRICT=1)", + "club_tenancy.library_content_visible_to_profile; Audit-Tabelle um globale Router (EXEMPT) ergänzt", + ], + }, + { + "version": "0.8.26", + "date": "2026-05-05", + "changes": [ + "ACCESS_LAYER: clubs-, club_memberships-, club_join_requests-Router nutzen Depends(get_tenant_context) statt nur require_auth", + "profiles: PUT /profiles/{id} und /profile mit TenantContext; profile_document für /auth/me und intern", + "exercise_progression_graphs: Liste/Detail nach library_content_visibility_sql; Leserechte Vereins-Graphs; POST/PUT mit assert_valid_governance_visibility und club_id wie Übungen", + ], + }, + { + "version": "0.8.25", + "date": "2026-05-05", + "changes": [ + "Übungen PUT: bei visibility club wird club_id aus aktivem Verein oder Body gesetzt (verhindert club ohne club_id für Vereinsnutzer)", + "club_tenancy: governance visibility club für Plattform-Admins ohne Vereinsmitgliedschaft (nur Existenz clubs.id)", + "Login POST /api/auth/login: Rate-Limit 30/minute pro IP (vorher 5/minute)", + ], + }, + { + "version": "0.8.24", + "date": "2026-05-05", + "changes": [ + "Übungen-Router: get/update/delete, Varianten, Medien — Depends(get_tenant_context); Upload-Limits via TenantContext; fehlenden DELETE decorator variants gefixt", + ], + }, + { + "version": "0.8.23", + "date": "2026-05-05", + "changes": [ + "ACCESS_LAYER: library_content_visibility_sql + TenantContext an Übungen-Liste, Rahmenprogramme, Trainingsplanung", + "POST Übung/Vorlage/Rahmenprogramm: visibility club ohne club_id → effective_club_id; POST Übung mit assert_valid_governance_visibility", + ], + }, + { + "version": "0.8.22", + "date": "2026-05-05", + "changes": [ + "ACCESS_LAYER Stufe B: Modul tenant_context (resolve_tenant_context, Depends(get_tenant_context)); GET /profiles/me liefert effective_club_id; veralteter X-Active-Club-Id wird dort verworfen (ignore), strikt auf anderen Endpoints via Depends", + "Arbeitspapier ACCESS_LAYER_ENDPOINT_AUDIT.md für Router-Inventar", + ], + }, + { + "version": "0.8.21", + "date": "2026-05-05", + "changes": [ + "FastAPI: Router club_memberships und club_join_requests registriert (GET /api/clubs/{id}/members, join-requests u. a.) — behoben 404 auf Vereinsseite Tab Mitglieder", + ], + }, + { + "version": "0.8.20", + "date": "2026-05-05", + "changes": [ + "Migration 041: wenn noch kein superadmin existiert, werden ältestes Profil mit role admin zu superadmin hochgestuft", + "Registrierung: erster Nutzer und ADMIN_BOOTSTRAP_EMAILS erhalten superadmin (vorher admin)", + ], + }, + { + "version": "0.8.19", + "date": "2026-05-05", + "changes": [ + "Portal-Admin: GET /api/admin/users (alle Nutzer + Vereine); PUT /profiles/{id} mit role/tier (Super-Admin nur durch Super-Admin); Mitgliedschaft inaktiv in Übersicht", + "GUI Admin → Nutzer: Portal-Rolle/Tier, Verein zuweisen, Vereinsrollen bearbeiten", + ], + }, + { + "version": "0.8.18", + "date": "2026-05-05", + "changes": [ + "DB 040 club_membership_requests; API Antrag/Abrufen/annehmen/ablehnen; öffentliches Vereinsverzeichnis; Mitglieder-Directory für Trainerwahl", + "GUI: Vereinsverwaltung Tab Mitglieder & Anträge; Registrierung/Einstellungen Vereinsantrag; Gruppenformular Haupt- und Co-Trainer", + "GET /profiles nur noch für Plattform-Admins", + ], + }, + { + "version": "0.8.17", + "date": "2026-05-05", + "changes": [ + "Multi-Tenancy Phase 4: training_plan_templates + training_framework_programs Listen und Lesen nach visibility/club wie Übungen; Schreiben nur Ersteller oder Plattform-Admin; club_tenancy.assert_valid_governance_visibility", + ], + }, + { + "version": "0.8.16", + "date": "2026-05-05", + "changes": [ + "API Vereinsmitglieder: GET/POST/GET-one/PUT/DELETE /api/clubs/{id}/members (Plattform- oder Vereinsadmin); Frontend api.js Hooks", + ], + }, + { + "version": "0.8.15", + "date": "2026-05-05", + "changes": [ + "DB 039 Fix: Co-Trainer-Backfill über Subquery + CASE (kein jsonb_array_length/jsonb_array_elements auf Nicht-Arrays durch Planner/LATERAL-Reihenfolge)", + ], + }, + { + "version": "0.8.14", + "date": "2026-05-05", + "changes": [ + "DB 039 Fix: Co-Trainer-Backfill nur wenn co_trainer_ids ein JSON-Array ist (vermeidet jsonb_array_length auf Nicht-Array)", + ], + }, + { + "version": "0.8.13", + "date": "2026-05-05", + "changes": [ + "Fix: Startup unter Windows (cp1252) — Emoji/Sonderzeichen in print durch ASCII ([OK]/[FAIL]/[WARN]) ersetzt (main, run_migrations, db_init)", + ], + }, + { + "version": "0.8.12", + "date": "2026-05-05", + "changes": [ + "DB 039: club_members, club_member_roles, clubs.primary_admin_profile_id, profiles.active_club_id + Backfill", + "Multi-Tenancy: Vereinslisten gefiltert, Vereinsanlage nur Plattform-Admin mit primary_admin_profile_id", + "Übungen: visibility club nur für Mitglieder des zugeordneten Vereins", + "GET /api/profiles/me: clubs[], ohne pin_hash; active_club_id setzen via PUT", + "Frontend: X-Active-Club-Id, Vereins-Umschalter, Vereinsseiten angepasst", + ], + }, { "version": "0.8.11", "date": "2026-05-05", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 069dd33..b545339 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -30,6 +30,8 @@ import AdminHierarchyPage from './pages/AdminHierarchyPage' import AdminMaturityModelsPage from './pages/AdminMaturityModelsPage' import TrainerContextsPage from './pages/TrainerContextsPage' import MediaWikiImportPage from './pages/MediaWikiImportPage' +import AdminUsersPage from './pages/AdminUsersPage' +import ActiveClubSwitcher from './components/ActiveClubSwitcher' import './app.css' // Bottom Navigation (Mobile) @@ -98,8 +100,11 @@ function ProtectedLayout() {
-
-
🥋 Shinkan
+
+
+
🥋 Shinkan
+
+
@@ -166,6 +171,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/app.css b/frontend/src/app.css index 9868097..4ebd9a0 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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; diff --git a/frontend/src/components/ActiveClubSwitcher.jsx b/frontend/src/components/ActiveClubSwitcher.jsx new file mode 100644 index 0000000..0243b79 --- /dev/null +++ b/frontend/src/components/ActiveClubSwitcher.jsx @@ -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 ( + + ) +} diff --git a/frontend/src/components/AdminPageNav.jsx b/frontend/src/components/AdminPageNav.jsx index 9677611..4ad7a50 100644 --- a/frontend/src/components/AdminPageNav.jsx +++ b/frontend/src/components/AdminPageNav.jsx @@ -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 } diff --git a/frontend/src/components/DesktopSidebar.jsx b/frontend/src/components/DesktopSidebar.jsx index b95be31..a918e17 100644 --- a/frontend/src/components/DesktopSidebar.jsx +++ b/frontend/src/components/DesktopSidebar.jsx @@ -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({
-
-
+
+
+
+ {(user?.name || user?.email || '?').trim().slice(0, 1).toUpperCase()} +
+
+ + {user?.name || user?.email || 'User'} + + {tier ? ( + {tier} + ) : null} +
+
+
-
- - {user?.name || user?.email || 'User'} - - {tier ? ( - {tier} - ) : null} -
+ +
-
) diff --git a/frontend/src/components/Navigation.jsx b/frontend/src/components/Navigation.jsx index 0871d43..f56485f 100644 --- a/frontend/src/components/Navigation.jsx +++ b/frontend/src/components/Navigation.jsx @@ -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 + {(clubs?.length ?? 0) > 1 && ( + + )} + {/* User Menu */}
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 ( diff --git a/frontend/src/pages/AccountSettingsPage.jsx b/frontend/src/pages/AccountSettingsPage.jsx index cba52d3..ea6634c 100644 --- a/frontend/src/pages/AccountSettingsPage.jsx +++ b/frontend/src/pages/AccountSettingsPage.jsx @@ -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() { {user?.tier || 'free'} + + Vereine + + {user?.clubs?.length ? ( + <> + {user.clubs.map((c) => ( +
+ {c.name} + {': '} + {(c.roles || []).length ? (c.roles || []).join(', ') : '—'} +
+ ))} + + ) : ( + '—' + )} +
+
+

Vereinsbeitritt

+

+ Beantrage die Mitgliedschaft in einem Verein. Vereinsadministratoren können den Antrag unter + „Vereinsverwaltung → Mitglieder“ annehmen oder ablehnen. +

+ + {myJoinRequests.length > 0 && ( +
+ Meine Anträge +
    + {myJoinRequests.map((r) => ( +
  • + {r.club_name || `Verein #${r.club_id}`} — {joinStatusLabel(r.status)} + {r.status === 'pending' ? ( + <> + {' '} + + + ) : null} +
  • + ))} +
+
+ )} + +
{ + 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) + } + }} + > + + + +