Compare commits
31 Commits
50c9beb4b3
...
4724da28b1
| Author | SHA1 | Date | |
|---|---|---|---|
| 4724da28b1 | |||
| d4b1780193 | |||
| f2650dac57 | |||
| fad1058d54 | |||
| 9dd44ce3ca | |||
| 87f258be38 | |||
| 779e2477ba | |||
| f074a8bef0 | |||
| 0677663268 | |||
| d4e9bded23 | |||
| 7411543a97 | |||
| dd0fae4bf5 | |||
| a9a6153ed5 | |||
| 4130a63dfe | |||
| 9d52aeab67 | |||
| b68185842e | |||
| 40641594ac | |||
| e4cb491d46 | |||
| 8404a42b6c | |||
| fa10450315 | |||
| 37785135b1 | |||
| 8ee8f52e0f | |||
| 8718cf5c70 | |||
| 91dae7b614 | |||
| 20927a5969 | |||
| 7db77f4738 | |||
| 3e87f7515a | |||
| a2f60d3f46 | |||
| 30dc30c7aa | |||
| 7cfbca40bb | |||
| c294c27de8 |
|
|
@ -79,16 +79,18 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
|
|||
|
||||
### 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.
|
||||
- **Verbindliche Spez v1:** `CAPABILITY_CATALOG.v1.md` — Capability-IDs, Account-Lifecycle, Rollen-Matrix, Endpoint-Mapping.
|
||||
- Markdown-Tabelle **Capability-Fingerprint**: Kennungen wie `exercises.ai.suggest`, `org.members.manage`, … mit Zuordnung zu den **heutigen** festen Vereinsrollen (siehe Katalog §5–6).
|
||||
- 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
|
||||
### Zurückgestellt – Vereinsabo / Limits (Konzept liegt vor)
|
||||
|
||||
- Wiederöffnen wenn ACCESS_LAYER Stufe C/D stabil; dann Enforcement vor ausgewählten Writes an einen Billing-Stripe binden.
|
||||
- **Spez v1:** `CLUB_MEMBERSHIP_AND_FEATURES.v1.md` — Feature-Registry (Mitai-v9c-Pattern), `club_plans`/`club_subscriptions`, Kontingente an `club_id`.
|
||||
- Implementierung/Billing (Stripe) weiter zurückgestellt; Schema- und Enforcement-Hooks gemäß 4-Phasen-Rollout (Mitai-Vorbild) vorbereiten, sobald Stufe C/D stabil.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -117,6 +119,8 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
|
|||
|
||||
## 7. Referenzen
|
||||
|
||||
- **`CAPABILITY_CATALOG.v1.md`** – Rollen, Capabilities, CRUD-Mapping, `GET /api/me/entitlements`.
|
||||
- **`CLUB_MEMBERSHIP_AND_FEATURES.v1.md`** – Vereinsabo, Feature-Limits, Mitai-Mapping, Ziel-Schema.
|
||||
- `.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md` – übergeordnetes Zielbild & Begriffe.
|
||||
- `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` – verbindliche Domänenregeln für **Medien-Assets** (gleiche Sichtbarkeit wie Übungen, Promotion-Kopplung, Copyright, Papierkorb/Lebenszyklus, externer Speicher). Bei Widerspruch zur Sichtbarkeits-Tabelle in §3 dieses Dokuments: §3 für Enums/`library_content_*`-Semantik, Medien-Spez für Asset-spezifische Zusatzregeln.
|
||||
- `backend/club_tenancy.py` – bestehende Bausteine (`assert_club_member`, `exercise_visible_to_profile`, `can_plan_in_club`, …); Ziel ist Deren schrittweise Zusammenführung unter die neue Zugriffsschicht ohne Big-Bang.
|
||||
|
|
|
|||
331
.claude/docs/technical/CAPABILITY_CATALOG.v1.md
Normal file
331
.claude/docs/technical/CAPABILITY_CATALOG.v1.md
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
# Capability-Katalog Shinkan v1
|
||||
|
||||
**Status:** Konzept (verbindliche Zieldefinition; M3 teilweise umgesetzt)
|
||||
**Stand:** 2026-06-06
|
||||
**Bezüge:** `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` (Stufe E), `MULTI_TENANCY_RBAC_ARCHITECTURE.md`, `CLUB_MEMBERSHIP_AND_FEATURES.v1.md`, **`MEMBERSHIP_RBAC_DECISIONS_2026-06.md`** (Produktentscheidungen)
|
||||
|
||||
---
|
||||
|
||||
## 1. Zweck
|
||||
|
||||
Dieses Dokument definiert **benannte Capabilities** (Wer darf welche **Funktion** ausführen?) — getrennt von:
|
||||
|
||||
- **Governance** (Darf ich *dieses Objekt* lesen/ändern? → `visibility`, `club_id`, `created_by`)
|
||||
- **Feature-Limits** (Wie viel darf der **Verein**? → `CLUB_MEMBERSHIP_AND_FEATURES.v1.md`)
|
||||
|
||||
Capabilities beantworten: *„Darf ein Trainer mit Rolle X die Funktion Y im Verein Z überhaupt nutzen?“*
|
||||
|
||||
---
|
||||
|
||||
## 2. Namenskonvention
|
||||
|
||||
```
|
||||
{domain}.{action}[.{qualifier}]
|
||||
```
|
||||
|
||||
| Segment | Beispiele |
|
||||
|---------|-----------|
|
||||
| `domain` | `exercises`, `media`, `planning`, `org`, `platform` |
|
||||
| `action` | `read`, `create`, `update`, `delete`, `manage`, `execute` |
|
||||
| `qualifier` | `ai.suggest`, `join_request`, `inbox.review` |
|
||||
|
||||
**CRUD-Mapping:**
|
||||
|
||||
| Aktion | Capability-Suffix | Bedeutung |
|
||||
|--------|-------------------|-----------|
|
||||
| Lesen (Listen/Detail) | `.read` | Navigation + API-Lesen erlaubt |
|
||||
| Anlegen | `.create` | POST/INSERT |
|
||||
| Bearbeiten | `.update` | PUT/PATCH (eigenes + berechtigtes Fremdes) |
|
||||
| Löschen | `.delete` | DELETE (strenger als update) |
|
||||
| Verwalten | `.manage` | Org-Funktionen, Freigaben, Mitglieder |
|
||||
| Ausführen (ohne Persistenz) | `.execute` | z. B. KI-Vorschau, Coach-Lauf |
|
||||
|
||||
Objektbezogene Feinheiten (nur Ersteller, nur Vereinsadmin des Objekt-Vereins) bleiben in **Governance** — Capabilities sind das **Tür-Schloss** davor.
|
||||
|
||||
---
|
||||
|
||||
## 3. Account-Lifecycle (Voraussetzung für Capabilities)
|
||||
|
||||
| `account_state` | Bedingung | Typische Capabilities |
|
||||
|-----------------|-----------|------------------------|
|
||||
| `anonymous` | Keine Session | nur öffentliche Routen (`/login`, Rechtstexte, `clubs/public-directory`) |
|
||||
| `unverified` | Session, `email_verified=false` | `account.resend_verification`, `account.logout` |
|
||||
| `verified_pending_club` | Verifiziert, keine aktive `club_members` | `club.join_request`, `club.creation_request` (M7), `account.settings` — **kein** Lesezugriff auf Domänen-Inhalte (siehe Entscheidungs-Doc §1.1) |
|
||||
| `active_member` | Mind. eine aktive Vereinsmitgliedschaft | Domänen-Capabilities gemäß Vereinsrolle |
|
||||
| `platform_admin` | `role` ∈ `admin`, `superadmin` | `platform.*` zusätzlich |
|
||||
|
||||
**Regel:** Domänen-Capabilities (`exercises.*`, `planning.*`, …) erfordern mindestens `active_member`, sofern nicht `platform_admin`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Rollen-Scopes
|
||||
|
||||
### 4.1 Portal-Rollen (`profiles.role`)
|
||||
|
||||
| Rolle | Scope | Kurz |
|
||||
|-------|-------|------|
|
||||
| `user` | Portal | Standard nach Registrierung (Zielbild; heute oft `trainer` Legacy) |
|
||||
| `trainer` | Portal | Legacy — mittelfristig durch `user` + Vereinsrollen ersetzen |
|
||||
| `admin` | Portal | Plattform-Admin (Vereine anlegen, erweiterte Ops) |
|
||||
| `superadmin` | Portal | Vollzugriff Plattform + Superadmin-Werkzeuge |
|
||||
|
||||
### 4.2 Vereinsrollen (`club_member_roles.role_code`)
|
||||
|
||||
| Rolle | Fachlich |
|
||||
|-------|----------|
|
||||
| `club_admin` | Vereinsorganisation, Mitglieder, Struktur |
|
||||
| `trainer` | Planung, Übungen, Durchführung |
|
||||
| `content_editor` | Inhalte pflegen (Bibliothek) |
|
||||
| `division_lead` | Spartenleitung (später division-scope) |
|
||||
|
||||
Mehrfachrollen pro Mitgliedschaft sind möglich (OR-Verknüpfung der Capabilities).
|
||||
|
||||
### 4.3 Mapping heutiger Helfer → Capabilities
|
||||
|
||||
| Heutiger Code (`club_tenancy.py`) | Ziel-Capability-Cluster |
|
||||
|-----------------------------------|-------------------------|
|
||||
| `can_manage_club_org` | `org.structure.manage`, `org.members.manage`, `org.inbox.review` |
|
||||
| `can_plan_in_club` | `planning.*`, `exercises.create/update`, `modules.*`, `framework.*` |
|
||||
| `is_platform_admin` | `platform.*` (Bypass Mandant, Audit-Pflicht) |
|
||||
| `is_superadmin` | `platform.superadmin.*` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Capability-Katalog (v1)
|
||||
|
||||
Legende Spalten:
|
||||
|
||||
- **Min. Account:** `verified_pending_club` | `active_member` | `platform_admin`
|
||||
- **Vereinsrollen:** leer = alle aktiven Mitglieder; sonst mindestens eine Rolle
|
||||
- **Feature-ID:** optionales Kontingent (siehe Club-Membership-Doc); leer = kein Limit
|
||||
- **Governance:** zusätzliche Objektprüfung ja/nein
|
||||
|
||||
### 5.1 Account & Onboarding
|
||||
|
||||
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Endpoints / UI |
|
||||
|---------------|--------------|---------------|------------|----------------|
|
||||
| `account.settings.read` | `unverified` | — | — | `GET /profiles/me`, Einstellungen |
|
||||
| `account.settings.update` | `unverified` | — | — | `PUT /profiles/{id}` (eigenes Profil) |
|
||||
| `account.password.change` | `unverified` | — | — | `PUT /api/auth/pin` |
|
||||
| `account.resend_verification` | `unverified` | — | — | `POST /api/auth/resend-verification` |
|
||||
| `club.directory.read` | `verified_pending_club` | — | — | `GET /clubs/public-directory` |
|
||||
| `club.join_request.create` | `verified_pending_club` | — | — | `POST /me/club-join-requests`, Registrierung mit `requested_club_id` |
|
||||
| `club.join_request.withdraw` | `verified_pending_club` | — | — | `DELETE /me/club-join-requests/{id}` |
|
||||
| `club.join_request.read_own` | `verified_pending_club` | — | — | `GET /me/club-join-requests` |
|
||||
|
||||
### 5.2 Organisation (Verein)
|
||||
|
||||
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Endpoints / UI |
|
||||
|---------------|--------------|---------------|------------|----------------|
|
||||
| `org.club.read` | `active_member` | * | — | `GET /clubs`, `GET /clubs/{id}` (eigene Vereine) |
|
||||
| `org.club.create` | `platform_admin` | — | — | `POST /clubs` |
|
||||
| `org.club.update` | `platform_admin` | `club_admin` | — | `PUT /clubs/{id}` |
|
||||
| `org.club.delete` | `platform_admin` | — | — | `DELETE /clubs/{id}` |
|
||||
| `org.structure.manage` | `active_member` | `club_admin` | `training_groups` | Sparten, Gruppen CRUD |
|
||||
| `org.members.read` | `active_member` | `club_admin` | — | `GET /clubs/{id}/members` |
|
||||
| `org.members.manage` | `active_member` | `club_admin` | `active_members` | POST/PUT/DELETE Mitglieder |
|
||||
| `org.members.directory` | `active_member` | * | — | `GET /clubs/{id}/members/directory` (ohne E-Mail für Nicht-Admins) |
|
||||
| `org.join_request.review` | `active_member` | `club_admin` | — | Join-Request accept/reject, Inbox |
|
||||
| `org.inbox.read` | `active_member` | `club_admin` | — | Posteingang Join + Content-Reports |
|
||||
|
||||
### 5.3 Übungen
|
||||
|
||||
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance |
|
||||
|---------------|--------------|---------------|------------|------------|
|
||||
| `exercises.read` | `active_member` | * | — | ja (visibility) |
|
||||
| `exercises.create` | `active_member` | `trainer`, `content_editor`, `club_admin`, `division_lead` | `exercises` | — |
|
||||
| `exercises.update` | `active_member` | `trainer`, `content_editor`, `club_admin`, `division_lead` | — | ja |
|
||||
| `exercises.delete` | `active_member` | `club_admin` (+ Ersteller privat) | — | ja |
|
||||
| `exercises.bulk_metadata` | `active_member` | `content_editor`, `club_admin` | — | ja |
|
||||
| `exercises.ai.suggest` | `active_member` | `trainer`, `content_editor`, `club_admin` | `ai_calls` | — |
|
||||
| `exercises.ai.regenerate` | `active_member` | `trainer`, `content_editor`, `club_admin` | `ai_calls` | ja (edit) |
|
||||
| `exercises.media.read` | `active_member` | * | — | ja |
|
||||
| `exercises.media.upload` | `active_member` | `trainer`, `content_editor`, `club_admin` | `exercise_media` | ja |
|
||||
| `exercises.variants.manage` | `active_member` | `trainer`, `content_editor`, `club_admin` | — | ja |
|
||||
|
||||
**Representative Endpoints:** `/api/exercises*`, `/api/exercises/ai/*`, Medien-Datei-Download.
|
||||
|
||||
### 5.4 Medien-Bibliothek (Archiv)
|
||||
|
||||
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance |
|
||||
|---------------|--------------|---------------|------------|------------|
|
||||
| `media.library.read` | `active_member` | * | — | ja |
|
||||
| `media.library.upload` | `active_member` | `trainer`, `content_editor`, `club_admin` | `exercise_media` | ja |
|
||||
| `media.library.update` | `active_member` | `trainer`, `content_editor`, `club_admin` | — | ja |
|
||||
| `media.library.lifecycle` | `active_member` | `trainer`, `club_admin` | — | ja |
|
||||
| `media.rights.declare` | `active_member` | `trainer`, `club_admin` | — | ja |
|
||||
| `media.admin.rights_review` | `platform_admin` | — | — | Plattform-Admin Legacy-Review |
|
||||
|
||||
### 5.5 Trainingsmodule & Rahmenprogramme
|
||||
|
||||
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance |
|
||||
|---------------|--------------|---------------|------------|------------|
|
||||
| `modules.read` | `active_member` | * | — | ja |
|
||||
| `modules.create` | `active_member` | `trainer`, `content_editor`, `club_admin` | `training_programs` | — |
|
||||
| `modules.update` | `active_member` | `trainer`, `content_editor`, `club_admin` | — | ja |
|
||||
| `modules.delete` | `active_member` | `club_admin` (+ Ersteller) | — | ja |
|
||||
| `framework.read` | `active_member` | * | — | ja |
|
||||
| `framework.create` | `active_member` | `trainer`, `club_admin` | `training_programs` | — |
|
||||
| `framework.update` | `active_member` | `trainer`, `club_admin` | — | ja |
|
||||
| `framework.delete` | `active_member` | `club_admin` (+ Ersteller) | — | ja |
|
||||
| `plan_templates.read` | `active_member` | * | — | ja |
|
||||
| `plan_templates.manage` | `active_member` | `trainer`, `club_admin` | — | ja |
|
||||
|
||||
### 5.6 Progressionspfade
|
||||
|
||||
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance |
|
||||
|---------------|--------------|---------------|------------|------------|
|
||||
| `progression.read` | `active_member` | * | — | ja |
|
||||
| `progression.manage` | `active_member` | `trainer`, `content_editor`, `club_admin` | — | ja |
|
||||
|
||||
### 5.7 Planung & Durchführung
|
||||
|
||||
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance |
|
||||
|---------------|--------------|---------------|------------|------------|
|
||||
| `planning.calendar.read` | `active_member` | * | — | ja (Gruppe/Verein) |
|
||||
| `planning.units.create` | `active_member` | `trainer`, `club_admin`, `division_lead` | `training_units` | ja |
|
||||
| `planning.units.update` | `active_member` | `trainer`, `club_admin`, `division_lead` | — | ja |
|
||||
| `planning.units.delete` | `active_member` | `club_admin`, `trainer` (eigene) | — | ja |
|
||||
| `planning.units.run` | `active_member` | `trainer`, `club_admin`, `division_lead` | — | ja |
|
||||
| `planning.coach.execute` | `active_member` | `trainer`, `club_admin` | — | ja |
|
||||
| `planning.ai.suggest` | `active_member` | `trainer`, `club_admin` | `ai_calls` | — |
|
||||
| `planning.ai.progression_path` | `active_member` | `trainer`, `club_admin` | `ai_calls` | — |
|
||||
|
||||
### 5.8 Fähigkeiten & Scoring
|
||||
|
||||
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance |
|
||||
|---------------|--------------|---------------|------------|------------|
|
||||
| `skills.catalog.read` | `active_member` | * | — | globaler Katalog |
|
||||
| `skills.discovery.read` | `active_member` | `trainer`, `content_editor` | — | — |
|
||||
| `skill_profiles.read` | `active_member` | * | — | ja (Artefakt) |
|
||||
|
||||
### 5.9 Governance & Meldungen
|
||||
|
||||
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID |
|
||||
|---------------|--------------|---------------|------------|
|
||||
| `governance.content_report.create` | `active_member` | * | — |
|
||||
| `governance.content_report.review` | `active_member` | `club_admin` | — |
|
||||
| `governance.change_request.*` | `active_member` | `content_editor`, `club_admin` | — |
|
||||
|
||||
### 5.10 Plattform (nur Portal-Admin / Superadmin)
|
||||
|
||||
| Capability-ID | Min. Account | Portal-Rolle | Feature-ID |
|
||||
|---------------|--------------|--------------|------------|
|
||||
| `platform.admin.access` | `platform_admin` | `admin`, `superadmin` | — |
|
||||
| `platform.users.manage` | `platform_admin` | `superadmin` | — |
|
||||
| `platform.catalogs.manage` | `platform_admin` | `superadmin` | — |
|
||||
| `platform.maturity_models.manage` | `platform_admin` | `superadmin` | — |
|
||||
| `platform.wiki_import.execute` | `platform_admin` | `superadmin` | `wiki_import` |
|
||||
| `platform.ai_prompts.manage` | `platform_admin` | `superadmin` | — |
|
||||
| `platform.exercise_enrichment.execute` | `platform_admin` | `superadmin` | `ai_calls` |
|
||||
| `platform.user_content.moderate` | `platform_admin` | `superadmin` | — |
|
||||
| `platform.legal_documents.manage` | `platform_admin` | `superadmin` | — |
|
||||
| `platform.media_storage.manage` | `platform_admin` | `superadmin` | — |
|
||||
| `platform.club_creation.approve` | `platform_admin` | `superadmin` | — |
|
||||
|
||||
*Geplant:* `club.creation_request.submit` → `verified_pending_club`; Freigabe über `platform.club_creation.approve`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Standard-Zuordnung Vereinsrolle → Capabilities (v1, fest)
|
||||
|
||||
Diese Tabelle ist die **initiale** Grant-Matrix (`club_role_capability_grants`). Später durch Custom Roles ersetzbar — gleiche Capability-IDs.
|
||||
|
||||
| Capability-Cluster | `club_admin` | `trainer` | `content_editor` | `division_lead` |
|
||||
|--------------------|:------------:|:---------:|:----------------:|:---------------:|
|
||||
| `org.structure.manage` | ✓ | — | — | ✓ (eigene Sparte, später) |
|
||||
| `org.members.manage` | ✓ | — | — | — |
|
||||
| `org.join_request.review` | ✓ | — | — | — |
|
||||
| `exercises.read` | ✓ | ✓ | ✓ | ✓ |
|
||||
| `exercises.create/update` | ✓ | ✓ | ✓ | ✓ |
|
||||
| `exercises.delete` | ✓ | — | — | — |
|
||||
| `exercises.ai.*` | ✓ | ✓ | ✓ | ✓ |
|
||||
| `media.library.*` | ✓ | ✓ | ✓ | ✓ |
|
||||
| `modules.*` / `framework.*` | ✓ | ✓ | ✓ | ✓ |
|
||||
| `planning.*` | ✓ | ✓ | — | ✓ |
|
||||
| `planning.coach.execute` | ✓ | ✓ | — | ✓ |
|
||||
| `governance.content_report.review` | ✓ | — | — | — |
|
||||
|
||||
---
|
||||
|
||||
## 7. API-Vertrag (Ziel)
|
||||
|
||||
### 7.1 Effektive Rechte für Frontend
|
||||
|
||||
```
|
||||
GET /api/me/entitlements?club_id={optional}
|
||||
```
|
||||
|
||||
Antwort (Ausschnitt):
|
||||
|
||||
```json
|
||||
{
|
||||
"account_state": "active_member",
|
||||
"portal_role": "user",
|
||||
"club_id": 12,
|
||||
"club_roles": ["trainer"],
|
||||
"capabilities": {
|
||||
"exercises.read": true,
|
||||
"exercises.ai.suggest": true,
|
||||
"org.members.manage": false
|
||||
},
|
||||
"features": {
|
||||
"ai_calls": { "allowed": true, "used": 4, "limit": 50, "remaining": 46, "reset_at": "2026-07-01T00:00:00Z" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Frontend: Navigation und Buttons nur aus dieser Antwort — **keine** duplizierten Rollen-Checks in JSX (Ausnahme: rein kosmetische Labels).
|
||||
|
||||
### 7.2 Backend-Enforcement
|
||||
|
||||
Zentral (Zielmodul `authorization/capabilities.py` oder Erweiterung `club_tenancy.py`):
|
||||
|
||||
```python
|
||||
assert_capability(tenant, "exercises.ai.suggest", club_id=tenant.effective_club_id)
|
||||
assert_club_feature(tenant, "ai_calls", club_id=tenant.effective_club_id) # siehe Club-Membership-Doc
|
||||
# + bestehende Governance auf Objekt-Ebene
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Implementierungsreihenfolge (Capabilities)
|
||||
|
||||
| Phase | Inhalt |
|
||||
|-------|--------|
|
||||
| C0 | Account-Gates (`unverified`, `verified_pending_club`) — ohne Capability-DB |
|
||||
| C1 | `capabilities` + `club_role_capability_grants` seed aus §5–6 |
|
||||
| C2 | `GET /api/me/entitlements` + Frontend-Nav |
|
||||
| C3 | Enforcement: KI-Endpoints, `exercises.create`, `planning.*` |
|
||||
| C4 | Restliche Router schrittweise; Audit in `ACCESS_LAYER_ENDPOINT_AUDIT.md` |
|
||||
| C5 | Custom Roles (optional) — gleiche IDs |
|
||||
|
||||
---
|
||||
|
||||
## 9. Abgrenzung & Drift-Schutz
|
||||
|
||||
1. **Neue Nutzerfunktion** → `register_capability()` in `rights_registrations/<modul>.py`, dann Endpoint mit `probe_capability`. Namenskonvention hier dokumentieren — **kein** Bulk-Seed in Migrationen.
|
||||
2. **Kontingent** → `register_feature()` im selben Modul; Consume über `consume_club_feature_with_usage`.
|
||||
3. **Kein** paralleles `if (user.role === 'trainer')` für Sicherheit — nur UX-Fallback.
|
||||
4. Capability ≠ Feature: `exercises.ai.suggest` (darf ich?) vs. `ai_calls` (wie viel übrig?).
|
||||
5. Plattform-Admin-Bypass dokumentieren und auditieren (`platform_admin` sieht Mandant, nicht automatisch alle Quotas).
|
||||
|
||||
Siehe **`docs/working/RIGHTS_AND_FEATURES_REGISTRY.md`** (Registry-first, ersetzt Katalog-first aus 079).
|
||||
|
||||
---
|
||||
|
||||
## 10. Referenzen
|
||||
|
||||
| Dokument | Inhalt |
|
||||
|----------|--------|
|
||||
| `CLUB_MEMBERSHIP_AND_FEATURES.v1.md` | Vereinsabo, Feature-Registry, Kontingente |
|
||||
| `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` | TenantContext, Governance, Stufe E |
|
||||
| `MULTI_TENANCY_RBAC_ARCHITECTURE.md` | §4.6 Vereinsabo-Zielbild |
|
||||
| `ACCESS_LAYER_ENDPOINT_AUDIT.md` | Endpoint-Pflege |
|
||||
| Mitai `FEATURE_ENFORCEMENT.md` | 4-Phasen-Rollout-Vorbild |
|
||||
|
||||
---
|
||||
|
||||
**Changelog**
|
||||
|
||||
- 2026-06-06: v1 — Initial-Katalog aus Ist-Code (`club_tenancy`, Router-Inventar) + Ziel-Onboarding.
|
||||
478
.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md
Normal file
478
.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
# Vereins-Membership & Feature-System Shinkan v1
|
||||
|
||||
**Status:** Konzept + M1–M3 teilweise produktiv (siehe Entscheidungs-Doc §2)
|
||||
**Stand:** 2026-06-06
|
||||
**Bezüge:** Schwesterprojekt Mitai (`v9c_subscription_system.sql`, `FEATURE_ENFORCEMENT.md`), `CAPABILITY_CATALOG.v1.md`, `MULTI_TENANCY_RBAC_ARCHITECTURE.md` §4.6, `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`, **`MEMBERSHIP_RBAC_DECISIONS_2026-06.md`**
|
||||
|
||||
---
|
||||
|
||||
## 1. Zweck
|
||||
|
||||
Shinkan verkauft und limitiert **nicht Einzelpersonen** (wie Mitai), sondern **Vereine**. Dieses Dokument definiert:
|
||||
|
||||
- das **Feature-Registry**-Muster (limitierbare Funktionen),
|
||||
- das **Vereins-Abo** (`club_plans`, `club_subscriptions`),
|
||||
- **Kontingente** und Enforcement,
|
||||
- die **Abbildung von Mitai** und **Vermeidung von Refactoring-Schulden**.
|
||||
|
||||
Capabilities (Rollen: *darf ich die Funktion?*) → `CAPABILITY_CATALOG.v1.md`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Grundprinzip: Zwei Achsen
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph cap [Achse 1 — Capabilities]
|
||||
CR[club_role_capability_grants]
|
||||
PR[portal_role_capability_grants]
|
||||
end
|
||||
|
||||
subgraph feat [Achse 2 — Features / Kontingente]
|
||||
FP[club_plans]
|
||||
FPL[club_plan_limits]
|
||||
FS[club_subscriptions]
|
||||
FU[club_feature_usage]
|
||||
end
|
||||
|
||||
subgraph gov [Achse 3 — Governance]
|
||||
GV[visibility / club_id / created_by]
|
||||
end
|
||||
|
||||
REQ[HTTP Request] --> ACCT[Account-Lifecycle]
|
||||
ACCT --> cap
|
||||
cap --> gov
|
||||
gov --> feat
|
||||
feat --> EXEC[Ausführung + increment]
|
||||
```
|
||||
|
||||
| Frage | System | Subjekt |
|
||||
|-------|--------|---------|
|
||||
| Darf Trainer X KI nutzen? | Capability `exercises.ai.suggest` | `profile_id` + `club_role` |
|
||||
| Wie viele KI-Aufrufe hat Verein Y? | Feature `ai_calls` | **`club_id`** |
|
||||
| Darf ich diese Übung ändern? | Governance | Objekt + Mitgliedschaft |
|
||||
|
||||
**Beide Achsen müssen erfüllt sein** (AND), außer dokumentierte Plattform-Ausnahmen.
|
||||
|
||||
---
|
||||
|
||||
## 3. Mitai-Mapping (was übernehmen, was nicht)
|
||||
|
||||
### 3.1 Übernehmen (Pattern)
|
||||
|
||||
| Mitai (Person) | Shinkan (Verein) | Anmerkung |
|
||||
|----------------|------------------|-----------|
|
||||
| `features` (TEXT-PK, Registry) | `features` (`app='shinkan'`) | Gemeinsames Muster, ggf. später Jinkendo-weit |
|
||||
| `tiers` | `club_plans` | Produktdefinition |
|
||||
| `tier_limits` | `club_plan_limits` | Matrix Plan × Feature |
|
||||
| `user_feature_restrictions` | `club_feature_overrides` | Admin-Override pro Verein |
|
||||
| `user_feature_usage` | `club_feature_usage` | Verbrauch pro Verein |
|
||||
| `access_grants` | `club_access_grants` | Trial, Promo, manuelle Freischaltung |
|
||||
| `check_feature_access()` | `check_club_feature_access()` | Subjekt `club_id` |
|
||||
| `increment_feature_usage()` | `increment_club_feature_usage()` | Nur bei INSERT / KI-Call |
|
||||
| 4-Phasen-Rollout | identisch | Log → UI → Hard-Block |
|
||||
| `GET /api/features/usage` | `GET /api/clubs/{id}/entitlements` | siehe Capability-Doc §7 |
|
||||
|
||||
### 3.2 Nicht übernehmen
|
||||
|
||||
| Mitai | Shinkan-Grund |
|
||||
|-------|---------------|
|
||||
| `profiles.tier` als Haupt-Abo | Verein zahlt, nicht Einzeltrainer |
|
||||
| `subscriptions` (Shinkan `001`, INT-Features) | Ungenutzt, Schema-Drift |
|
||||
| `get_effective_tier(profile_id)` für Shinkan-Limits | Ersetzen durch `get_effective_club_plan(club_id)` |
|
||||
| Profil-zentrierte Enforcement-Hooks allein | Primär `club_id`; Profil nur für Attribution |
|
||||
|
||||
### 3.3 Parallelität Jinkendo-Familie (später)
|
||||
|
||||
`CENTRAL_SUBSCRIPTION_SYSTEM.md` (Mitai): zentrales Personen-Abo über Apps.
|
||||
|
||||
**Zielbild ohne Refactoring:**
|
||||
|
||||
```
|
||||
features.enforcement_subject ∈ { 'club', 'profile', 'portal' }
|
||||
|
||||
effektives_limit(feature) = merge(
|
||||
club_plan_limit(club_id, feature), # Shinkan-Hauptquelle
|
||||
profile_grant_limit(profile_id, feature) # optional Jinkendo-Bonus
|
||||
)
|
||||
```
|
||||
|
||||
Merge-Regel (Vorschlag): **Maximum** der erlaubten Kontingente, boolean = OR. Details vor Stripe festlegen.
|
||||
|
||||
---
|
||||
|
||||
## 4. Ist-Zustand Shinkan (Drift — zuerst bereinigen)
|
||||
|
||||
| Artefakt | Problem |
|
||||
|----------|---------|
|
||||
| `backend/migrations/001_auth_membership.sql` | `features.id SERIAL`, `tier_limits.tier VARCHAR` |
|
||||
| `backend/auth.py` `check_feature_access()` | Erwartet Mitai-v9c-Schema (`features.id TEXT`, `tier_id`, `limit_type`, …) |
|
||||
| Kein Router | Ruft `check_feature_access` auf |
|
||||
| `profiles.tier` | Existiert, ohne Shinkan-Enforcement |
|
||||
|
||||
**Pflicht vor Phase 3 (Enforcement):** Migration `0XX_club_features_v1.sql` — v9c-kompatibles Feature-Schema + Vereins-Tabellen; alte `001`-Feature-Zeilen migrieren oder deprecaten.
|
||||
|
||||
---
|
||||
|
||||
## 5. Ziel-Schema (v1)
|
||||
|
||||
### 5.1 Feature-Registry (app-weit, Mitai-kompatibel)
|
||||
|
||||
```sql
|
||||
-- Konzept — Implementierung als nummerierte Migration
|
||||
CREATE TABLE features (
|
||||
id TEXT PRIMARY KEY, -- z.B. 'ai_calls'
|
||||
app TEXT NOT NULL DEFAULT 'shinkan',
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT NOT NULL, -- 'content'|'planning'|'ai'|'org'|'integration'|'platform'
|
||||
limit_type TEXT NOT NULL DEFAULT 'count', -- 'count' | 'boolean'
|
||||
reset_period TEXT NOT NULL DEFAULT 'never', -- 'never' | 'daily' | 'monthly'
|
||||
default_limit INTEGER, -- NULL=∞, 0=aus
|
||||
enforcement_subject TEXT NOT NULL DEFAULT 'club', -- 'club'|'profile'|'portal'
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### 5.2 Vereins-Produkte & Abo
|
||||
|
||||
```sql
|
||||
CREATE TABLE club_plans (
|
||||
id TEXT PRIMARY KEY, -- 'free', 'verein_starter', 'verein_pro'
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
price_monthly_cents INTEGER,
|
||||
price_yearly_cents INTEGER,
|
||||
stripe_price_id_monthly TEXT,
|
||||
stripe_price_id_yearly TEXT,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE club_subscriptions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||
plan_id TEXT NOT NULL REFERENCES club_plans(id),
|
||||
status TEXT NOT NULL DEFAULT 'active', -- active|trial|past_due|cancelled
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ends_at TIMESTAMPTZ,
|
||||
trial_ends_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (club_id) -- ein aktiver Plan pro Verein (v1)
|
||||
);
|
||||
|
||||
CREATE TABLE club_plan_limits (
|
||||
id SERIAL PRIMARY KEY,
|
||||
plan_id TEXT NOT NULL REFERENCES club_plans(id) ON DELETE CASCADE,
|
||||
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
|
||||
limit_value INTEGER, -- NULL=∞, 0=deaktiviert
|
||||
UNIQUE (plan_id, feature_id)
|
||||
);
|
||||
```
|
||||
|
||||
### 5.3 Overrides, Grants, Verbrauch
|
||||
|
||||
```sql
|
||||
CREATE TABLE club_feature_overrides (
|
||||
id SERIAL PRIMARY KEY,
|
||||
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
|
||||
limit_value INTEGER NOT NULL,
|
||||
reason TEXT,
|
||||
set_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (club_id, feature_id)
|
||||
);
|
||||
|
||||
CREATE TABLE club_access_grants (
|
||||
id SERIAL PRIMARY KEY,
|
||||
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||
plan_id TEXT REFERENCES club_plans(id),
|
||||
feature_id TEXT REFERENCES features(id), -- optional Einzel-Feature
|
||||
grant_limit INTEGER,
|
||||
starts_at TIMESTAMPTZ NOT NULL,
|
||||
ends_at TIMESTAMPTZ NOT NULL,
|
||||
reason TEXT,
|
||||
created_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE club_feature_usage (
|
||||
id SERIAL PRIMARY KEY,
|
||||
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
reset_at TIMESTAMPTZ,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
UNIQUE (club_id, feature_id)
|
||||
);
|
||||
|
||||
-- Optional: Attribution / Fairness / Audit
|
||||
CREATE TABLE club_feature_usage_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
club_id INT NOT NULL,
|
||||
feature_id TEXT NOT NULL,
|
||||
profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL, -- 'ai_suggest', 'exercise_create', ...
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### 5.4 Capabilities (Rollen — Kurzreferenz)
|
||||
|
||||
Siehe `CAPABILITY_CATALOG.v1.md` für IDs. Tabellen:
|
||||
|
||||
```sql
|
||||
CREATE TABLE capabilities (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
domain TEXT NOT NULL,
|
||||
min_account_state TEXT NOT NULL DEFAULT 'active_member',
|
||||
linked_feature_id TEXT REFERENCES features(id), -- optional Kontingent
|
||||
active BOOLEAN NOT NULL DEFAULT true
|
||||
);
|
||||
|
||||
CREATE TABLE club_role_capability_grants (
|
||||
role_code TEXT NOT NULL, -- club_admin, trainer, ...
|
||||
capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (role_code, capability_id)
|
||||
);
|
||||
|
||||
CREATE TABLE portal_role_capability_grants (
|
||||
portal_role TEXT NOT NULL, -- admin, superadmin
|
||||
capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (portal_role, capability_id)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Shinkan Feature-Katalog (Seed v1)
|
||||
|
||||
Übernahme aus `001_auth_membership.sql` + Ist-Endpoints, angereichert:
|
||||
|
||||
| feature_id | category | limit_type | reset_period | enforcement_subject | Default Free | Beschreibung |
|
||||
|------------|----------|------------|--------------|---------------------|--------------|--------------|
|
||||
| `exercises` | content | count | never | club | 100 | Anzahl Übungen im Verein (Bestand) |
|
||||
| `exercise_media` | content | count | monthly | club | 20 | Medien-Uploads / Monat |
|
||||
| `training_units` | planning | count | monthly | club | 40 | Geplante/durchgeführte Einheiten |
|
||||
| `training_programs` | planning | count | never | club | 5 | Module + Rahmenprogramme (kombiniert v1) |
|
||||
| `training_groups` | org | count | never | club | 10 | Trainingsgruppen |
|
||||
| `active_members` | org | count | never | club | 25 | Aktive Mitglieder |
|
||||
| `ai_calls` | ai | count | monthly | club | 0 | KI-Aufrufe (Suggest, Regenerate, Planung) |
|
||||
| `ai_pipeline` | ai | boolean | never | club | 0 | Erweiterte KI-Pipelines (Batch, später) |
|
||||
| `wiki_import` | integration | boolean | never | portal | 0 | MediaWiki-Import (Superadmin) |
|
||||
| `data_export` | integration | boolean | never | club | 0 | Export-Funktionen (wenn eingeführt) |
|
||||
|
||||
**Hinweis:** Free-Defaults sind Produktentscheidung — Tabelle dient Implementierung.
|
||||
|
||||
### 6.1 Beispiel-Pläne (Seed)
|
||||
|
||||
| plan_id | ai_calls/Monat | exercises | active_members |
|
||||
|---------|----------------|-----------|----------------|
|
||||
| `free` | 0 | 100 | 25 |
|
||||
| `verein_starter` | 30 | 500 | 80 |
|
||||
| `verein_pro` | 200 | NULL (∞) | NULL |
|
||||
| `pilot` | 100 | NULL | NULL |
|
||||
|
||||
Jeder Verein erhält bei Anlage durch Superadmin initial `club_subscriptions.plan_id = 'free'` (oder `pilot`).
|
||||
|
||||
---
|
||||
|
||||
## 7. Auflösungslogik
|
||||
|
||||
### 7.1 Effektiver Vereinsplan
|
||||
|
||||
```python
|
||||
def get_effective_club_plan(cur, club_id: int) -> str:
|
||||
"""
|
||||
1. Aktiver club_access_grants mit plan_id (höchste Priorität, Zeitfenster)
|
||||
2. club_subscriptions.status == 'active' → plan_id
|
||||
3. Fallback 'free'
|
||||
"""
|
||||
```
|
||||
|
||||
### 7.2 Feature-Limit (analog Mitai `check_feature_access`)
|
||||
|
||||
```python
|
||||
def check_club_feature_access(
|
||||
cur,
|
||||
club_id: int,
|
||||
feature_id: str,
|
||||
*,
|
||||
profile_id: int | None = None, # nur für Logging / optionale Profil-Boni später
|
||||
) -> dict:
|
||||
"""
|
||||
Priorität:
|
||||
1. club_feature_overrides (club_id, feature_id)
|
||||
2. club_plan_limits für get_effective_club_plan(club_id)
|
||||
3. features.default_limit
|
||||
|
||||
Auswertung:
|
||||
- limit_type boolean: limit_value == 1
|
||||
- limit_type count: used < limit (club_feature_usage, reset beachten)
|
||||
|
||||
Returns: { allowed, limit, used, remaining, reason, reset_at }
|
||||
"""
|
||||
```
|
||||
|
||||
### 7.3 Vollständige Request-Kette
|
||||
|
||||
```
|
||||
1. require_auth
|
||||
2. assert_account_state(min_state) # unverified / verified_pending_club / active_member
|
||||
3. get_tenant_context
|
||||
4. assert_capability(tenant, cap_id) # Rollen-Achse
|
||||
5. assert_content_governance(...) # nur bei Objekt-Endpoints
|
||||
6. check_club_feature_access(club_id, feature_id)
|
||||
7. … Business-Logik …
|
||||
8. consume_club_feature_with_usage(…) + merge_feature_usage_into_response(payload, usage)
|
||||
# Standard: zählen, JSON-Log phase=consume, feature_usage in Response
|
||||
9. optional: club_feature_usage_events (profile_id, action)
|
||||
```
|
||||
|
||||
**Response-Standard (alle Consume-Endpoints):** JSON-Feld `feature_usage` — Map `feature_id → { allowed, used, limit, remaining, reason, … }` wie `GET /me/entitlements`. Frontend: `request()` synchronisiert Entitlements automatisch (`featureUsageSync.js`); UI-Komponenten brauchen keinen Einzelcode.
|
||||
|
||||
### 7.4 Wer zählt als Verbrauch?
|
||||
|
||||
| Aktion | increment | Subjekt |
|
||||
|--------|-----------|---------|
|
||||
| `POST /exercises` (neu) | `exercises` | `club_id` des Objekts oder `effective_club_id` |
|
||||
| Medien-Upload | `exercise_media` | Verein des Mediums |
|
||||
| KI Suggest/Regenerate | `ai_calls` | `effective_club_id` |
|
||||
| Mitglied hinzufügen | `active_members` | Ziel-`club_id` |
|
||||
| Trainingsgruppe anlegen | `training_groups` | `club_id` |
|
||||
|
||||
**Mitai-Regel:** Counter **nicht** bei UPDATE/DELETE erhöhen.
|
||||
|
||||
---
|
||||
|
||||
## 8. API-Oberfläche
|
||||
|
||||
### 8.1 Nutzer / Vereinsadmin
|
||||
|
||||
```
|
||||
GET /api/clubs/{club_id}/entitlements
|
||||
```
|
||||
|
||||
Kombiniert Capabilities + Feature-Kontingente (siehe `CAPABILITY_CATALOG.v1.md` §7.1).
|
||||
|
||||
```
|
||||
GET /api/me/entitlements?club_id=12
|
||||
```
|
||||
|
||||
Bequemer Alias für aktiven Verein.
|
||||
|
||||
### 8.2 Superadmin / Plattform
|
||||
|
||||
| Endpoint | Zweck |
|
||||
|----------|-------|
|
||||
| `GET/PUT /api/admin/club-plans` | Plan-CRUD |
|
||||
| `GET/PUT /api/admin/club-plan-limits` | Matrix |
|
||||
| `GET/PUT /api/admin/clubs/{id}/subscription` | Verein-Abo |
|
||||
| `GET/PUT /api/admin/clubs/{id}/feature-overrides` | Sonderkontingente |
|
||||
| `POST /api/admin/clubs/{id}/access-grants` | Trial/Promo |
|
||||
|
||||
Vorbild UI: Mitai `AdminTierLimitsPage.jsx`, `AdminUserRestrictionsPage.jsx` → Vereins-Kontext.
|
||||
|
||||
### 8.3 Geplant: Vereinsgründung
|
||||
|
||||
```
|
||||
POST /api/club-creation-requests # Nutzer (verified_pending_club)
|
||||
GET /api/admin/club-creation-requests
|
||||
POST /api/admin/club-creation-requests/{id}/approve # legt club + subscription an
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Vier-Phasen-Rollout (aus Mitai)
|
||||
|
||||
| Phase | Shinkan-Aktivität | Nutzer sichtbar? |
|
||||
|-------|-------------------|------------------|
|
||||
| **0** | Schema-Migration, Seed `features` + `club_plans`, Drift `001` bereinigen | Nein |
|
||||
| **1** | Account-Gates + Capability-Grants (ohne Limits) | Onboarding-Hinweise |
|
||||
| **2** | `check_club_feature_access` — **nur JSON-Log** (`feature_logger` analog Mitai) | Nein |
|
||||
| **3** | `GET …/entitlements` + UsageBadge im UI | Ja (Kontingent-Anzeige) |
|
||||
| **4** | HTTP 403 bei Limit + `increment` | Ja (Hard-Block) |
|
||||
|
||||
**Reihenfolge innerhalb Phase 4:** zuerst `ai_calls`, dann `exercise_media`, dann Bestands-Limits (`exercises`, `active_members`).
|
||||
|
||||
---
|
||||
|
||||
## 10. CI / Test-Isolation (Betrieb)
|
||||
|
||||
Unabhängig vom Membership-System — **Pflicht** wegen Prod-Vorfälle (`access_layer_it_*@test.local`):
|
||||
|
||||
| Regel | Umsetzung |
|
||||
|-------|-----------|
|
||||
| Integrationstests nie gegen Prod-DB | Eigene Test-DB oder Job-Postgres in Gitea |
|
||||
| `ENVIRONMENT=production` + `ALLOW_INTEGRATION_TESTS` | Default `0`, Tests abbrechen |
|
||||
| Test-Accounts | E-Mail `@test.local` oder `profiles.is_test_account` |
|
||||
| Cleanup | Fixture-`finally` + Nightly-Job löscht Leichen |
|
||||
|
||||
`.gitea/workflows/test.yml`: pytest-backend gegen Deploy-DB **ersetzen** durch isolierte DB (eigenes Epic, parallel zu Membership).
|
||||
|
||||
---
|
||||
|
||||
## 11. Implementierungs-Roadmap (gesamt)
|
||||
|
||||
| Schritt | Deliverable | Membership-relevant |
|
||||
|---------|-------------|-------------------|
|
||||
| M0 | CI-Isolation + Prod-Cleanup-Runbook | Nein |
|
||||
| M1 | Migration Feature-Schema v9c + `club_plans`/`club_subscriptions` (leer nutzbar) | **Ja** |
|
||||
| M2 | `check_club_feature_access` + Seed Pläne | **Ja** |
|
||||
| M3 | Account-Lifecycle + Capability-Grants | Capabilities |
|
||||
| M4 | `GET /me/entitlements` | **Ja** |
|
||||
| M5 | Enforcement `ai_calls` (Phase 4) | **Ja** |
|
||||
| M6 | Admin Plan-Matrix UI | **Ja** |
|
||||
| M7 | `club_creation_requests` | Prozess |
|
||||
| M8 | Stripe / Rechnung | Später |
|
||||
|
||||
**Nach Produktentscheidungen 2026-06-06** (Details `MEMBERSHIP_RBAC_DECISIONS_2026-06.md` §4):
|
||||
|
||||
| Phase | Paket | Priorität |
|
||||
|-------|--------|-----------|
|
||||
| A | Onboarding-Gates vollständig (`verified_pending_club`) | **Als Nächstes** |
|
||||
| B | M7 Vereinsgründung beantragen | hoch |
|
||||
| C | M5 Hard-Block `ai_calls` | danach |
|
||||
| D | M6 Superadmin-UI | danach |
|
||||
| E | Systemrolle `co_trainer` + Frontend-Entitlements | v1 Rollen |
|
||||
| F | Trainer-Member-Budgets (v2) | später |
|
||||
|
||||
---
|
||||
|
||||
## 12. Offene Produktentscheidungen
|
||||
|
||||
Vor M6 festlegen:
|
||||
|
||||
1. **Zählen `active_members`:** alle Mitglieder oder nur Rollen mit Planungsrecht?
|
||||
2. **Soft-Limit vs. Hard-Stop:** Warnung bei 80 % oder sofort 403?
|
||||
3. **Pilotverein:** eigener Plan `pilot` mit hohen Limits?
|
||||
4. **KI-Fairness:** nur Vereinslimit oder zusätzlich Max pro Trainer/Monat?
|
||||
5. **Offizielle Inhalte:** für `verified_pending_club` sichtbar oder gesperrt? → **entschieden: gesperrt** (`MEMBERSHIP_RBAC_DECISIONS_2026-06.md` §1.1)
|
||||
6. **Portal `admin` vs. `superadmin`:** Wer darf Vereine anlegen? (Ziel: nur `superadmin` für Freigabe)
|
||||
|
||||
---
|
||||
|
||||
## 13. Referenzen
|
||||
|
||||
| Pfad | Inhalt |
|
||||
|------|--------|
|
||||
| `c:/dev/mitai-jinkendo/backend/migrations/v9c_subscription_system.sql` | Mitai-Schema-Vorlage |
|
||||
| `c:/dev/mitai-jinkendo/.claude/docs/architecture/FEATURE_ENFORCEMENT.md` | 4-Phasen-Modell |
|
||||
| `c:/dev/mitai-jinkendo/.claude/docs/technical/MEMBERSHIP_SYSTEM.md` | Mitai-Hauptdoku |
|
||||
| `c:/dev/mitai-jinkendo/.claude/docs/technical/CENTRAL_SUBSCRIPTION_SYSTEM.md` | Jinkendo-Familie später |
|
||||
| `CAPABILITY_CATALOG.v1.md` | Rollen & Capabilities |
|
||||
| `MULTI_TENANCY_RBAC_ARCHITECTURE.md` §4.6 | Ursprüngliches Vereinsabo-Zielbild |
|
||||
| `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` | Stufe E/F |
|
||||
|
||||
---
|
||||
|
||||
**Changelog**
|
||||
|
||||
- 2026-06-06: v1 — Mitai-Mapping, Ziel-Schema, Feature-Seed, Auflösungslogik, Rollout.
|
||||
243
.claude/docs/technical/MEMBERSHIP_RBAC_DECISIONS_2026-06.md
Normal file
243
.claude/docs/technical/MEMBERSHIP_RBAC_DECISIONS_2026-06.md
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
# Membership, RBAC & Kontingente — Produktentscheidungen
|
||||
|
||||
**Status:** Verbindlich (Zielbild & Roadmap-Priorisierung)
|
||||
**Stand:** 2026-06-06
|
||||
**Bezüge:** `CAPABILITY_CATALOG.v1.md`, `CLUB_MEMBERSHIP_AND_FEATURES.v1.md`, `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`
|
||||
|
||||
Dieses Dokument hält **getroffene Produktentscheidungen** fest (Session 2026-06-06) und ergänzt die v1-Konzept-Specs um Umsetzungsrichtung. Technischer Implementierungsstand: Abschnitt 2.
|
||||
|
||||
---
|
||||
|
||||
## 1. Getroffene Entscheidungen
|
||||
|
||||
### 1.1 Onboarding: `verified_pending_club`
|
||||
|
||||
Nutzer **ohne aktive Vereinsmitgliedschaft** (E-Mail verifiziert) dürfen **nur**:
|
||||
|
||||
| Erlaubt | Nicht erlaubt (Zielbild) |
|
||||
|---------|---------------------------|
|
||||
| Konto / Einstellungen | Übungen, Planung, KI, Medien |
|
||||
| Vereinsverzeichnis lesen | Vereinsinterne Inhalte (`club`), private Fremdinhalte |
|
||||
| **Beitrittsantrag** an bestehenden Verein | Vollzugriff auf Bibliothek / offizielle Inhalte (Lesen) — **bewusst gesperrt** bis Mitgliedschaft |
|
||||
| **Vereinsgründung beantragen** (Prozess M7, Superadmin-Freigabe) | |
|
||||
|
||||
**Kein** „Bibliothek durchstöbern“ für Bewerber — reduziert Datenexposition und vereinfacht UX („erst Verein, dann Arbeit“).
|
||||
|
||||
Technischer Zustand: `account_state = verified_pending_club` (siehe `CAPABILITY_CATALOG.v1.md` §3).
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Rollenmodell: Risikoarm statt Big-Bang
|
||||
|
||||
**Zielbild (langfristig):**
|
||||
|
||||
- **Fest:** nur `superadmin` (Plattform) als nicht konfigurierbare Systemrolle.
|
||||
- **Dynamisch konfigurierbar:** alle Vereinsrollen und deren Capability-Bundles (später `club_custom_roles`).
|
||||
- Optional: `admin` (Plattform) als abgeschwächter Portal-Admin bleibt vorerst bestehen (Ist-Code).
|
||||
|
||||
**Entscheidung v1 (risikoarm):**
|
||||
|
||||
| Maßnahme | Jetzt | Später |
|
||||
|----------|-------|--------|
|
||||
| Alte Helfer (`can_plan_in_club`, `if (club_admin)` in JSX) | **Behalten** — weiter produktiv | Schrittweise durch `entitlements` ersetzen |
|
||||
| Neue Endpoints / Features | Nur über **Capability-IDs** + Audit | — |
|
||||
| Neue Vereinsrollen | Als **Systemrollen** ergänzen (z. B. `co_trainer`) | Custom Roles UI |
|
||||
| `club_custom_roles` | **Nicht** in v1 | v2 Epic |
|
||||
|
||||
**Begründung:** Backend und Frontend haben hunderte Verdrahtungen auf `trainer` / `club_admin` / Plattform-Rollen. Parallelbetrieb Capability-System + Legacy-Helfer ist sicherer als einmaliges Aufbrechen.
|
||||
|
||||
**Co-Trainer (geplant als Systemrolle):** weniger Capabilities als `trainer` (z. B. kein `planning.*`, kein `exercises.create`) — Umsetzung nach Onboarding-Gates + Entitlements-Rollout, nicht vorher.
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Vereins-Kontingente (Membership-Pakete)
|
||||
|
||||
**Jetzt:** Schema und Anzeige vorbereiten; **keine** detaillierte Paket-Logik (z. B. „3 Trainer + 10 Co-Trainer“) implementieren.
|
||||
|
||||
| Vorbereitet (DB/Module) | Bewusst zurückgestellt |
|
||||
|-------------------------|-------------------------|
|
||||
| `features`, `club_plans`, `club_subscriptions` | Eigene Feature-IDs `trainer_seats` / `co_trainer_seats` |
|
||||
| Bestands-Limits (`exercises`, `training_groups`, `ai_calls`, …) | Zählregel „nur planungsberechtigte Mitglieder“ vs. alle Mitglieder |
|
||||
| `GET /me/entitlements` Feature-Teil | Stripe / Rechnung (M8) |
|
||||
|
||||
**Prinzip:** Neue Kontingent-Typen = neue `features`-Zeile + Plan-Limits + optional Capability-`linked_feature_id` — ohne Schema-Bruch.
|
||||
|
||||
---
|
||||
|
||||
### 1.4 Trainer-Budget innerhalb Vereins-Kontingent (v2)
|
||||
|
||||
**Anforderung:** Vereins-KI-Kontingent liegt beim Verein; **Vereinsadmin** kann pro Trainer ein **Sub-Budget** vergeben (Fairness, „Kontingent-Fresser“).
|
||||
|
||||
**Entscheidung:**
|
||||
|
||||
- v1: nur **Vereins-Ebene** (`club_plan_limits`, `club_feature_usage`).
|
||||
- v2: neue Tabellen (Skizze):
|
||||
|
||||
```sql
|
||||
-- Skizze — noch nicht migriert
|
||||
club_member_feature_budgets (club_id, profile_id, feature_id, limit_value, …)
|
||||
club_member_feature_usage (club_id, profile_id, feature_id, usage_count, reset_at, …)
|
||||
```
|
||||
|
||||
**Prüf-Kette v2:** Capability → Mitglieds-Budget (falls gesetzt, `profile_id` aus Session) → Vereins-Kontingent.
|
||||
|
||||
**Fairness-Modell (offen, Tendenz):** harte Sub-Budgets (Modell A) — Trainer darf sein Budget nicht überschreiten, auch wenn Verein noch Rest hat.
|
||||
|
||||
**Roadmap:** Phase 5b / Meilenstein **M9** in `docs/working/RBAC_ENFORCEMENT_ROADMAP.md` — Vereinsadmin-UI zur Verteilung, Entitlements mit persönlichem + Vereins-Rest, Auswertung je Person.
|
||||
|
||||
---
|
||||
|
||||
### 1.5 Enforcement-Phasen (unverändert, bestätigt)
|
||||
|
||||
| Phase | Verhalten | Nutzer sichtbar |
|
||||
|-------|-----------|-----------------|
|
||||
| 2 (M2/M3) | JSON-Log, kein Block | Nein (außer Logs) |
|
||||
| 3 (M4) | `GET /me/entitlements` + Badge | Kontingent-Anzeige |
|
||||
| 4 (M5+) | HTTP 403 + `increment` | Hard-Block |
|
||||
|
||||
Env-Schalter: `ACCOUNT_GATE_ENFORCE` (Default `1`, Endpoint-Helfer), `ACCOUNT_GATE_API_ENFORCE` (Default `1`, API-Middleware Phase A), `CAPABILITY_ENFORCE` / `CLUB_FEATURE_ENFORCE` (Default `0`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Implementierungsstand (Ist, Codebase)
|
||||
|
||||
**DB-Schema:** `20260606083` · App **0.8.199** (`backend/version.py`)
|
||||
**Roadmap (detailliert):** `docs/working/RBAC_ENFORCEMENT_ROADMAP.md`
|
||||
|
||||
### M1 — Feature-Schema v9c ✅
|
||||
|
||||
| Deliverable | Status |
|
||||
|-------------|--------|
|
||||
| Migration `078_club_features_and_plans.sql` | ✅ |
|
||||
| Legacy `001` archiviert | ✅ |
|
||||
| `club_plans`, `club_subscriptions`, Usage-Tabellen | ✅ |
|
||||
| Seed Features + Pläne (`free`, …) | ✅ |
|
||||
| `club_features.py`: `check_club_feature_access`, `get_effective_club_plan` | ✅ |
|
||||
| Backfill Vereine → Plan `free` | ✅ |
|
||||
|
||||
### M2 — Feature-Probe (Log only) ✅
|
||||
|
||||
| Deliverable | Status |
|
||||
|-------------|--------|
|
||||
| `club_feature_logger.py` → `club-feature-usage.log` | ✅ |
|
||||
| `probe_club_feature_access()` | ✅ |
|
||||
| Hooks: KI-Endpoints, `POST /exercises`, Medien-Upload, Planungs-KI | ✅ |
|
||||
| Consume-Standard + `feature_usage` in Response (`ai_calls`) | ✅ |
|
||||
| `CLUB_FEATURE_ENFORCE=0` (Default) | ✅ |
|
||||
|
||||
### M3 — Account-Lifecycle + Capability-Grants ⚠️ teilweise
|
||||
|
||||
| Deliverable | Status | Lücke |
|
||||
|-------------|--------|-------|
|
||||
| Migration `079_capabilities.sql` + Seed | ✅ | — |
|
||||
| `account_lifecycle.py`, `resolve_account_state` | ✅ | — |
|
||||
| `capabilities.py`, `check_capability`, `probe_capability` | ✅ | — |
|
||||
| `TenantContext.account_state` | ✅ | — |
|
||||
| `GET /profiles/me` → `account_state`, `club_roles` | ✅ | — |
|
||||
| Account-Gates auf **Schreib-/KI-Endpoints** | ✅ | Lesepfade für Bewerber noch offen |
|
||||
| `CAPABILITY_ENFORCE=0` (nur Log) | ✅ | — |
|
||||
| Onboarding UX: nur Bewerbung/Gründung | ✅ | Phase A: API-Middleware + `/onboarding` + reduzierte Nav |
|
||||
| `club_creation_requests` (M7) | ✅ Basis | Capabilities + Admin-Freigabe |
|
||||
| Quota-Bypass via Capability-Grants (083) | ✅ | kein paralleles Exemption-Schema |
|
||||
| Custom Roles / Co-Trainer | ❌ | bewusst v2 |
|
||||
| Legacy-Helfer entfernt | ❌ | bewusst parallel |
|
||||
|
||||
### M4 — Anzeige ✅ teilweise
|
||||
|
||||
| Deliverable | Status |
|
||||
|-------------|--------|
|
||||
| `GET /api/me/entitlements` | ✅ |
|
||||
| `EntitlementsContext`, `hasCapability()` | ✅ (UI nutzt noch kaum) |
|
||||
| `FeatureUsageBadge` | ✅ nur KI im Übungsformular |
|
||||
| `featureUsageSync` in `request()` | ✅ |
|
||||
|
||||
### M5 — Hard-Block + vollständiger Verbrauch ⚠️
|
||||
|
||||
| Deliverable | Status |
|
||||
|-------------|--------|
|
||||
| `consume_club_feature_with_usage` Standard | ✅ `ai_calls` |
|
||||
| `CLUB_FEATURE_ENFORCE=1` produktiv | ❌ Default 0 |
|
||||
| Consume `exercises`, `exercise_media`, … | ❌ |
|
||||
|
||||
### M6 — Admin UI Rollen & Rechte ⚠️
|
||||
|
||||
| Deliverable | Status |
|
||||
|-------------|--------|
|
||||
| `/admin/rights` Capability-Matrix (Portal + Verein) | ✅ |
|
||||
| Klartext zuerst, Enforcement-Badge | ✅ 2026-06-07 |
|
||||
| Kontingent-Bypass + Vereinspläne (Seed) | ✅ |
|
||||
| Neue Pläne / Rollen anlegen (CRUD) | ❌ |
|
||||
|
||||
### Bewusst zurückgestellt
|
||||
|
||||
| ID | Inhalt |
|
||||
|----|--------|
|
||||
| M0 | CI-Isolation / Test-DB |
|
||||
| M8 | Stripe |
|
||||
| v2 | Trainer-Budgets, Custom Roles |
|
||||
|
||||
---
|
||||
|
||||
## 3. Architektur-Zielbild (kompakt)
|
||||
|
||||
```
|
||||
Request
|
||||
→ require_auth
|
||||
→ account_state (Gate)
|
||||
→ TenantContext
|
||||
→ assert_capability (Rolle / Funktion)
|
||||
→ check_club_feature_access (Vereins-Kontingent)
|
||||
→ [v2] member_feature_budget (Trainer-Budget)
|
||||
→ Governance (Objekt)
|
||||
```
|
||||
|
||||
**Drei Achsen:** Account-Lifecycle · Capabilities · Features (Kontingente). Governance bleibt vierte Prüfung.
|
||||
|
||||
---
|
||||
|
||||
## 4. Empfohlene Roadmap (nach Entscheidungen)
|
||||
|
||||
| Phase | Paket | Warum zuerst |
|
||||
|-------|--------|--------------|
|
||||
| **A** | **Onboarding-Gates vollständig** | ✅ umgesetzt (API + Frontend `/onboarding`) |
|
||||
| **B** | **M7 Vereinsgründung beantragen** | **Als Nächstes** — zweiter Pfad für `verified_pending_club` |
|
||||
| **C** | **M5 Hard-Block `ai_calls`** | Free-Plan `0` wird real; Badge (M4) liefert Erklärung |
|
||||
| **D** | **M6 voll** | Pläne-CRUD, Rollen-CRUD | ⚠️ Matrix da |
|
||||
| **E** | Entitlements im Frontend (`hasCapability`) | Entscheidung 1.2 risikoarm |
|
||||
| **F** | **M9 Kontingent-Verteilung** — Vereinsadmin vergibt Sub-Budgets pro Person (`profile_id`); Prüfung + Consume personenbezogen; UI Vereinsorga | Entscheidung 1.4, Roadmap Phase 5b |
|
||||
| **G** | `co_trainer` + Custom Roles (v2) | Entscheidung 1.2 |
|
||||
|
||||
M0 parallel, nicht blockierend.
|
||||
|
||||
---
|
||||
|
||||
## 5. Offene Punkte (vor M6 / v2)
|
||||
|
||||
1. Fairness Modell A/B/C für Trainer-Budget (Tendenz: A).
|
||||
2. Ob `admin` (Portal) langfristig neben `superadmin` bleibt.
|
||||
3. Ob offizielle Inhalte für Bewerber **nie** lesbar bleiben (aktuell: ja).
|
||||
|
||||
---
|
||||
|
||||
## 6. Referenzen
|
||||
|
||||
| Pfad | Inhalt |
|
||||
|------|--------|
|
||||
| `CAPABILITY_CATALOG.v1.md` | Capability-IDs, Account-States |
|
||||
| `CLUB_MEMBERSHIP_AND_FEATURES.v1.md` | Feature-Registry, Kontingente |
|
||||
| `backend/club_features.py` | Vereins-Features |
|
||||
| `backend/capabilities.py` | Capability-Auflösung |
|
||||
| `backend/account_lifecycle.py` | Account-Gates |
|
||||
|
||||
## 7. Superadmin im Verein (FAQ)
|
||||
|
||||
Siehe **`docs/working/RBAC_ENFORCEMENT_ROADMAP.md` §4**: Plattform-Admin (`admin`, `superadmin`) erhält **Capability-Bypass** für Vereins-Funktionen ohne `club_admin`-Mitgliedschaft. Mandant über aktiven Verein wählen; Kontingente via Bypass. Einzelne Legacy-Pfade (z. B. Löschen `visibility=club`) sind noch nicht vereinheitlicht — Ziel Phase 3.
|
||||
|
||||
---
|
||||
|
||||
**Changelog**
|
||||
|
||||
- 2026-06-06: Initial — Entscheidungen Onboarding, Rollen-Risiko, Kontingente, Trainer-Budget v2; Ist-Stand M1–M3; Roadmap A–F.
|
||||
- 2026-06-06: Phase A — `account_onboarding_gate.py`, Frontend `/onboarding`, reduzierte Navigation.
|
||||
- 2026-06-07: M4–M6 Ist-Stand, Roadmap-Verweis, Superadmin-FAQ; Admin-Matrix UX + Enforcement-Audit.
|
||||
- 2026-06-08: Roadmap Phase 5b / M9 — Vereinsadmin-Kontingentverteilung pro Person; Enforce Dev verifiziert (0.8.202).
|
||||
|
|
@ -227,7 +227,9 @@ Ziel: **vereinszentrierte** Vertrags- und Limitlogik, analog zur bestehenden Tie
|
|||
## 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.
|
||||
- **`CAPABILITY_CATALOG.v1.md`** – Rollen, Capability-IDs, Account-Lifecycle, Endpoint-Mapping.
|
||||
- **`CLUB_MEMBERSHIP_AND_FEATURES.v1.md`** – Vereinsabo, Feature-Registry (Mitai-v9c-Pattern), Kontingente.
|
||||
|
||||
---
|
||||
|
||||
**Letzte Aktualisierung:** 2026-05-05
|
||||
**Letzte Aktualisierung:** 2026-06-06
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
**Status:** Planungs-/Architektur-Arbeitspapier (keine Implementierungspflicht)
|
||||
**Ziel:** Für die **spätere** Planungs-KI bereits **Schnittstellen und Schichten** vorzeichnen, damit die **kleinere, starre** Übungs-KI nicht zur impliziten Vorlage für einen viel größeren Kopf wird — **ohne** jetzt eine Mitai-artige Workflow-Engine zu bauen.
|
||||
|
||||
**Update 2026-06-07:** Progressionsgraph startet **Phase F** (`planning_progression_roadmap.py`) — Roadmap-first, Workflow-lite. Siehe **`PLANNING_PROGRESSION_ROADMAP_SPEC.md`** und **`docs/architecture/PLANNING_KI_ROADMAP.md`**. Gruppenanalyse bleibt in der **Trainingsplanungs-Pipeline** (§3 S0–S4), nicht im Graphen.
|
||||
|
||||
**Bezüge:** `technical/AI_TRAINING_PLANNING_CONCEPT.md` · `functional/AI_EXERCISE_ASSISTANT_VISION.md` · `technical/SKILL_SCORING_SPEC.md` · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR-003) · Schwesterprojekt Mitai: `c:/dev/mitai-jinkendo` (Referenz: `prompt_executor`, `placeholder_resolver`, `workflow_*` — **nicht** Pflicht-Port).
|
||||
|
||||
---
|
||||
|
|
@ -107,6 +109,16 @@ So bleibt dieselbe fachliche Tiefe erreichbar ohne Kontext-Explosion.
|
|||
|
||||
---
|
||||
|
||||
## 9. Changelog
|
||||
## 9. Progressionsgraph vs. Trainingsplanung (2026-06-07)
|
||||
|
||||
| Pipeline | Kontext | Orchestrator |
|
||||
|----------|---------|--------------|
|
||||
| **Progressionsgraph (F)** | Zieltext, N Steps, Semantic Brief | `planning_progression_roadmap.py` |
|
||||
| **Trainingsplanung (G, später)** | Gruppe, Historie, Rahmen, Zeit | `planning_ai_steps` + ggf. Mitai Workflow |
|
||||
|
||||
---
|
||||
|
||||
## 10. Changelog
|
||||
|
||||
- **2026-06-07:** Verweis Phase F Roadmap-first; Abgrenzung Graphen/Planung.
|
||||
- **2026-05-22:** Erstfassung als Vorschau-Dokument für mehrstufige Planungs-KI.
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ score = w_ft * fulltext_rank
|
|||
|
||||
Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
|
||||
|
||||
- Gleiches `context_summary` an `suggestExerciseAi` anhängen (Felder `planning_context_json` o. ä. — noch offen)
|
||||
- `planning_context` im Request-Body → `planning_context_json` in Übungs-Prompts (Migration **085**); Pfad-Builder + Picker ✅ **0.8.208**
|
||||
- Kurzbeschreibung optional leer (freier Vorschlag) oder aus Intent/Skizze
|
||||
|
||||
---
|
||||
|
|
@ -193,7 +193,7 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
|
|||
| **C3** | Graph-Builder (Ziel → Pfad → speichern) | ✅ **0.8.185** |
|
||||
| **E** | Semantik-Schicht + Pfad-QA (Lücken/Brücken/LLM-QS) | ✅ **0.8.186** |
|
||||
| **E2** | Pfad-Neuordnung + KI-Lückenfüller | ✅ **0.8.187** |
|
||||
| **D** | Neu-Anlage: Pack an `suggestExerciseAi` | 🔲 |
|
||||
| **D** | Neu-Anlage: `planning_context` an `suggestExerciseAi` (Migration **085**) | ✅ **0.8.208** |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -486,4 +486,30 @@ Nach Pfad-Bildung:
|
|||
|
||||
---
|
||||
|
||||
## 23. Backlog (offen)
|
||||
## 23. Phase E3 (0.8.203) ✅
|
||||
|
||||
- Off-Topic aus Pfad entfernen; `gap_fill_offers` mit `goal_for_ai`; voller KI-Call im UI (kein Pre-Vorschlag)
|
||||
- Migration **077** `suggested_new_exercises` im Pfad-QS-Prompt
|
||||
|
||||
---
|
||||
|
||||
## 24. Phase F — Roadmap-first Progressionsgraph (0.8.204+) 🔄
|
||||
|
||||
**Entscheidung:** Progressionsgraph plant **vom Ziel rückwärts** (Roadmap → Stufenspezifikation → Bibliothek/KI). **Keine Gruppenanalyse** — die gehört zur Trainingsplanung.
|
||||
|
||||
**Spec:** `working/PLANNING_PROGRESSION_ROADMAP_SPEC.md` · **Roadmap:** `docs/architecture/PLANNING_KI_ROADMAP.md`
|
||||
|
||||
| Teil | Modul / API |
|
||||
|------|-------------|
|
||||
| Pipeline | `planning_progression_roadmap.py` (Workflow-lite) |
|
||||
| API | `include_roadmap_preview`, `include_llm_roadmap`, `roadmap_first` auf `progression-path-suggest` |
|
||||
| Prompts | Migration **078/079** — Slugs in `ai_prompts` (Admin), **kein** Template im Python-Code |
|
||||
| UI | `ExerciseProgressionPathBuilder` — Roadmap-Box (Major Steps) |
|
||||
|
||||
**F3 (0.8.206):** `roadmap_first=true` (Default im UI) — Retrieval pro `stage_spec`/Major Step; `roadmap_unfilled` Gap-Angebote. Ohne Flag: retrieval-first wie bisher, Roadmap nur Preview.
|
||||
|
||||
**Mitai Workflow-Engine:** bewusst **nicht** jetzt — Pipeline workflow-ready für spätere Anbindung.
|
||||
|
||||
---
|
||||
|
||||
## 25. Backlog (offen)
|
||||
|
|
|
|||
198
.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md
Normal file
198
.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
# Planungs-KI — Progressions-Roadmap (Phase F)
|
||||
|
||||
**Version:** 0.1
|
||||
**Datum:** 2026-06-07
|
||||
**Status:** VERBINDLICHE ZIELARCHITEKTUR — Umsetzung gestartet (0.8.204+)
|
||||
**Geltungsbereich:** **Progressionsgraph** (`exercise_progression_graphs`) — **ohne** Gruppenanalyse
|
||||
|
||||
**Bezüge:**
|
||||
`working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` · `working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md` · `technical/AI_TRAINING_PLANNING_CONCEPT.md` · `technical/AI_PROMPT_TARGET_ARCHITECTURE.md` · `docs/architecture/PLANNING_KI_ROADMAP.md` · `docs/HANDOVER.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. Entscheidung (2026-06-07)
|
||||
|
||||
### 1.1 Problem
|
||||
|
||||
Der Pfad-Builder (Phase C3/E) ist **retrieval-first**: Zieltext → N Übungen aus der Bibliothek → QS nachbessern. Das entspricht nicht der menschlichen Planung (Ziel → Roadmap → Stufenspezifikation → Übung).
|
||||
|
||||
### 1.2 Festlegung
|
||||
|
||||
| Thema | Entscheidung |
|
||||
|--------|----------------|
|
||||
| **Progressionsgraph** | **Roadmap-first** — Phasen A→B→C, dann Bibliothek (D), dann Feinausplanung (E) |
|
||||
| **Gruppenanalyse** | **Nicht** in der Graphen-Pipeline — erst bei **Trainingsplanung** (Einheit/Rahmen) |
|
||||
| **Mitai Workflow-Engine** | **Nicht** jetzt portieren — **Workflow-lite** (`PlanningProgressionPipeline`), später workflow-ready |
|
||||
| **Ein Mega-Prompt** | **Verboten** — validierte Artefakte pro Phase |
|
||||
|
||||
### 1.3 Abgrenzung Trainingsplanung
|
||||
|
||||
```
|
||||
Progressionsgraph-Pipeline Trainingsplanungs-Pipeline (später)
|
||||
───────────────────────── ───────────────────────────────────
|
||||
Ziel + N Major Steps Gruppe + Historie + Termin + Rahmen
|
||||
Kein Gruppenkontext Kontext-Pack S0 (AI_PLANNING_KI_MULTISTAGE_FORECAST)
|
||||
Curriculum / Technikpfad Session-Füllung / Reihenfolge / Zeiten
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Menschliches Vorbild → Phasen
|
||||
|
||||
| Mensch | Phase | Output-Artefakt | LLM |
|
||||
|--------|-------|-----------------|-----|
|
||||
| Startpunkt + Zielzustand | **A** Zielanalyse | `goal_analysis` | Optional (klein) |
|
||||
| Zwischenziele, gewichten, auf N reduzieren | **B** Roadmap | `roadmap` (`micro_objectives[]`, `major_steps[N]`) | Ja |
|
||||
| Belastung, Übungstyp, Lernziel je Stufe | **C** Stufenspezifikation | `stage_specs[]` | Teilweise |
|
||||
| Bibliothek / Brücke | **D** Match | `step_matches[]` oder `gaps[]` | Nein (Retrieval) |
|
||||
| Skizze + Feinplan | **E** Übungsentwurf | bestehend `suggestExerciseAi` | On-demand |
|
||||
|
||||
**Phase B** = Kern: 8–12 `micro_objectives` → Konsolidierung → exakt `max_steps` `major_steps`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Pipeline-Orchestrator (Workflow-lite)
|
||||
|
||||
Modul: **`backend/planning_progression_roadmap.py`**
|
||||
|
||||
```python
|
||||
ctx = ProgressionRoadmapContext(goal_query=..., max_steps=N, semantic_brief=...)
|
||||
ctx = phase_a_goal_analysis(ctx) # deterministisch + optional LLM
|
||||
ctx = phase_b_roadmap(ctx) # micro → major
|
||||
ctx = phase_c_stage_specs(ctx) # je major_step
|
||||
# Phase D/E: bestehende path_builder / retrieval / ai_fill — speisen von ctx.major_steps
|
||||
```
|
||||
|
||||
Jede Phase: `(ctx) → ctx`, Zwischenergebnisse in API-Response für **Human-in-the-loop** (Roadmap-Review vor Übungs-Match).
|
||||
|
||||
**Später:** jede Phase = Workflow-Knoten (Mitai-kompatibel), keine API-Änderung an Artefakten.
|
||||
|
||||
---
|
||||
|
||||
## 4. JSON-Artefakte (Pydantic)
|
||||
|
||||
### 4.1 `goal_analysis` (Phase A)
|
||||
|
||||
```json
|
||||
{
|
||||
"primary_topic": "Mae Geri",
|
||||
"start_assumption": "Grundkenntnisse der Standführung, keine Perfektion",
|
||||
"target_state": "Sicherer, präziser Mae Geri unter Belastung und in Anwendung",
|
||||
"success_criteria": ["saubere Kammerhaltung", "Hüftführung", "Kime am Zielpunkt"],
|
||||
"constraints": { "partner_required": false, "equipment": [] }
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 `roadmap` (Phase B)
|
||||
|
||||
```json
|
||||
{
|
||||
"micro_objectives": [
|
||||
{ "id": "m1", "phase": "grundlage", "title": "Stellung und Kammerhaltung", "weight": 0.9, "depends_on": [] },
|
||||
{ "id": "m2", "phase": "vertiefung", "title": "Hüft- und Kniekoordination", "weight": 0.85, "depends_on": ["m1"] }
|
||||
],
|
||||
"major_steps": [
|
||||
{
|
||||
"index": 0,
|
||||
"phase": "grundlage",
|
||||
"learning_goal": "Stabile Mae-Geri-Grundstellung",
|
||||
"consolidates": ["m1"],
|
||||
"rationale": "Einstieg ohne Perfektionsdruck"
|
||||
}
|
||||
],
|
||||
"consolidation_notes": ["Perfektion mit Anwendung zusammengeführt"]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 `stage_spec` (Phase C, je Major Step)
|
||||
|
||||
```json
|
||||
{
|
||||
"major_step_index": 2,
|
||||
"learning_goal": "…",
|
||||
"load_profile": ["präzision", "koordination"],
|
||||
"exercise_type": "kihon_einzel",
|
||||
"success_criteria": ["…"],
|
||||
"anti_patterns": ["reine Kraftübung ohne Technikbezug"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. API (schrittweise)
|
||||
|
||||
### 5.1 Erweiterung `POST /api/planning/progression-path-suggest`
|
||||
|
||||
| Feld (neu) | Default | Bedeutung |
|
||||
|------------|---------|-----------|
|
||||
| `roadmap_first` | `false` → später `true` | Roadmap-Pipeline vor Retrieval |
|
||||
| `include_roadmap_preview` | `true` wenn `roadmap_first` | Artefakte A/B/C in Response |
|
||||
|
||||
**Response (neu):**
|
||||
|
||||
```json
|
||||
{
|
||||
"progression_roadmap": {
|
||||
"goal_analysis": { },
|
||||
"roadmap": { },
|
||||
"stage_specs": [ ],
|
||||
"pipeline_phase": "roadmap_v1"
|
||||
},
|
||||
"steps": [ ]
|
||||
}
|
||||
```
|
||||
|
||||
**Übergangsphase (0.8.204):** `include_roadmap_preview=true` liefert Roadmap **parallel** zum bestehenden retrieval-first Pfad — UI kann Roadmap reviewen, Schritte bleiben vorerst retrieval-basiert.
|
||||
|
||||
**Zielphase (F2):** `roadmap_first=true` — Retrieval pro Major Step aus `stage_specs`, nicht mehr iterativ „beste nächste Übung“.
|
||||
|
||||
### 5.2 Prompt-Slugs — nur in `ai_prompts`, nie im Code
|
||||
|
||||
**Regel:** Prompt-**Texte** leben ausschließlich in der Tabelle `ai_prompts` (Superadmin bearbeitbar, Vorschau, `openrouter_model` pro Zeile). Python referenziert nur **Slugs** (`PROMPT_SLUG_*` in `planning_progression_roadmap.py`). Kein verstecktes Hardcoding von Templates.
|
||||
|
||||
| Slug | Phase | Migration |
|
||||
|------|-------|-----------|
|
||||
| `planning_progression_goal_analysis` | A | **078** |
|
||||
| `planning_progression_roadmap` | B | **078** |
|
||||
| `planning_progression_stage_spec` | C | **079** |
|
||||
|
||||
**API:** `include_llm_roadmap` (Default `true`) — lädt Prompts via `load_and_render_ai_prompt`. Bei Fehler/kein OpenRouter: **deterministischer Fallback** (kein stilles Versagen).
|
||||
|
||||
**Response:** `prompt_slugs` (genutzte Slugs), `prompt_slug_catalog` (Referenz), `llm_*_applied` Flags.
|
||||
|
||||
**Admin:** Templates unter Kategorie `training` pflegen — siehe `AI_PROMPT_SYSTEM_SPEC.md`.
|
||||
|
||||
---
|
||||
|
||||
## 6. UI-Roadmap
|
||||
|
||||
1. **F1:** Roadmap-Box unter Ziel-Eingabe (Major Steps als Karten, editierbar) — vor Übungsliste
|
||||
2. **F2:** Match-Ergebnis pro Major Step (Bibliothek / Lücke / KI anlegen)
|
||||
3. **F3:** `roadmap_first` als Default im Graph-Builder
|
||||
|
||||
---
|
||||
|
||||
## 7. Was bewusst nicht in Phase F
|
||||
|
||||
- Gruppen-Historie, Belastungssteuerung der Gruppe
|
||||
- Mitai `workflow_engine` Port
|
||||
- Vollautomatisches Speichern ohne Trainer-Review
|
||||
|
||||
---
|
||||
|
||||
## 8. Implementierungsstände
|
||||
|
||||
| ID | Inhalt | Status |
|
||||
|----|--------|--------|
|
||||
| **F0** | Spec + Doku + `planning_progression_roadmap.py` Scaffold | 🔄 0.8.204 |
|
||||
| **F1** | `include_roadmap_preview` in API + deterministische A/B | 🔄 0.8.204 |
|
||||
| **F2** | LLM Phase A/B/C über `ai_prompts` (078/079), `include_llm_roadmap` | 🔄 0.8.205 |
|
||||
| **F3** | Retrieval aus `stage_specs` (roadmap_first) | ✅ 0.8.206 |
|
||||
| **F4** | UI Roadmap-Review | ✅ 0.8.207 |
|
||||
| **F5** | Trainingsplanung: eigene Pipeline + ggf. Workflow-Engine | 🔲 |
|
||||
|
||||
---
|
||||
|
||||
## 9. Changelog
|
||||
|
||||
- **2026-06-07:** Erstfassung — Roadmap-first Entscheidung, Abgrenzung Graphen vs. Planung, Workflow-lite.
|
||||
|
|
@ -34,6 +34,10 @@ DB_PASSWORD=CHANGE_ME_SECURE_PASSWORD
|
|||
|
||||
OPENROUTER_API_KEY=your_api_key_here
|
||||
OPENROUTER_MODEL=anthropic/claude-sonnet-4
|
||||
|
||||
# Vereins-Kontingente hart blockieren (KI-Kosten!). Nur 1, true oder yes aktivieren.
|
||||
# Nach Änderung: docker compose -f docker-compose.dev-env.yml up -d backend
|
||||
CLUB_FEATURE_ENFORCE=1
|
||||
# Standard-OpenRouter-Modell (alle Aufrufe). Optional pro Prompt in ai_prompts.openrouter_model
|
||||
# ueberschreibbar (Migration 070, Superadmin unter „KI Prompts“).
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ jobs:
|
|||
docker compose -f docker-compose.dev-env.yml build --no-cache
|
||||
docker compose -f docker-compose.dev-env.yml up -d
|
||||
sleep 5
|
||||
curl -sf http://localhost:8098/api/version && echo "✓ DEV API healthy"
|
||||
if ! curl -sf http://localhost:8098/api/version; then
|
||||
echo "✗ DEV API nicht erreichbar — Backend-Logs (Migration/Startup):"
|
||||
docker compose -f docker-compose.dev-env.yml logs backend --tail 120 || true
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ DEV API healthy"
|
||||
curl -sf http://localhost:3098/api/version && echo "✓ DEV über Frontend-Nginx (wie Browser) healthy"
|
||||
echo "=== Shinkan DEV Deploy complete ==="
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
name: Test Suite
|
||||
|
||||
# develop: push/PR → Tests gegen Dev (parallel oder vor Deploy Development).
|
||||
# main: kein push/PR-Trigger — vermeidet doppelten Dev-Lauf beim Merge develop→main;
|
||||
# Prod-Tests nur via workflow_run nach erfolgreichem Deploy Production.
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
branches: [develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
branches: [develop]
|
||||
workflow_run:
|
||||
workflows: ["Deploy Development", "Deploy Production"]
|
||||
types: [completed]
|
||||
|
|
@ -17,8 +20,10 @@ jobs:
|
|||
steps:
|
||||
- name: Backend pytest im deployten Container
|
||||
run: |
|
||||
set -e
|
||||
EVENT_NAME="${{ github.event_name }}"
|
||||
REF_NAME="${{ github.ref_name }}"
|
||||
BASE_REF="${{ github.base_ref }}"
|
||||
RUN_WORKFLOW="${{ github.event.workflow_run.name }}"
|
||||
APP_DIR="/home/lars/docker/shinkan"
|
||||
COMPOSE_FILE="docker-compose.yml"
|
||||
|
|
@ -28,12 +33,27 @@ jobs:
|
|||
APP_DIR="/home/lars/docker/shinkan-dev"
|
||||
COMPOSE_FILE="docker-compose.dev-env.yml"
|
||||
fi
|
||||
elif [ "$REF_NAME" = "develop" ]; then
|
||||
elif [ "$REF_NAME" = "develop" ] || [ "$BASE_REF" = "develop" ]; then
|
||||
APP_DIR="/home/lars/docker/shinkan-dev"
|
||||
COMPOSE_FILE="docker-compose.dev-env.yml"
|
||||
fi
|
||||
|
||||
cd "$APP_DIR"
|
||||
echo "Warte auf stabilen backend-Container …"
|
||||
for i in $(seq 1 60); do
|
||||
if docker compose -f "$COMPOSE_FILE" exec -T backend true 2>/dev/null; then
|
||||
echo "Backend bereit (Versuch $i)"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 60 ]; then
|
||||
echo "Timeout: backend-Container nicht bereit"
|
||||
docker compose -f "$COMPOSE_FILE" ps || true
|
||||
docker compose -f "$COMPOSE_FILE" logs backend --tail 80 || true
|
||||
exit 1
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
docker compose -f "$COMPOSE_FILE" exec -T backend sh -lc "
|
||||
pip install -r /app/requirements-dev.txt &&
|
||||
cd /app &&
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
> | Architektur-Zielbild, Refaktor-Roadmap, verbindliche Shinkan-Regeln | **`docs/architecture/README.md`** |
|
||||
> | Performance-Baseline (Phase 0) | **`docs/architecture/BASELINE_SNAPSHOT.md`** |
|
||||
> | KI-Prompt-System — Zielarchitektur | `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` |
|
||||
> | Planungs-KI Progressions-Roadmap (Phase F) | **`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`** · **`docs/architecture/PLANNING_KI_ROADMAP.md`** |
|
||||
|
||||
## Projekt-Übersicht
|
||||
|
||||
|
|
|
|||
77
backend/account_lifecycle.py
Normal file
77
backend/account_lifecycle.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""
|
||||
Account-Lifecycle (CAPABILITY_CATALOG.v1.md §3, M3 C0).
|
||||
|
||||
Zustände: unverified → verified_pending_club → active_member; platform_admin separat.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from club_tenancy import is_platform_admin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tenant_context import TenantContext
|
||||
|
||||
_ACCOUNT_STATE_RANK = {
|
||||
"unverified": 1,
|
||||
"verified_pending_club": 2,
|
||||
"active_member": 3,
|
||||
"platform_admin": 4,
|
||||
}
|
||||
|
||||
|
||||
def resolve_account_state(
|
||||
*,
|
||||
email_verified: bool,
|
||||
global_role: str,
|
||||
has_active_membership: bool,
|
||||
) -> str:
|
||||
"""Ermittelt account_state für ein Profil."""
|
||||
if is_platform_admin(global_role):
|
||||
return "platform_admin"
|
||||
if not email_verified:
|
||||
return "unverified"
|
||||
if not has_active_membership:
|
||||
return "verified_pending_club"
|
||||
return "active_member"
|
||||
|
||||
|
||||
def account_state_satisfies(current: str, required: str) -> bool:
|
||||
"""True wenn current mindestens required ist."""
|
||||
cur = _ACCOUNT_STATE_RANK.get(current, 0)
|
||||
req = _ACCOUNT_STATE_RANK.get(required, 99)
|
||||
if current == "platform_admin":
|
||||
return True
|
||||
return cur >= req
|
||||
|
||||
|
||||
def account_gate_enforcement_enabled() -> bool:
|
||||
"""Account-Gates aktiv (Default an — nur wenige Endpoints in M3)."""
|
||||
return os.getenv("ACCOUNT_GATE_ENFORCE", "1").strip() == "1"
|
||||
|
||||
|
||||
def assert_min_account_state(
|
||||
tenant: "TenantContext",
|
||||
min_state: str,
|
||||
*,
|
||||
endpoint: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Prüft Mindest-Account-Status. Wirft 403 wenn ACCOUNT_GATE_ENFORCE=1 (Default).
|
||||
"""
|
||||
current = getattr(tenant, "account_state", "active_member")
|
||||
ok = account_state_satisfies(current, min_state)
|
||||
if ok:
|
||||
return
|
||||
if not account_gate_enforcement_enabled():
|
||||
return
|
||||
detail = (
|
||||
f"Account-Status „{current}“ reicht nicht für diese Aktion "
|
||||
f"(erforderlich: {min_state})."
|
||||
)
|
||||
if endpoint:
|
||||
detail = f"{detail} ({endpoint})"
|
||||
raise HTTPException(status_code=403, detail=detail)
|
||||
178
backend/account_onboarding_gate.py
Normal file
178
backend/account_onboarding_gate.py
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
"""
|
||||
API-Gates für Onboarding (Phase A — MEMBERSHIP_RBAC_DECISIONS_2026-06.md §1.1).
|
||||
|
||||
Blockiert Domänen-APIs für unverified / verified_pending_club vor dem Router.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from account_lifecycle import resolve_account_state
|
||||
from club_tenancy import memberships_with_roles
|
||||
|
||||
# Öffentlich ohne Session
|
||||
PUBLIC_API_PREFIXES = (
|
||||
"/api/auth/login",
|
||||
"/api/auth/register",
|
||||
"/api/auth/forgot-password",
|
||||
"/api/auth/reset-password",
|
||||
"/api/auth/verify/",
|
||||
"/api/legal-documents/",
|
||||
"/api/clubs/public-directory",
|
||||
"/api/version",
|
||||
"/api/health/",
|
||||
"/health",
|
||||
)
|
||||
|
||||
# Mit Session, unabhängig vom account_state (Logout, Profil lesen, …)
|
||||
AUTH_INFRA_PREFIXES = (
|
||||
"/api/auth/logout",
|
||||
"/api/auth/me",
|
||||
"/api/auth/status",
|
||||
"/api/auth/pin",
|
||||
"/api/auth/resend-verification",
|
||||
"/api/profiles/me",
|
||||
"/api/me/entitlements",
|
||||
)
|
||||
|
||||
# Zusätzlich für verified_pending_club (Verein bewerben)
|
||||
PENDING_CLUB_PREFIXES = (
|
||||
"/api/me/club-join-requests",
|
||||
"/api/me/club-creation-requests",
|
||||
)
|
||||
|
||||
_PROFILE_MUTATION_RE = re.compile(r"^/api/profiles/(\d+)$")
|
||||
|
||||
|
||||
def api_onboarding_gate_enabled() -> bool:
|
||||
"""Produktions-Gate aktiv (ACCOUNT_GATE_API_ENFORCE=0 zum Abschalten)."""
|
||||
return os.getenv("ACCOUNT_GATE_API_ENFORCE", "1").strip() == "1"
|
||||
|
||||
|
||||
def _middleware_db_lookup_enabled() -> bool:
|
||||
"""
|
||||
Middleware-Session-Lookup nur mit echter DB (nicht in pytest TestClient ohne Postgres).
|
||||
"""
|
||||
if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() in ("1", "true", "yes"):
|
||||
return False
|
||||
if os.getenv("PYTEST_CURRENT_TEST"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def normalize_api_path(path: str) -> str:
|
||||
p = (path or "").split("?", 1)[0].strip()
|
||||
if not p.startswith("/"):
|
||||
p = "/" + p
|
||||
if len(p) > 1 and p.endswith("/"):
|
||||
p = p[:-1]
|
||||
return p
|
||||
|
||||
|
||||
def is_public_api_path(path: str) -> bool:
|
||||
p = normalize_api_path(path)
|
||||
return any(p == pref or p.startswith(pref) for pref in PUBLIC_API_PREFIXES)
|
||||
|
||||
|
||||
def _path_allowed_for_state(path: str, method: str, account_state: str, profile_id: int) -> bool:
|
||||
p = normalize_api_path(path)
|
||||
m = (method or "GET").upper()
|
||||
|
||||
for pref in AUTH_INFRA_PREFIXES:
|
||||
if p == pref or p.startswith(pref + "/"):
|
||||
return True
|
||||
|
||||
match = _PROFILE_MUTATION_RE.match(p)
|
||||
if match and m in ("PUT", "PATCH") and int(match.group(1)) == int(profile_id):
|
||||
return True
|
||||
|
||||
if account_state == "unverified":
|
||||
return False
|
||||
|
||||
if account_state == "verified_pending_club":
|
||||
for pref in PENDING_CLUB_PREFIXES:
|
||||
if p == pref or p.startswith(pref + "/"):
|
||||
return True
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def resolve_account_state_for_token(cur, session_row: dict) -> str:
|
||||
profile_id = int(session_row["profile_id"])
|
||||
role = (session_row.get("role") or "").lower()
|
||||
cur.execute(
|
||||
"SELECT COALESCE(email_verified, false) AS email_verified FROM profiles WHERE id = %s",
|
||||
(profile_id,),
|
||||
)
|
||||
prof = cur.fetchone()
|
||||
email_verified = bool(prof.get("email_verified")) if prof else False
|
||||
memberships = memberships_with_roles(cur, profile_id, active_only=True)
|
||||
has_active = len(memberships) > 0
|
||||
return resolve_account_state(
|
||||
email_verified=email_verified,
|
||||
global_role=role,
|
||||
has_active_membership=has_active,
|
||||
)
|
||||
|
||||
|
||||
def check_api_onboarding_gate(
|
||||
*,
|
||||
path: str,
|
||||
method: str,
|
||||
profile_id: int,
|
||||
account_state: str,
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Returns (allowed, reason).
|
||||
active_member / platform_admin → immer erlaubt (Domain).
|
||||
"""
|
||||
if not api_onboarding_gate_enabled():
|
||||
return True, None
|
||||
|
||||
if account_state in ("active_member", "platform_admin"):
|
||||
return True, None
|
||||
|
||||
if _path_allowed_for_state(path, method, account_state, profile_id):
|
||||
return True, None
|
||||
|
||||
return False, f"account_state_{account_state}"
|
||||
|
||||
|
||||
def evaluate_request_gate(token: Optional[str], path: str, method: str) -> Tuple[bool, Optional[str], Optional[str]]:
|
||||
"""
|
||||
Vollständige Prüfung inkl. Session-Lookup.
|
||||
Returns: allowed, reason, account_state (für Logging)
|
||||
"""
|
||||
if not api_onboarding_gate_enabled() or not _middleware_db_lookup_enabled():
|
||||
return True, None, None
|
||||
|
||||
p = normalize_api_path(path)
|
||||
if not p.startswith("/api/"):
|
||||
return True, None, None
|
||||
if is_public_api_path(p):
|
||||
return True, None, None
|
||||
if not token:
|
||||
return True, None, None
|
||||
|
||||
from auth import get_session
|
||||
from db import get_db, get_cursor
|
||||
|
||||
session = get_session(token)
|
||||
if not session:
|
||||
return True, None, None
|
||||
|
||||
profile_id = int(session["profile_id"])
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
account_state = resolve_account_state_for_token(cur, session)
|
||||
|
||||
allowed, reason = check_api_onboarding_gate(
|
||||
path=p,
|
||||
method=method,
|
||||
profile_id=profile_id,
|
||||
account_state=account_state,
|
||||
)
|
||||
return allowed, reason, account_state
|
||||
|
|
@ -5,7 +5,7 @@ Keine Imports aus exercise_ai — vermeidet Zirkelimporte mit ai_prompt_job / ex
|
|||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional, Sequence, Tuple
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
|
@ -31,6 +31,7 @@ class ExerciseFormAiPromptContext(BaseModel):
|
|||
trainer_notes: Optional[str] = None
|
||||
focus_hint: Optional[str] = None
|
||||
focus_areas_context: Optional[List[ExerciseFormAiFocusRow]] = None
|
||||
planning_context: Optional[Dict[str, Any]] = None
|
||||
|
||||
def focus_area_tuples(self) -> Optional[List[Tuple[int, bool]]]:
|
||||
if not self.focus_areas_context:
|
||||
|
|
@ -57,6 +58,7 @@ class ExerciseFormAiPromptContext(BaseModel):
|
|||
trainer_notes: Optional[str] = None,
|
||||
focus_area_hint: Optional[str] = None,
|
||||
focus_areas_context: Optional[Sequence[ExerciseFormAiFocusRow]] = None,
|
||||
planning_context: Optional[Dict[str, Any]] = None,
|
||||
) -> ExerciseFormAiPromptContext:
|
||||
"""Mappt Felder aus POST /exercises/ai/suggest (focus_area_hint → focus_hint)."""
|
||||
hint = (focus_area_hint or "").strip() or None
|
||||
|
|
@ -68,6 +70,7 @@ class ExerciseFormAiPromptContext(BaseModel):
|
|||
trainer_notes=trainer_notes,
|
||||
focus_hint=hint,
|
||||
focus_areas_context=list(focus_areas_context) if focus_areas_context else None,
|
||||
planning_context=dict(planning_context) if planning_context else None,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ def resolve_exercise_form_variables(cur, slug: str, ctx: ExerciseFormAiPromptCon
|
|||
focus_areas_context=ctx.focus_area_tuples(),
|
||||
preparation=ctx.preparation,
|
||||
trainer_notes=ctx.trainer_notes,
|
||||
planning_context=ctx.planning_context,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -170,6 +170,10 @@ def get_effective_tier(profile_id: str, conn=None) -> str:
|
|||
|
||||
def check_feature_access(profile_id: str, feature_id: str, conn=None) -> dict:
|
||||
"""
|
||||
DEPRECATED für Shinkan: Mitai-v9c-Profil-Limits — Schema 001 ist archiviert (Migration 078).
|
||||
|
||||
Für Vereins-Kontingente: club_features.check_club_feature_access(club_id, feature_id).
|
||||
|
||||
Check if a profile has access to a feature.
|
||||
|
||||
Access hierarchy:
|
||||
|
|
@ -315,6 +319,8 @@ def _check_impl(profile_id: str, feature_id: str, conn) -> dict:
|
|||
|
||||
def increment_feature_usage(profile_id: str, feature_id: str) -> None:
|
||||
"""
|
||||
DEPRECATED für Shinkan — siehe club_features.increment_club_feature_usage.
|
||||
|
||||
Increment usage counter for a feature.
|
||||
|
||||
Creates usage record if it doesn't exist, with reset_at based on
|
||||
|
|
|
|||
285
backend/capabilities.py
Normal file
285
backend/capabilities.py
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
"""
|
||||
Capability-Auflösung (CAPABILITY_CATALOG.v1.md, M3 C1).
|
||||
|
||||
Phase 2: probe_capability — JSON-Log, kein Block (CAPABILITY_ENFORCE=0).
|
||||
Phase 3+: CAPABILITY_ENFORCE=1 — HTTP 403 bei fehlender Capability.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from account_lifecycle import account_state_satisfies
|
||||
from club_tenancy import is_platform_admin
|
||||
from db import get_db, get_cursor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tenant_context import TenantContext
|
||||
|
||||
|
||||
def capability_enforcement_enabled() -> bool:
|
||||
v = os.getenv("CAPABILITY_ENFORCE", "0").strip().lower()
|
||||
return v in ("1", "true", "yes")
|
||||
|
||||
|
||||
def club_roles_in_club(tenant: "TenantContext", club_id: Optional[int]) -> List[str]:
|
||||
if club_id is None:
|
||||
return []
|
||||
for m in tenant.memberships or []:
|
||||
if int(m.get("id") or 0) == int(club_id):
|
||||
roles = m.get("roles") or []
|
||||
if hasattr(roles, "tolist"):
|
||||
roles = roles.tolist()
|
||||
return list(roles)
|
||||
return []
|
||||
|
||||
|
||||
def check_capability(
|
||||
cur,
|
||||
tenant: "TenantContext",
|
||||
capability_id: str,
|
||||
*,
|
||||
club_id: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Prüft eine Capability für Tenant + optionalen Vereinskontext.
|
||||
|
||||
Returns: allowed, reason, account_state, club_roles, linked_feature_id
|
||||
"""
|
||||
account_state = getattr(tenant, "account_state", "active_member")
|
||||
eff_club = club_id if club_id is not None else tenant.effective_club_id
|
||||
club_roles = club_roles_in_club(tenant, eff_club) if eff_club is not None else []
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, min_account_state, linked_feature_id, active, domain
|
||||
FROM capabilities
|
||||
WHERE id = %s
|
||||
""",
|
||||
(capability_id,),
|
||||
)
|
||||
cap = cur.fetchone()
|
||||
if not cap or not cap.get("active"):
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": "capability_not_found",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": None,
|
||||
}
|
||||
|
||||
min_state = cap.get("min_account_state") or "active_member"
|
||||
if not account_state_satisfies(account_state, min_state):
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": "account_state_insufficient",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
|
||||
domain = (cap.get("domain") or "").strip().lower()
|
||||
|
||||
# Kontingent-Bypass (konfigurierbar per portal_role / profile grants, ohne Plattform-Admin-Pflicht)
|
||||
if domain == "quota_bypass":
|
||||
role_lc = (tenant.global_role or "").lower()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM portal_role_capability_grants
|
||||
WHERE portal_role = %s AND capability_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(role_lc, capability_id),
|
||||
)
|
||||
if cur.fetchone():
|
||||
return {
|
||||
"allowed": True,
|
||||
"reason": "quota_bypass_portal_grant",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM profile_capability_grants
|
||||
WHERE profile_id = %s AND capability_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(tenant.profile_id, capability_id),
|
||||
)
|
||||
if cur.fetchone():
|
||||
return {
|
||||
"allowed": True,
|
||||
"reason": "quota_bypass_profile_grant",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": "quota_bypass_denied",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
|
||||
# Plattform-Capabilities
|
||||
if domain == "platform" or capability_id.startswith("platform."):
|
||||
role_lc = (tenant.global_role or "").lower()
|
||||
if not is_platform_admin(role_lc):
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": "portal_role_required",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM portal_role_capability_grants
|
||||
WHERE portal_role = %s AND capability_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(role_lc, capability_id),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM profile_capability_grants
|
||||
WHERE profile_id = %s AND capability_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(tenant.profile_id, capability_id),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": "portal_capability_denied",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
return {
|
||||
"allowed": True,
|
||||
"reason": "portal_granted",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
|
||||
# Plattform-Admin-Bypass für Mandanten-Funktionen (Audit-Pflicht, s. Katalog §9)
|
||||
if is_platform_admin(tenant.global_role):
|
||||
return {
|
||||
"allowed": True,
|
||||
"reason": "platform_admin_bypass",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
|
||||
# Vereins-Capabilities: aktive Mitgliedschaft im Zielverein
|
||||
if min_state == "active_member":
|
||||
if eff_club is None:
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": "no_club_context",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
if eff_club not in tenant.club_ids:
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": "not_club_member",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT role_code FROM club_role_capability_grants
|
||||
WHERE capability_id = %s
|
||||
""",
|
||||
(capability_id,),
|
||||
)
|
||||
required_roles = [r["role_code"] for r in cur.fetchall()]
|
||||
|
||||
if required_roles:
|
||||
if not any(r in required_roles for r in club_roles):
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": "club_role_denied",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
elif min_state == "active_member" and eff_club is not None:
|
||||
# Offene Capability für alle aktiven Mitglieder — Mitgliedschaft reicht
|
||||
pass
|
||||
|
||||
return {
|
||||
"allowed": True,
|
||||
"reason": "granted",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
|
||||
|
||||
def resolve_capabilities_map(
|
||||
cur,
|
||||
tenant: "TenantContext",
|
||||
*,
|
||||
club_id: Optional[int] = None,
|
||||
) -> Dict[str, bool]:
|
||||
"""Alle aktiven Capabilities → bool (für späteres /me/entitlements)."""
|
||||
cur.execute("SELECT id FROM capabilities WHERE active = true ORDER BY id")
|
||||
ids = [r["id"] for r in cur.fetchall()]
|
||||
out: Dict[str, bool] = {}
|
||||
for cid in ids:
|
||||
res = check_capability(cur, tenant, cid, club_id=club_id)
|
||||
out[cid] = bool(res.get("allowed"))
|
||||
return out
|
||||
|
||||
|
||||
def probe_capability(
|
||||
tenant: "TenantContext",
|
||||
capability_id: str,
|
||||
*,
|
||||
action: str,
|
||||
club_id: Optional[int] = None,
|
||||
endpoint: Optional[str] = None,
|
||||
conn=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Phase 2: Capability prüfen + JSON-Log; blockiert nur bei CAPABILITY_ENFORCE=1."""
|
||||
from capability_logger import log_capability_check
|
||||
|
||||
def _run(c):
|
||||
cur = get_cursor(c)
|
||||
result = check_capability(cur, tenant, capability_id, club_id=club_id)
|
||||
log_capability_check(
|
||||
club_id=club_id if club_id is not None else tenant.effective_club_id,
|
||||
profile_id=tenant.profile_id,
|
||||
capability_id=capability_id,
|
||||
action=action,
|
||||
result=result,
|
||||
endpoint=endpoint,
|
||||
phase="enforce" if capability_enforcement_enabled() else "probe",
|
||||
)
|
||||
if capability_enforcement_enabled() and not result.get("allowed"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=(
|
||||
f"Keine Berechtigung für {capability_id} "
|
||||
f"({result.get('reason', 'denied')})."
|
||||
),
|
||||
)
|
||||
return result
|
||||
|
||||
if conn is not None:
|
||||
return _run(conn)
|
||||
with get_db() as c:
|
||||
return _run(c)
|
||||
94
backend/capability_enforcement_audit.py
Normal file
94
backend/capability_enforcement_audit.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""
|
||||
Audit: Welche Capabilities sind an Endpoints angebunden?
|
||||
|
||||
Für Admin-Matrix (Rollen & Rechte) und Roadmap — bei neuem probe_capability hier eintragen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
# Endpoints rufen probe_capability auf (Log; Block nur bei CAPABILITY_ENFORCE=1)
|
||||
WIRED_PROBE = frozenset(
|
||||
{
|
||||
"exercises.ai.suggest",
|
||||
"exercises.ai.regenerate",
|
||||
"exercises.create",
|
||||
"exercises.media.upload",
|
||||
"planning.ai.suggest",
|
||||
"planning.ai.progression_path",
|
||||
"club.creation_request.read_own",
|
||||
"club.creation_request.create",
|
||||
"club.creation_request.withdraw",
|
||||
"platform.club_creation.approve",
|
||||
}
|
||||
)
|
||||
|
||||
# Kontingent-Verbrauch nach Erfolg (consume_club_feature_with_usage)
|
||||
FEATURE_CONSUME_WIRED = frozenset(
|
||||
{
|
||||
"ai_calls",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def enforcement_status_for_capability(capability_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Anzeige-Status für Superadmin-Matrix.
|
||||
|
||||
level: probe | legacy | platform | open | none
|
||||
"""
|
||||
cid = (capability_id or "").strip()
|
||||
if cid in WIRED_PROBE:
|
||||
return {
|
||||
"level": "probe",
|
||||
"label": "API vorbereitet (Log)",
|
||||
"detail": "probe_capability am Endpoint; Hard-Block erst mit CAPABILITY_ENFORCE=1",
|
||||
"implemented": True,
|
||||
}
|
||||
if cid.startswith("platform."):
|
||||
if cid == "platform.admin.access":
|
||||
return {
|
||||
"level": "platform",
|
||||
"label": "Plattform (Router-Guard)",
|
||||
"detail": "RequireAdmin / Superadmin-Checks",
|
||||
"implemented": True,
|
||||
}
|
||||
if cid in WIRED_PROBE:
|
||||
pass
|
||||
return {
|
||||
"level": "platform",
|
||||
"label": "Plattform (teilweise)",
|
||||
"detail": "Meist Router-Guard; Capability-Probe nur wo eingetragen",
|
||||
"implemented": cid in WIRED_PROBE,
|
||||
}
|
||||
if cid.startswith("club."):
|
||||
return {
|
||||
"level": "open",
|
||||
"label": "Onboarding",
|
||||
"detail": "Account-State / eigene Flows",
|
||||
"implemented": cid in WIRED_PROBE,
|
||||
}
|
||||
# Vereins-Capabilities ohne Probe: Legacy club_tenancy (can_plan_in_club, has_club_role, …)
|
||||
return {
|
||||
"level": "legacy",
|
||||
"label": "Nur Legacy-Rollen",
|
||||
"detail": "Noch kein probe_capability — prüft can_plan_in_club / club_admin im Code",
|
||||
"implemented": False,
|
||||
}
|
||||
|
||||
|
||||
def feature_consume_status(feature_id: str) -> Dict[str, Any]:
|
||||
fid = (feature_id or "").strip()
|
||||
if fid in FEATURE_CONSUME_WIRED:
|
||||
return {
|
||||
"level": "consume",
|
||||
"label": "Verbrauch aktiv",
|
||||
"detail": "consume_club_feature_with_usage + feature_usage in Response",
|
||||
"implemented": True,
|
||||
}
|
||||
return {
|
||||
"level": "inventory",
|
||||
"label": "Bestand / Probe",
|
||||
"detail": "Probe oder Live-Zählung; kein Consume nach Aktion",
|
||||
"implemented": False,
|
||||
}
|
||||
64
backend/capability_logger.py
Normal file
64
backend/capability_logger.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""
|
||||
JSON-Log für Capability-Checks (M3 Phase 2 — analog club_feature_logger).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
def _log_dir() -> Path:
|
||||
custom = (os.getenv("CAPABILITY_LOG_DIR") or "").strip()
|
||||
if custom:
|
||||
return Path(custom)
|
||||
return Path("/app/logs")
|
||||
|
||||
|
||||
capability_logger = logging.getLogger("shinkan.capability_usage")
|
||||
capability_logger.setLevel(logging.INFO)
|
||||
capability_logger.propagate = False
|
||||
|
||||
if not capability_logger.handlers:
|
||||
log_dir = _log_dir()
|
||||
try:
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_file = log_dir / "capability-usage.log"
|
||||
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
capability_logger.addHandler(file_handler)
|
||||
except OSError:
|
||||
stream_handler = logging.StreamHandler()
|
||||
stream_handler.setFormatter(logging.Formatter("[capability-usage] %(message)s"))
|
||||
capability_logger.addHandler(stream_handler)
|
||||
|
||||
|
||||
def log_capability_check(
|
||||
*,
|
||||
club_id: Optional[int],
|
||||
profile_id: Optional[int],
|
||||
capability_id: str,
|
||||
action: str,
|
||||
result: Dict[str, Any],
|
||||
endpoint: Optional[str] = None,
|
||||
phase: str = "probe",
|
||||
) -> None:
|
||||
entry = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"club_id": club_id,
|
||||
"profile_id": profile_id,
|
||||
"capability": capability_id,
|
||||
"action": action,
|
||||
"endpoint": endpoint,
|
||||
"phase": phase,
|
||||
"allowed": result.get("allowed", True),
|
||||
"reason": result.get("reason", "unknown"),
|
||||
"account_state": result.get("account_state"),
|
||||
"club_roles": result.get("club_roles"),
|
||||
"enforcement": os.getenv("CAPABILITY_ENFORCE", "0") == "1",
|
||||
}
|
||||
capability_logger.info(json.dumps(entry, ensure_ascii=False))
|
||||
74
backend/club_feature_logger.py
Normal file
74
backend/club_feature_logger.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""
|
||||
JSON-Log für Vereins-Feature-Zugriffe (Phase 2: nur Monitoring, kein Block).
|
||||
|
||||
Spez: CLUB_MEMBERSHIP_AND_FEATURES.v1.md §9 Phase 2 — analog Mitai feature_logger.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
def _log_dir() -> Path:
|
||||
custom = (os.getenv("CLUB_FEATURE_LOG_DIR") or "").strip()
|
||||
if custom:
|
||||
return Path(custom)
|
||||
return Path("/app/logs")
|
||||
|
||||
|
||||
feature_usage_logger = logging.getLogger("shinkan.club_feature_usage")
|
||||
feature_usage_logger.setLevel(logging.INFO)
|
||||
feature_usage_logger.propagate = False
|
||||
|
||||
if not feature_usage_logger.handlers:
|
||||
log_dir = _log_dir()
|
||||
try:
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_file = log_dir / "club-feature-usage.log"
|
||||
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
feature_usage_logger.addHandler(file_handler)
|
||||
except OSError:
|
||||
# Dev ohne /app/logs: Fallback stderr
|
||||
stream_handler = logging.StreamHandler()
|
||||
stream_handler.setFormatter(logging.Formatter("[club-feature-usage] %(message)s"))
|
||||
feature_usage_logger.addHandler(stream_handler)
|
||||
|
||||
|
||||
def log_club_feature_usage(
|
||||
*,
|
||||
club_id: Optional[int],
|
||||
profile_id: Optional[int],
|
||||
feature_id: str,
|
||||
action: str,
|
||||
access: Dict[str, Any],
|
||||
endpoint: Optional[str] = None,
|
||||
phase: str = "probe",
|
||||
) -> None:
|
||||
"""
|
||||
Strukturiertes JSON-Log eines Feature-Checks.
|
||||
|
||||
phase: probe (Phase 2, non-blocking) | enforce (Phase 4, nach Block-Entscheid)
|
||||
"""
|
||||
entry = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"club_id": club_id,
|
||||
"profile_id": profile_id,
|
||||
"feature": feature_id,
|
||||
"action": action,
|
||||
"endpoint": endpoint,
|
||||
"phase": phase,
|
||||
"plan_id": access.get("plan_id"),
|
||||
"used": access.get("used", 0),
|
||||
"limit": access.get("limit"),
|
||||
"remaining": access.get("remaining"),
|
||||
"allowed": access.get("allowed", True),
|
||||
"reason": access.get("reason", "unknown"),
|
||||
"enforcement": os.getenv("CLUB_FEATURE_ENFORCE", "0") == "1",
|
||||
}
|
||||
feature_usage_logger.info(json.dumps(entry, ensure_ascii=False))
|
||||
713
backend/club_features.py
Normal file
713
backend/club_features.py
Normal file
|
|
@ -0,0 +1,713 @@
|
|||
"""
|
||||
Vereinsbezogene Feature-Limits (Mitai-v9c-Pattern, Subjekt club_id).
|
||||
|
||||
Spez: .claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md
|
||||
Phase 2 (M2): probe_club_feature_access — JSON-Log, kein HTTP-Block.
|
||||
Phase 4 (M5+): CLUB_FEATURE_ENFORCE=1 — HTTP 403 + increment.
|
||||
|
||||
Verbrauch-Standard für Router:
|
||||
probe_club_feature_access → Business-Logik → consume_club_feature_with_usage → merge_feature_usage_into_response
|
||||
|
||||
Legacy profil-zentriert: auth.check_feature_access (001 / Mitai-Überbleibsel) — nicht für Shinkan-Limits nutzen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Optional, TYPE_CHECKING
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from db import get_db, get_cursor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tenant_context import TenantContext
|
||||
|
||||
# Bestands-Features: Verbrauch = Live-Zählung in DB (nicht club_feature_usage)
|
||||
_INVENTORY_FEATURES = frozenset(
|
||||
{"exercises", "training_groups", "active_members", "training_programs"}
|
||||
)
|
||||
|
||||
|
||||
def _calculate_next_reset(reset_period: str, *, now: Optional[datetime] = None) -> Optional[datetime]:
|
||||
"""Nächster Reset-Zeitpunkt; None bei 'never'."""
|
||||
ref = now or datetime.now(timezone.utc)
|
||||
if reset_period == "never":
|
||||
return None
|
||||
if reset_period == "daily":
|
||||
tomorrow = ref.date() + timedelta(days=1)
|
||||
return datetime.combine(tomorrow, datetime.min.time(), tzinfo=timezone.utc)
|
||||
if reset_period == "monthly":
|
||||
if ref.month == 12:
|
||||
return datetime(ref.year + 1, 1, 1, tzinfo=timezone.utc)
|
||||
return datetime(ref.year, ref.month + 1, 1, tzinfo=timezone.utc)
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_limit(raw: Any) -> Optional[int]:
|
||||
"""NULL = unbegrenzt; -1 (Legacy 001) wird als unbegrenzt behandelt."""
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
v = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if v < 0:
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
def get_effective_club_plan(cur, club_id: int) -> str:
|
||||
"""
|
||||
Effektiver Plan für einen Verein.
|
||||
|
||||
1. Aktiver club_access_grants mit plan_id (Zeitfenster, neueste ends_at)
|
||||
2. club_subscriptions.status = 'active' → plan_id
|
||||
3. Fallback 'free'
|
||||
"""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT plan_id
|
||||
FROM club_access_grants
|
||||
WHERE club_id = %s
|
||||
AND plan_id IS NOT NULL
|
||||
AND starts_at <= NOW()
|
||||
AND ends_at > NOW()
|
||||
ORDER BY ends_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(club_id,),
|
||||
)
|
||||
grant = cur.fetchone()
|
||||
if grant and grant.get("plan_id"):
|
||||
return str(grant["plan_id"])
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT plan_id
|
||||
FROM club_subscriptions
|
||||
WHERE club_id = %s AND status = 'active'
|
||||
LIMIT 1
|
||||
""",
|
||||
(club_id,),
|
||||
)
|
||||
sub = cur.fetchone()
|
||||
if sub and sub.get("plan_id"):
|
||||
return str(sub["plan_id"])
|
||||
|
||||
return "free"
|
||||
|
||||
|
||||
def _resolve_club_limit(cur, club_id: int, feature_id: str, feature_row: dict) -> Optional[int]:
|
||||
"""Limit-Wert: Override > Plan > Feature-Default."""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT limit_value
|
||||
FROM club_feature_overrides
|
||||
WHERE club_id = %s AND feature_id = %s
|
||||
""",
|
||||
(club_id, feature_id),
|
||||
)
|
||||
override = cur.fetchone()
|
||||
if override is not None:
|
||||
return _normalize_limit(override.get("limit_value"))
|
||||
|
||||
plan_id = get_effective_club_plan(cur, club_id)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT limit_value
|
||||
FROM club_plan_limits
|
||||
WHERE plan_id = %s AND feature_id = %s
|
||||
""",
|
||||
(plan_id, feature_id),
|
||||
)
|
||||
plan_lim = cur.fetchone()
|
||||
if plan_lim is not None:
|
||||
return _normalize_limit(plan_lim.get("limit_value"))
|
||||
|
||||
return _normalize_limit(feature_row.get("default_limit"))
|
||||
|
||||
|
||||
def _live_inventory_count(cur, club_id: int, feature_id: str) -> Optional[int]:
|
||||
"""Aktueller Bestand für reset_period=never Features."""
|
||||
if feature_id == "exercises":
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*)::int AS c
|
||||
FROM exercises
|
||||
WHERE club_id = %s AND status != 'archived'
|
||||
""",
|
||||
(club_id,),
|
||||
)
|
||||
elif feature_id == "training_groups":
|
||||
cur.execute(
|
||||
"SELECT COUNT(*)::int AS c FROM training_groups WHERE club_id = %s",
|
||||
(club_id,),
|
||||
)
|
||||
elif feature_id == "active_members":
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*)::int AS c
|
||||
FROM club_members
|
||||
WHERE club_id = %s AND status = 'active'
|
||||
""",
|
||||
(club_id,),
|
||||
)
|
||||
elif feature_id == "training_programs":
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*)::int AS c FROM (
|
||||
SELECT id FROM training_framework_programs WHERE club_id = %s
|
||||
UNION ALL
|
||||
SELECT id FROM training_modules WHERE club_id = %s
|
||||
) t
|
||||
""",
|
||||
(club_id, club_id),
|
||||
)
|
||||
else:
|
||||
return None
|
||||
|
||||
row = cur.fetchone()
|
||||
return int(row["c"] or 0) if row else 0
|
||||
|
||||
|
||||
def resolve_club_id_for_probe(
|
||||
tenant: "TenantContext",
|
||||
*,
|
||||
object_club_id: Optional[int] = None,
|
||||
) -> Optional[int]:
|
||||
"""Verein für Feature-Probe: explizites Objekt > effective_club_id."""
|
||||
if object_club_id is not None:
|
||||
return int(object_club_id)
|
||||
eff = getattr(tenant, "effective_club_id", None)
|
||||
return int(eff) if eff is not None else None
|
||||
|
||||
|
||||
def _maybe_reset_usage(cur, conn, club_id: int, feature_id: str, feature_row: dict, usage_row: Optional[dict]) -> int:
|
||||
"""Setzt Zähler zurück wenn reset_at überschritten; gibt aktuellen used zurück."""
|
||||
used = int(usage_row.get("usage_count") or 0) if usage_row else 0
|
||||
reset_at = usage_row.get("reset_at") if usage_row else None
|
||||
period = (feature_row.get("reset_period") or "never").strip().lower()
|
||||
|
||||
if not usage_row or not reset_at or period == "never":
|
||||
return used
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
ra = reset_at
|
||||
if hasattr(ra, "tzinfo") and ra.tzinfo is None:
|
||||
ra = ra.replace(tzinfo=timezone.utc)
|
||||
|
||||
if ra and now > ra:
|
||||
next_reset = _calculate_next_reset(period, now=now)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE club_feature_usage
|
||||
SET usage_count = 0, reset_at = %s, updated_at = NOW()
|
||||
WHERE club_id = %s AND feature_id = %s
|
||||
""",
|
||||
(next_reset, club_id, feature_id),
|
||||
)
|
||||
conn.commit()
|
||||
return 0
|
||||
|
||||
return used
|
||||
|
||||
|
||||
def check_club_feature_access(
|
||||
club_id: int,
|
||||
feature_id: str,
|
||||
*,
|
||||
conn=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Prüft Vereins-Kontingent für ein Feature.
|
||||
|
||||
Returns:
|
||||
allowed, limit, used, remaining, reason, plan_id, reset_at (optional)
|
||||
"""
|
||||
if conn is not None:
|
||||
return _check_club_impl(club_id, feature_id, conn)
|
||||
|
||||
with get_db() as c:
|
||||
return _check_club_impl(club_id, feature_id, c)
|
||||
|
||||
|
||||
def _check_club_impl(club_id: int, feature_id: str, conn) -> Dict[str, Any]:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, limit_type, reset_period, default_limit, active, enforcement_subject
|
||||
FROM features
|
||||
WHERE id = %s AND app = 'shinkan'
|
||||
""",
|
||||
(feature_id,),
|
||||
)
|
||||
feature = cur.fetchone()
|
||||
if not feature or not feature.get("active"):
|
||||
return {
|
||||
"allowed": False,
|
||||
"limit": None,
|
||||
"used": 0,
|
||||
"remaining": None,
|
||||
"reason": "feature_not_found",
|
||||
"plan_id": get_effective_club_plan(cur, club_id),
|
||||
}
|
||||
|
||||
plan_id = get_effective_club_plan(cur, club_id)
|
||||
limit = _resolve_club_limit(cur, club_id, feature_id, feature)
|
||||
limit_type = (feature.get("limit_type") or "count").strip().lower()
|
||||
|
||||
if limit_type == "boolean":
|
||||
allowed = limit == 1
|
||||
return {
|
||||
"allowed": allowed,
|
||||
"limit": limit,
|
||||
"used": 0,
|
||||
"remaining": None,
|
||||
"reason": "enabled" if allowed else "feature_disabled",
|
||||
"plan_id": plan_id,
|
||||
}
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT usage_count, reset_at
|
||||
FROM club_feature_usage
|
||||
WHERE club_id = %s AND feature_id = %s
|
||||
""",
|
||||
(club_id, feature_id),
|
||||
)
|
||||
usage = cur.fetchone()
|
||||
used = _maybe_reset_usage(cur, conn, club_id, feature_id, feature, usage)
|
||||
|
||||
period = (feature.get("reset_period") or "never").strip().lower()
|
||||
if period == "never" and feature_id in _INVENTORY_FEATURES:
|
||||
inv = _live_inventory_count(cur, club_id, feature_id)
|
||||
if inv is not None:
|
||||
used = inv
|
||||
|
||||
if limit is None:
|
||||
return {
|
||||
"allowed": True,
|
||||
"limit": None,
|
||||
"used": used,
|
||||
"remaining": None,
|
||||
"reason": "unlimited",
|
||||
"plan_id": plan_id,
|
||||
"reset_at": usage.get("reset_at") if usage else None,
|
||||
}
|
||||
|
||||
if limit == 0:
|
||||
return {
|
||||
"allowed": False,
|
||||
"limit": 0,
|
||||
"used": used,
|
||||
"remaining": 0,
|
||||
"reason": "feature_disabled",
|
||||
"plan_id": plan_id,
|
||||
"reset_at": usage.get("reset_at") if usage else None,
|
||||
}
|
||||
|
||||
allowed = used < limit
|
||||
return {
|
||||
"allowed": allowed,
|
||||
"limit": limit,
|
||||
"used": used,
|
||||
"remaining": max(0, limit - used),
|
||||
"reason": "within_limit" if allowed else "limit_exceeded",
|
||||
"plan_id": plan_id,
|
||||
"reset_at": usage.get("reset_at") if usage else None,
|
||||
}
|
||||
|
||||
|
||||
def club_feature_enforcement_enabled() -> bool:
|
||||
"""Phase 4: Hard-Block aktiv (Env CLUB_FEATURE_ENFORCE=1|true|yes)."""
|
||||
v = os.getenv("CLUB_FEATURE_ENFORCE", "0").strip().lower()
|
||||
return v in ("1", "true", "yes")
|
||||
|
||||
|
||||
def probe_club_feature_access(
|
||||
*,
|
||||
feature_id: str,
|
||||
action: str,
|
||||
club_id: Optional[int] = None,
|
||||
profile_id: Optional[int] = None,
|
||||
portal_role: Optional[str] = None,
|
||||
endpoint: Optional[str] = None,
|
||||
tenant: Optional["TenantContext"] = None,
|
||||
conn=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Phase 2: Prüft Vereins-Kontingent, schreibt JSON-Log, blockiert standardmäßig nicht.
|
||||
|
||||
Bei CLUB_FEATURE_ENFORCE=1: HTTP 403 wenn nicht allowed.
|
||||
"""
|
||||
from club_feature_logger import log_club_feature_usage
|
||||
|
||||
if club_id is None:
|
||||
access = {
|
||||
"allowed": not club_feature_enforcement_enabled(),
|
||||
"limit": None,
|
||||
"used": 0,
|
||||
"remaining": None,
|
||||
"reason": "no_club_context",
|
||||
"plan_id": None,
|
||||
}
|
||||
log_club_feature_usage(
|
||||
club_id=None,
|
||||
profile_id=profile_id,
|
||||
feature_id=feature_id,
|
||||
action=action,
|
||||
access=access,
|
||||
endpoint=endpoint,
|
||||
phase="enforce" if club_feature_enforcement_enabled() else "probe",
|
||||
)
|
||||
if club_feature_enforcement_enabled() and not access.get("allowed"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=(
|
||||
f"Kein Vereinskontext für {feature_id} — "
|
||||
"aktiven Verein wählen (X-Active-Club-Id)."
|
||||
),
|
||||
)
|
||||
return access
|
||||
|
||||
def _resolve_access(connection):
|
||||
from club_quota_bypass import is_club_feature_quota_bypassed, quota_bypass_access
|
||||
|
||||
cur = get_cursor(connection)
|
||||
if is_club_feature_quota_bypassed(
|
||||
cur,
|
||||
profile_id=profile_id,
|
||||
portal_role=portal_role,
|
||||
feature_id=feature_id,
|
||||
tenant=tenant,
|
||||
):
|
||||
plan_id = get_effective_club_plan(cur, int(club_id))
|
||||
return quota_bypass_access(
|
||||
feature_id=feature_id,
|
||||
club_id=int(club_id),
|
||||
plan_id=plan_id,
|
||||
)
|
||||
return check_club_feature_access(club_id, feature_id, conn=connection)
|
||||
|
||||
if conn is not None:
|
||||
access = _resolve_access(conn)
|
||||
else:
|
||||
with get_db() as c:
|
||||
access = _resolve_access(c)
|
||||
|
||||
log_club_feature_usage(
|
||||
club_id=club_id,
|
||||
profile_id=profile_id,
|
||||
feature_id=feature_id,
|
||||
action=action,
|
||||
access=access,
|
||||
endpoint=endpoint,
|
||||
phase="enforce" if club_feature_enforcement_enabled() else "probe",
|
||||
)
|
||||
|
||||
if club_feature_enforcement_enabled() and not access.get("allowed"):
|
||||
limit = access.get("limit")
|
||||
used = access.get("used", 0)
|
||||
detail = (
|
||||
f"Kontingent überschritten für {feature_id} "
|
||||
f"({used}/{limit if limit is not None else '∞'}). "
|
||||
f"Grund: {access.get('reason', 'limit_exceeded')}."
|
||||
)
|
||||
raise HTTPException(status_code=403, detail=detail)
|
||||
|
||||
return access
|
||||
|
||||
|
||||
def consume_club_feature(
|
||||
*,
|
||||
feature_id: str,
|
||||
club_id: Optional[int],
|
||||
profile_id: Optional[int] = None,
|
||||
portal_role: Optional[str] = None,
|
||||
action: Optional[str] = None,
|
||||
amount: int = 1,
|
||||
conn=None,
|
||||
) -> None:
|
||||
"""
|
||||
Phase 4 (M5): Zähler nach erfolgreichem Verbrauch erhöhen.
|
||||
Nur wenn club_id gesetzt (Vereins-Kontingent); amount = Anzahl LLM/API-Verbrauchseinheiten.
|
||||
Plattform-Ausnahmen (superadmin, konfigurierte Rollen/Profile) werden nicht gezählt.
|
||||
"""
|
||||
if club_id is None:
|
||||
return
|
||||
|
||||
def _is_exempt(connection) -> bool:
|
||||
from club_quota_bypass import is_club_feature_quota_bypassed
|
||||
|
||||
cur = get_cursor(connection)
|
||||
return is_club_feature_quota_bypassed(
|
||||
cur,
|
||||
profile_id=profile_id,
|
||||
portal_role=portal_role,
|
||||
feature_id=feature_id,
|
||||
)
|
||||
|
||||
if conn is not None:
|
||||
if _is_exempt(conn):
|
||||
return
|
||||
else:
|
||||
with get_db() as c:
|
||||
if _is_exempt(c):
|
||||
return
|
||||
try:
|
||||
n = int(amount)
|
||||
except (TypeError, ValueError):
|
||||
n = 1
|
||||
if n < 1:
|
||||
return
|
||||
for _ in range(n):
|
||||
increment_club_feature_usage(
|
||||
int(club_id),
|
||||
feature_id,
|
||||
profile_id=profile_id,
|
||||
action=action,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
def _log_consume(connection) -> None:
|
||||
from club_feature_logger import log_club_feature_usage
|
||||
|
||||
access = check_club_feature_access(int(club_id), feature_id, conn=connection)
|
||||
log_club_feature_usage(
|
||||
club_id=int(club_id),
|
||||
profile_id=profile_id,
|
||||
feature_id=feature_id,
|
||||
action=action or "consume",
|
||||
access=access,
|
||||
phase="consume",
|
||||
)
|
||||
|
||||
if conn is not None:
|
||||
_log_consume(conn)
|
||||
else:
|
||||
with get_db() as c:
|
||||
_log_consume(c)
|
||||
|
||||
|
||||
def consume_club_feature_with_usage(
|
||||
*,
|
||||
feature_id: str,
|
||||
club_id: Optional[int],
|
||||
profile_id: Optional[int] = None,
|
||||
portal_role: Optional[str] = None,
|
||||
action: Optional[str] = None,
|
||||
amount: int = 1,
|
||||
cur,
|
||||
tenant: Optional["TenantContext"] = None,
|
||||
conn=None,
|
||||
) -> Optional[Dict[str, Dict[str, Any]]]:
|
||||
"""
|
||||
Standard nach erfolgreichem Verbrauch: zählen, protokollieren, Snapshot für Response.
|
||||
|
||||
Alle Endpoints mit Vereins-Kontingent-Verbrauch nutzen diese Funktion und
|
||||
``merge_feature_usage_into_response`` — kein duplizierter Einzelcode pro Route.
|
||||
"""
|
||||
consume_club_feature(
|
||||
feature_id=feature_id,
|
||||
club_id=club_id,
|
||||
profile_id=profile_id,
|
||||
portal_role=portal_role,
|
||||
action=action,
|
||||
amount=amount,
|
||||
conn=conn,
|
||||
)
|
||||
if club_id is None:
|
||||
return None
|
||||
return {
|
||||
feature_id: club_feature_usage_for_api(
|
||||
cur,
|
||||
club_id=int(club_id),
|
||||
feature_id=feature_id,
|
||||
profile_id=profile_id,
|
||||
portal_role=portal_role,
|
||||
tenant=tenant,
|
||||
conn=conn,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def merge_feature_usage_into_response(
|
||||
payload: Any,
|
||||
feature_usage: Optional[Dict[str, Dict[str, Any]]],
|
||||
) -> Any:
|
||||
"""Standard-Einbettung ``feature_usage`` in JSON-Responses."""
|
||||
if not feature_usage or not isinstance(payload, dict):
|
||||
return payload
|
||||
return {**payload, "feature_usage": feature_usage}
|
||||
|
||||
|
||||
def club_feature_usage_for_api(
|
||||
cur,
|
||||
*,
|
||||
club_id: int,
|
||||
feature_id: str,
|
||||
profile_id: Optional[int] = None,
|
||||
portal_role: Optional[str] = None,
|
||||
tenant: Optional["TenantContext"] = None,
|
||||
conn=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Feature-Zustand wie GET /me/entitlements → features[feature_id] (nach Verbrauch)."""
|
||||
from club_quota_bypass import is_club_feature_quota_bypassed, quota_bypass_access
|
||||
|
||||
db_conn = conn if conn is not None else cur.connection
|
||||
access = check_club_feature_access(int(club_id), feature_id, conn=db_conn)
|
||||
plan_id = access.get("plan_id") or get_effective_club_plan(cur, int(club_id))
|
||||
|
||||
if is_club_feature_quota_bypassed(
|
||||
cur,
|
||||
profile_id=profile_id,
|
||||
portal_role=portal_role,
|
||||
feature_id=feature_id,
|
||||
tenant=tenant,
|
||||
):
|
||||
ex = quota_bypass_access(
|
||||
feature_id=feature_id,
|
||||
club_id=int(club_id),
|
||||
plan_id=plan_id,
|
||||
)
|
||||
reset_at = access.get("reset_at")
|
||||
return {
|
||||
"allowed": True,
|
||||
"used": access.get("used"),
|
||||
"limit": None,
|
||||
"remaining": None,
|
||||
"reason": ex.get("reason"),
|
||||
"platform_exempt": True,
|
||||
"reset_at": reset_at.isoformat() if hasattr(reset_at, "isoformat") else reset_at,
|
||||
}
|
||||
|
||||
return {
|
||||
"allowed": access.get("allowed"),
|
||||
"used": access.get("used"),
|
||||
"limit": access.get("limit"),
|
||||
"remaining": access.get("remaining"),
|
||||
"reason": access.get("reason"),
|
||||
"platform_exempt": False,
|
||||
"reset_at": access.get("reset_at").isoformat()
|
||||
if access.get("reset_at") is not None and hasattr(access.get("reset_at"), "isoformat")
|
||||
else access.get("reset_at"),
|
||||
}
|
||||
|
||||
|
||||
def increment_club_feature_usage(
|
||||
club_id: int,
|
||||
feature_id: str,
|
||||
*,
|
||||
profile_id: Optional[int] = None,
|
||||
action: Optional[str] = None,
|
||||
conn=None,
|
||||
) -> None:
|
||||
"""Erhöht Vereins-Zähler (nur bei neuem Verbrauch / INSERT-Pfad aufrufen)."""
|
||||
def _run(c):
|
||||
cur = get_cursor(c)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT reset_period, limit_type
|
||||
FROM features
|
||||
WHERE id = %s AND app = 'shinkan' AND active = true
|
||||
""",
|
||||
(feature_id,),
|
||||
)
|
||||
feature = cur.fetchone()
|
||||
if not feature:
|
||||
return
|
||||
if (feature.get("limit_type") or "count").strip().lower() == "boolean":
|
||||
return
|
||||
|
||||
period = (feature.get("reset_period") or "never").strip().lower()
|
||||
next_reset = _calculate_next_reset(period)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO club_feature_usage (club_id, feature_id, usage_count, reset_at, last_used_at)
|
||||
VALUES (%s, %s, 1, %s, NOW())
|
||||
ON CONFLICT (club_id, feature_id)
|
||||
DO UPDATE SET
|
||||
usage_count = club_feature_usage.usage_count + 1,
|
||||
last_used_at = NOW(),
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(club_id, feature_id, next_reset),
|
||||
)
|
||||
|
||||
if profile_id is not None or action:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO club_feature_usage_events (club_id, feature_id, profile_id, action)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""",
|
||||
(club_id, feature_id, profile_id, action or feature_id),
|
||||
)
|
||||
|
||||
if conn is not None:
|
||||
_run(conn)
|
||||
else:
|
||||
with get_db() as c:
|
||||
_run(c)
|
||||
|
||||
|
||||
def list_club_entitlements(cur, club_id: int, *, conn=None) -> Dict[str, Any]:
|
||||
"""Alle aktiven Shinkan-Features mit effektivem Limit und Verbrauch (Liste, intern)."""
|
||||
db_conn = conn if conn is not None else cur.connection
|
||||
plan_id = get_effective_club_plan(cur, club_id)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name, category, limit_type, reset_period
|
||||
FROM features
|
||||
WHERE app = 'shinkan' AND active = true
|
||||
ORDER BY category, id
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
features_out = []
|
||||
for row in rows:
|
||||
fid = row["id"]
|
||||
access = _check_club_impl(club_id, fid, db_conn)
|
||||
features_out.append(
|
||||
{
|
||||
"id": fid,
|
||||
"name": row.get("name"),
|
||||
"category": row.get("category"),
|
||||
"limit_type": row.get("limit_type"),
|
||||
"reset_period": row.get("reset_period"),
|
||||
"allowed": access.get("allowed"),
|
||||
"limit": access.get("limit"),
|
||||
"used": access.get("used"),
|
||||
"remaining": access.get("remaining"),
|
||||
"reason": access.get("reason"),
|
||||
"reset_at": access.get("reset_at"),
|
||||
}
|
||||
)
|
||||
return {"club_id": club_id, "plan_id": plan_id, "features": features_out}
|
||||
|
||||
|
||||
def club_features_map(cur, club_id: int, *, conn=None) -> Dict[str, Any]:
|
||||
"""Feature-Kontingente als Dict feature_id → Zustand (für /me/entitlements)."""
|
||||
raw = list_club_entitlements(cur, club_id, conn=conn)
|
||||
features_dict: Dict[str, Any] = {}
|
||||
for row in raw.get("features") or []:
|
||||
fid = row["id"]
|
||||
features_dict[fid] = {
|
||||
"name": row.get("name"),
|
||||
"category": row.get("category"),
|
||||
"limit_type": row.get("limit_type"),
|
||||
"reset_period": row.get("reset_period"),
|
||||
"allowed": row.get("allowed"),
|
||||
"limit": row.get("limit"),
|
||||
"used": row.get("used"),
|
||||
"remaining": row.get("remaining"),
|
||||
"reason": row.get("reason"),
|
||||
"reset_at": row.get("reset_at"),
|
||||
}
|
||||
return {
|
||||
"club_id": raw.get("club_id"),
|
||||
"plan_id": raw.get("plan_id"),
|
||||
"features": features_dict,
|
||||
}
|
||||
180
backend/club_quota_bypass.py
Normal file
180
backend/club_quota_bypass.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"""
|
||||
Vereins-Kontingent-Bypass über das Capability-System (kein Parallel-Rechtemodell).
|
||||
|
||||
Capabilities:
|
||||
- platform.club_quota.bypass — alle Vereins-Features (Portal-Admin, Grant via portal_role)
|
||||
- platform.club_quota.bypass.{feature_id} — ein Feature (domain quota_bypass, auch für Nicht-Admins per Grant)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tenant_context import TenantContext
|
||||
|
||||
QUOTA_BYPASS_ALL = "platform.club_quota.bypass"
|
||||
QUOTA_BYPASS_FEATURE_PREFIX = "platform.club_quota.bypass."
|
||||
|
||||
|
||||
def quota_bypass_capability_id_for_feature(feature_id: str) -> str:
|
||||
return f"{QUOTA_BYPASS_FEATURE_PREFIX}{feature_id}"
|
||||
|
||||
|
||||
def ensure_quota_bypass_capability(cur, feature_id: str) -> str:
|
||||
"""Legt feature-spezifische Bypass-Capability an falls nötig."""
|
||||
cap_id = quota_bypass_capability_id_for_feature(feature_id)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id)
|
||||
VALUES (%s, %s, 'quota_bypass', 'active_member', %s)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""",
|
||||
(cap_id, f"Vereins-Kontingent umgehen: {feature_id}", feature_id),
|
||||
)
|
||||
return cap_id
|
||||
|
||||
|
||||
def _bypass_capability_ids(cur, feature_id: str) -> List[str]:
|
||||
ids: List[str] = [QUOTA_BYPASS_ALL, quota_bypass_capability_id_for_feature(feature_id)]
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM capabilities
|
||||
WHERE active = true
|
||||
AND domain = 'quota_bypass'
|
||||
AND linked_feature_id = %s
|
||||
AND id <> %s
|
||||
""",
|
||||
(feature_id, quota_bypass_capability_id_for_feature(feature_id)),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
cid = row.get("id")
|
||||
if cid and cid not in ids:
|
||||
ids.append(str(cid))
|
||||
return ids
|
||||
|
||||
|
||||
def _portal_role_has_grant(cur, portal_role: str, capability_id: str) -> bool:
|
||||
role = (portal_role or "").strip().lower()
|
||||
if not role:
|
||||
return False
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM portal_role_capability_grants
|
||||
WHERE portal_role = %s AND capability_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(role, capability_id),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def _profile_has_grant(cur, profile_id: int, capability_id: str) -> bool:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM profile_capability_grants
|
||||
WHERE profile_id = %s AND capability_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(int(profile_id), capability_id),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def is_club_feature_quota_bypassed(
|
||||
cur,
|
||||
*,
|
||||
profile_id: Optional[int],
|
||||
portal_role: Optional[str],
|
||||
feature_id: str,
|
||||
tenant: Optional["TenantContext"] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
True wenn ein konfigurierter Capability-Grant das Vereins-Kontingent für feature_id umgeht.
|
||||
"""
|
||||
if tenant is not None:
|
||||
from capabilities import check_capability
|
||||
|
||||
for cap_id in _bypass_capability_ids(cur, feature_id):
|
||||
if check_capability(cur, tenant, cap_id).get("allowed"):
|
||||
return True
|
||||
return False
|
||||
|
||||
for cap_id in _bypass_capability_ids(cur, feature_id):
|
||||
if _portal_role_has_grant(cur, portal_role or "", cap_id):
|
||||
return True
|
||||
if profile_id is not None and _profile_has_grant(cur, int(profile_id), cap_id):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def quota_bypass_access(
|
||||
*,
|
||||
feature_id: str,
|
||||
club_id: Optional[int] = None,
|
||||
plan_id: Optional[str] = None,
|
||||
capability_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"allowed": True,
|
||||
"limit": None,
|
||||
"used": 0,
|
||||
"remaining": None,
|
||||
"reason": "capability_quota_bypass",
|
||||
"platform_exempt": True,
|
||||
"quota_bypass_capability": capability_id,
|
||||
"plan_id": plan_id,
|
||||
"club_id": club_id,
|
||||
"feature_id": feature_id,
|
||||
}
|
||||
|
||||
|
||||
def list_quota_bypass_grants(cur) -> Dict[str, Any]:
|
||||
"""Admin: alle Grants zu Kontingent-Bypass-Capabilities."""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT g.portal_role, g.capability_id, c.name AS capability_name,
|
||||
c.linked_feature_id, c.domain
|
||||
FROM portal_role_capability_grants g
|
||||
INNER JOIN capabilities c ON c.id = g.capability_id
|
||||
WHERE g.capability_id = %s
|
||||
OR g.capability_id LIKE %s
|
||||
OR c.domain = 'quota_bypass'
|
||||
ORDER BY g.portal_role, g.capability_id
|
||||
""",
|
||||
(QUOTA_BYPASS_ALL, f"{QUOTA_BYPASS_FEATURE_PREFIX}%"),
|
||||
)
|
||||
portal_grants = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT g.profile_id, p.email, p.name AS profile_name,
|
||||
g.capability_id, c.name AS capability_name, c.linked_feature_id,
|
||||
g.reason, g.granted_by_profile_id, g.created_at
|
||||
FROM profile_capability_grants g
|
||||
INNER JOIN profiles p ON p.id = g.profile_id
|
||||
INNER JOIN capabilities c ON c.id = g.capability_id
|
||||
WHERE g.capability_id = %s
|
||||
OR g.capability_id LIKE %s
|
||||
OR c.domain = 'quota_bypass'
|
||||
ORDER BY g.profile_id, g.capability_id
|
||||
""",
|
||||
(QUOTA_BYPASS_ALL, f"{QUOTA_BYPASS_FEATURE_PREFIX}%"),
|
||||
)
|
||||
profile_grants = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name, domain, linked_feature_id
|
||||
FROM capabilities
|
||||
WHERE id = %s OR id LIKE %s OR domain = 'quota_bypass'
|
||||
ORDER BY id
|
||||
""",
|
||||
(QUOTA_BYPASS_ALL, f"{QUOTA_BYPASS_FEATURE_PREFIX}%"),
|
||||
)
|
||||
capabilities = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
return {
|
||||
"capabilities": capabilities,
|
||||
"portal_role_grants": portal_grants,
|
||||
"profile_grants": profile_grants,
|
||||
}
|
||||
113
backend/entitlements.py
Normal file
113
backend/entitlements.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""
|
||||
Zusammenstellung effektiver Rechte für GET /api/me/entitlements (M4).
|
||||
|
||||
Spez: CAPABILITY_CATALOG.v1.md §7.1, CLUB_MEMBERSHIP_AND_FEATURES.v1.md §8.1
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional, TYPE_CHECKING
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from capabilities import club_roles_in_club, resolve_capabilities_map
|
||||
from club_quota_bypass import is_club_feature_quota_bypassed, quota_bypass_access
|
||||
from club_features import club_features_map
|
||||
from club_tenancy import is_platform_admin
|
||||
from tenant_context import _club_exists
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tenant_context import TenantContext
|
||||
|
||||
|
||||
def _serialize_reset_at(value: Any) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=None).isoformat() + "Z"
|
||||
return value.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
def _resolve_target_club_id(
|
||||
cur,
|
||||
tenant: "TenantContext",
|
||||
club_id: Optional[int],
|
||||
) -> Optional[int]:
|
||||
"""Effektiver Verein für Entitlements (Query > Tenant)."""
|
||||
target = int(club_id) if club_id is not None else tenant.effective_club_id
|
||||
if target is None:
|
||||
return None
|
||||
|
||||
if is_platform_admin(tenant.global_role):
|
||||
if not _club_exists(cur, target):
|
||||
raise HTTPException(status_code=400, detail="Verein nicht gefunden")
|
||||
return target
|
||||
|
||||
if target not in tenant.club_ids:
|
||||
raise HTTPException(status_code=403, detail="Keine Mitgliedschaft in diesem Verein")
|
||||
return target
|
||||
|
||||
|
||||
def build_me_entitlements(
|
||||
cur,
|
||||
tenant: "TenantContext",
|
||||
*,
|
||||
club_id: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Kombiniert Account-Status, Capabilities und Feature-Kontingente.
|
||||
"""
|
||||
target_club = _resolve_target_club_id(cur, tenant, club_id)
|
||||
club_roles = club_roles_in_club(tenant, target_club) if target_club is not None else []
|
||||
|
||||
capabilities = resolve_capabilities_map(cur, tenant, club_id=target_club)
|
||||
|
||||
features: Dict[str, Any] = {}
|
||||
plan_id = None
|
||||
if target_club is not None:
|
||||
raw = club_features_map(cur, target_club)
|
||||
plan_id = raw.get("plan_id")
|
||||
for fid, row in (raw.get("features") or {}).items():
|
||||
if is_club_feature_quota_bypassed(
|
||||
cur,
|
||||
profile_id=tenant.profile_id,
|
||||
portal_role=tenant.global_role,
|
||||
feature_id=fid,
|
||||
tenant=tenant,
|
||||
):
|
||||
ex = quota_bypass_access(
|
||||
feature_id=fid,
|
||||
club_id=target_club,
|
||||
plan_id=plan_id,
|
||||
)
|
||||
features[fid] = {
|
||||
"allowed": True,
|
||||
"used": row.get("used"),
|
||||
"limit": None,
|
||||
"remaining": None,
|
||||
"reset_at": _serialize_reset_at(row.get("reset_at")),
|
||||
"reason": ex.get("reason"),
|
||||
"platform_exempt": True,
|
||||
}
|
||||
else:
|
||||
features[fid] = {
|
||||
"allowed": row.get("allowed"),
|
||||
"used": row.get("used"),
|
||||
"limit": row.get("limit"),
|
||||
"remaining": row.get("remaining"),
|
||||
"reset_at": _serialize_reset_at(row.get("reset_at")),
|
||||
"reason": row.get("reason"),
|
||||
"platform_exempt": False,
|
||||
}
|
||||
|
||||
return {
|
||||
"account_state": tenant.account_state,
|
||||
"portal_role": tenant.global_role,
|
||||
"club_id": target_club,
|
||||
"plan_id": plan_id,
|
||||
"club_roles": club_roles,
|
||||
"capabilities": capabilities,
|
||||
"features": features,
|
||||
}
|
||||
|
|
@ -650,10 +650,13 @@ def build_exercise_placeholder_variables(
|
|||
focus_areas_context: Optional[Sequence[Tuple[int, bool]]],
|
||||
preparation: Optional[str] = None,
|
||||
trainer_notes: Optional[str] = None,
|
||||
planning_context: Optional[Mapping[str, Any]] = None,
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Baut die Variable-Map fuer {{platzhalter}} passend zur Slug fuer Uebungs-KI.
|
||||
"""
|
||||
from planning_exercise_form_context import planning_context_prompt_variables
|
||||
|
||||
s = (slug or "").strip().lower()
|
||||
if s == "pipeline":
|
||||
return {}
|
||||
|
|
@ -671,8 +674,19 @@ def build_exercise_placeholder_variables(
|
|||
"exercise_preparation": p_plain or "-",
|
||||
"exercise_trainer_notes": n_plain or "-",
|
||||
}
|
||||
ctx.update(planning_context_prompt_variables(planning_context))
|
||||
if s == "exercise_summary":
|
||||
return {k: ctx[k] for k in ("exercise_title", "exercise_focus_area", "exercise_goal", "exercise_execution")}
|
||||
return {
|
||||
k: ctx[k]
|
||||
for k in (
|
||||
"exercise_title",
|
||||
"exercise_focus_area",
|
||||
"exercise_goal",
|
||||
"exercise_execution",
|
||||
"planning_context_json",
|
||||
"has_planning_context",
|
||||
)
|
||||
}
|
||||
if s == "exercise_instruction_rewrite":
|
||||
return ctx
|
||||
if s == "exercise_skill_suggestions":
|
||||
|
|
@ -893,6 +907,7 @@ def run_exercise_ai_suggestion(
|
|||
execution=execution,
|
||||
focus_area_hint=focus_area_hint,
|
||||
focus_areas_context=focus_areas_context,
|
||||
planning_context=form_ctx.planning_context,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
|
@ -938,6 +953,7 @@ def run_exercise_ai_suggestion(
|
|||
execution=execution,
|
||||
focus_area_hint=focus_area_hint,
|
||||
focus_areas_context=focus_areas_context,
|
||||
planning_context=form_ctx.planning_context,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
|
@ -1015,6 +1031,7 @@ def run_exercise_ai_suggestion(
|
|||
trainer_notes=trainer_notes,
|
||||
focus_area_hint=focus_area_hint,
|
||||
focus_areas_context=focus_areas_context,
|
||||
planning_context=form_ctx.planning_context,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
|
|
|||
|
|
@ -52,6 +52,28 @@ else:
|
|||
print(f"[FAIL] Migration-Laufzeitfehler: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Registry-first: Module → DB (nur registrierte Rechte/Kontingente in Admin-Matrix)
|
||||
if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() not in ("1", "true", "yes"):
|
||||
try:
|
||||
from rights_registry import sync_rights_registry_to_db
|
||||
|
||||
counts = sync_rights_registry_to_db()
|
||||
print(
|
||||
f"[OK] Rights registry sync: {counts['capabilities']} capabilities, "
|
||||
f"{counts['features']} features"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[FAIL] Rights registry sync: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
from club_features import club_feature_enforcement_enabled
|
||||
|
||||
_cfe = os.getenv("CLUB_FEATURE_ENFORCE", "0")
|
||||
print(
|
||||
f"[OK] CLUB_FEATURE_ENFORCE raw={_cfe!r} "
|
||||
f"active={club_feature_enforcement_enabled()}"
|
||||
)
|
||||
|
||||
from routers.auth import limiter as auth_rate_limiter
|
||||
|
||||
# OpenAPI: in Produktion standardmäßig aus (Schema nicht öffentlich). Notfall: PUBLIC_OPENAPI=1
|
||||
|
|
@ -87,6 +109,34 @@ app.add_middleware(
|
|||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def account_onboarding_api_gate(request: Request, call_next):
|
||||
"""
|
||||
Phase A: Domänen-APIs für unverified / verified_pending_club sperren.
|
||||
Siehe account_onboarding_gate.py und MEMBERSHIP_RBAC_DECISIONS_2026-06.md §1.1
|
||||
"""
|
||||
from account_onboarding_gate import evaluate_request_gate
|
||||
|
||||
token = request.headers.get("x-auth-token") or request.headers.get("X-Auth-Token")
|
||||
allowed, reason, _state = evaluate_request_gate(
|
||||
token,
|
||||
request.url.path,
|
||||
request.method,
|
||||
)
|
||||
if not allowed:
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={
|
||||
"detail": (
|
||||
"Zugriff erst nach E-Mail-Bestätigung und Vereinsmitgliedschaft möglich. "
|
||||
"Du kannst einen Beitrittsantrag stellen oder dein Konto in den Einstellungen verwalten."
|
||||
),
|
||||
"reason": reason,
|
||||
},
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def add_api_security_headers(request: Request, call_next):
|
||||
"""Konsistente Basis-Header auch für rein JSON-Responses (MIME-Sniffing)."""
|
||||
|
|
@ -193,7 +243,7 @@ def read_root():
|
|||
return out
|
||||
|
||||
# Register routers
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, admin_user_content, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, club_creation_requests, admin_users, admin_user_content, admin_rights, me_entitlements, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(profiles.router)
|
||||
|
|
@ -202,8 +252,11 @@ 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(club_creation_requests.router)
|
||||
app.include_router(admin_users.router)
|
||||
app.include_router(admin_user_content.router)
|
||||
app.include_router(admin_rights.router)
|
||||
app.include_router(me_entitlements.router)
|
||||
app.include_router(platform_media_storage.router)
|
||||
app.include_router(media_assets.router)
|
||||
app.include_router(media_assets.admin_rights_router)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
-- Migration 078: Planungs-KI Phase F — Progressions-Roadmap Prompts (Zielanalyse + Roadmap)
|
||||
|
||||
INSERT INTO ai_prompts (
|
||||
slug, display_name, description, template,
|
||||
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||
)
|
||||
SELECT
|
||||
'planning_progression_goal_analysis',
|
||||
'Progressions-Roadmap Zielanalyse',
|
||||
'Phase A: Ist-/Soll-Zustand und Erfolgskriterien für einen Progressionsgraphen (ohne Gruppenkontext).',
|
||||
$t$Du bist Assistent für Kampfsport-Trainer und analysierst eine Anfrage für einen Progressionsgraphen.
|
||||
|
||||
Anfrage: {{goal_query}}
|
||||
Semantic Brief: {{semantic_brief_json}}
|
||||
|
||||
Wichtig: Keine Gruppenanalyse — nur didaktischer Pfad für die Technik/das Thema.
|
||||
|
||||
Antworte NUR mit JSON:
|
||||
{
|
||||
"primary_topic": "Mae Geri",
|
||||
"start_assumption": "Welche Voraussetzungen werden für den Einstieg angenommen",
|
||||
"target_state": "Konkreter Zielzustand der Progression",
|
||||
"success_criteria": ["messbare Kriterien"],
|
||||
"constraints": { "partner_required": false }
|
||||
}$t$,
|
||||
'training',
|
||||
'json',
|
||||
'{"type":"object","properties":{"primary_topic":{"type":"string"},"target_state":{"type":"string"},"success_criteria":{"type":"array"}}}'::jsonb,
|
||||
true,
|
||||
NULL,
|
||||
true,
|
||||
14
|
||||
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_progression_goal_analysis');
|
||||
|
||||
INSERT INTO ai_prompts (
|
||||
slug, display_name, description, template,
|
||||
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||
)
|
||||
SELECT
|
||||
'planning_progression_roadmap',
|
||||
'Progressions-Roadmap Major Steps',
|
||||
'Phase B: 8–12 micro_objectives, Konsolidierung auf N major_steps.',
|
||||
$t$Du bist Assistent für Kampfsport-Trainer und erstellst eine didaktische Roadmap für einen Progressionsgraphen.
|
||||
|
||||
Anfrage: {{goal_query}}
|
||||
Zielanalyse: {{goal_analysis_json}}
|
||||
Semantic Brief: {{semantic_brief_json}}
|
||||
Anzahl Major Steps (N): {{max_steps}}
|
||||
|
||||
Erzeuge zuerst 8–12 micro_objectives (phase, title, weight, depends_on), dann konsolidiere auf genau N major_steps.
|
||||
Phasen: einstieg, grundlage, vertiefung, anwendung, perfektion — in sinnvoller Reihenfolge (Grundlagen vor Perfektion).
|
||||
|
||||
Antworte NUR mit JSON:
|
||||
{
|
||||
"micro_objectives": [
|
||||
{ "id": "m1", "phase": "grundlage", "title": "…", "weight": 0.9, "depends_on": [] }
|
||||
],
|
||||
"major_steps": [
|
||||
{ "index": 0, "phase": "grundlage", "learning_goal": "…", "consolidates": ["m1","m2"], "rationale": "…" }
|
||||
],
|
||||
"consolidation_notes": ["…"]
|
||||
}$t$,
|
||||
'training',
|
||||
'json',
|
||||
'{"type":"object","properties":{"micro_objectives":{"type":"array"},"major_steps":{"type":"array"},"consolidation_notes":{"type":"array"}}}'::jsonb,
|
||||
true,
|
||||
NULL,
|
||||
true,
|
||||
15
|
||||
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_progression_roadmap');
|
||||
|
||||
UPDATE ai_prompts SET default_template = template
|
||||
WHERE slug IN ('planning_progression_goal_analysis', 'planning_progression_roadmap')
|
||||
AND (default_template IS NULL OR TRIM(default_template) = '');
|
||||
286
backend/migrations/078_club_features_and_plans.sql
Normal file
286
backend/migrations/078_club_features_and_plans.sql
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
-- Migration 078: Vereins-Feature-Registry (Mitai-v9c-Pattern) + club_plans/subscriptions
|
||||
-- Spez: .claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md (M1)
|
||||
-- Legacy 001 (SERIAL features, profile tier_limits) wird archiviert, nicht gelöscht.
|
||||
|
||||
-- ── 1. Legacy-Tabellen archivieren (nur alte Struktur) ─────────────────────
|
||||
DO $migration$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'features'
|
||||
) AND EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'features' AND column_name = 'name'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'features' AND column_name = 'limit_type'
|
||||
) THEN
|
||||
-- Nach abgebrochenem Erstversuch kann features_legacy_001 schon existieren
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'features_legacy_001'
|
||||
) THEN
|
||||
DROP TABLE features;
|
||||
ELSE
|
||||
ALTER TABLE features RENAME TO features_legacy_001;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'tier_limits'
|
||||
) AND EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'tier_limits' AND column_name = 'tier'
|
||||
) THEN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'tier_limits_legacy_001'
|
||||
) THEN
|
||||
DROP TABLE tier_limits;
|
||||
ELSE
|
||||
ALTER TABLE tier_limits RENAME TO tier_limits_legacy_001;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'user_feature_usage'
|
||||
) AND EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'user_feature_usage' AND column_name = 'profile_id'
|
||||
) THEN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'user_feature_usage_legacy_001'
|
||||
) THEN
|
||||
DROP TABLE user_feature_usage;
|
||||
ELSE
|
||||
ALTER TABLE user_feature_usage RENAME TO user_feature_usage_legacy_001;
|
||||
END IF;
|
||||
END IF;
|
||||
END
|
||||
$migration$;
|
||||
|
||||
-- ── 2. Feature-Registry (TEXT-PK, app=shinkan) ────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS features (
|
||||
id TEXT PRIMARY KEY,
|
||||
app TEXT NOT NULL DEFAULT 'shinkan',
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT NOT NULL DEFAULT 'content',
|
||||
limit_type TEXT NOT NULL DEFAULT 'count'
|
||||
CHECK (limit_type IN ('count', 'boolean')),
|
||||
reset_period TEXT NOT NULL DEFAULT 'never'
|
||||
CHECK (reset_period IN ('never', 'daily', 'monthly')),
|
||||
default_limit INTEGER,
|
||||
enforcement_subject TEXT NOT NULL DEFAULT 'club'
|
||||
CHECK (enforcement_subject IN ('club', 'profile', 'portal')),
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_features_app ON features(app) WHERE active = true;
|
||||
|
||||
-- ── 3. Vereins-Produkte ─────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS club_plans (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
price_monthly_cents INTEGER,
|
||||
price_yearly_cents INTEGER,
|
||||
stripe_price_id_monthly TEXT,
|
||||
stripe_price_id_yearly TEXT,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_plan_limits (
|
||||
id SERIAL PRIMARY KEY,
|
||||
plan_id TEXT NOT NULL REFERENCES club_plans(id) ON DELETE CASCADE,
|
||||
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
|
||||
limit_value INTEGER,
|
||||
UNIQUE (plan_id, feature_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_plan_limits_plan ON club_plan_limits(plan_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_subscriptions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||
plan_id TEXT NOT NULL REFERENCES club_plans(id),
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'trial', 'past_due', 'cancelled')),
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ends_at TIMESTAMPTZ,
|
||||
trial_ends_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (club_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_subscriptions_plan ON club_subscriptions(plan_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_feature_overrides (
|
||||
id SERIAL PRIMARY KEY,
|
||||
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
|
||||
limit_value INTEGER NOT NULL,
|
||||
reason TEXT,
|
||||
set_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (club_id, feature_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_access_grants (
|
||||
id SERIAL PRIMARY KEY,
|
||||
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||
plan_id TEXT REFERENCES club_plans(id) ON DELETE SET NULL,
|
||||
feature_id TEXT REFERENCES features(id) ON DELETE SET NULL,
|
||||
grant_limit INTEGER,
|
||||
starts_at TIMESTAMPTZ NOT NULL,
|
||||
ends_at TIMESTAMPTZ NOT NULL,
|
||||
reason TEXT,
|
||||
created_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_access_grants_club ON club_access_grants(club_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_club_access_grants_window ON club_access_grants(club_id, starts_at, ends_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_feature_usage (
|
||||
id SERIAL PRIMARY KEY,
|
||||
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
reset_at TIMESTAMPTZ,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (club_id, feature_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_feature_usage_club ON club_feature_usage(club_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_feature_usage_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
|
||||
profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_feature_usage_events_club
|
||||
ON club_feature_usage_events(club_id, created_at DESC);
|
||||
|
||||
-- ── 4. Seed: Features ─────────────────────────────────────────────────────
|
||||
INSERT INTO features (id, app, name, description, category, limit_type, reset_period, default_limit, enforcement_subject)
|
||||
VALUES
|
||||
('exercises', 'shinkan', 'Übungen', 'Anzahl Übungen im Verein (Bestand)', 'content', 'count', 'never', 100, 'club'),
|
||||
('exercise_media', 'shinkan', 'Medien-Uploads', 'Medien-Uploads pro Monat', 'content', 'count', 'monthly', 20, 'club'),
|
||||
('training_units', 'shinkan', 'Trainingseinheiten', 'Trainingseinheiten pro Monat', 'planning', 'count', 'monthly', 40, 'club'),
|
||||
('training_programs', 'shinkan', 'Trainingsprogramme', 'Module und Rahmenprogramme (Bestand)', 'planning', 'count', 'never', 5, 'club'),
|
||||
('training_groups', 'shinkan', 'Trainingsgruppen', 'Anzahl Trainingsgruppen', 'org', 'count', 'never', 10, 'club'),
|
||||
('active_members', 'shinkan', 'Aktive Mitglieder', 'Anzahl aktiver Vereinsmitglieder', 'org', 'count', 'never', 25, 'club'),
|
||||
('ai_calls', 'shinkan', 'KI-Aufrufe', 'KI-Aufrufe pro Monat (Suggest, Regenerate, Planung)', 'ai', 'count', 'monthly', 0, 'club'),
|
||||
('ai_pipeline', 'shinkan', 'KI-Pipeline', 'Erweiterte KI-Batch-Pipelines', 'ai', 'boolean', 'never', 0, 'club'),
|
||||
('wiki_import', 'shinkan', 'Wiki-Import', 'MediaWiki-Import (Plattform)', 'integration', 'boolean', 'never', 0, 'portal'),
|
||||
('data_export', 'shinkan', 'Daten-Export', 'Export-Funktionen', 'integration', 'boolean', 'never', 0, 'club')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ── 5. Seed: Pläne ──────────────────────────────────────────────────────────
|
||||
INSERT INTO club_plans (id, name, description, sort_order, active)
|
||||
VALUES
|
||||
('free', 'Free', 'Einstieg für Vereine', 0, true),
|
||||
('verein_starter', 'Verein Starter', 'Erweiterte Kontingente', 10, true),
|
||||
('verein_pro', 'Verein Pro', 'Hohe Limits und KI-Kontingent', 20, true),
|
||||
('pilot', 'Pilot', 'Pilotverein mit großzügigen Limits', 5, true)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Plan-Limits: free
|
||||
INSERT INTO club_plan_limits (plan_id, feature_id, limit_value)
|
||||
SELECT 'free', f.id,
|
||||
CASE f.id
|
||||
WHEN 'exercises' THEN 100
|
||||
WHEN 'exercise_media' THEN 20
|
||||
WHEN 'training_units' THEN 40
|
||||
WHEN 'training_programs' THEN 5
|
||||
WHEN 'training_groups' THEN 10
|
||||
WHEN 'active_members' THEN 25
|
||||
WHEN 'ai_calls' THEN 0
|
||||
WHEN 'ai_pipeline' THEN 0
|
||||
WHEN 'wiki_import' THEN 0
|
||||
WHEN 'data_export' THEN 0
|
||||
END
|
||||
FROM features f
|
||||
WHERE f.app = 'shinkan'
|
||||
ON CONFLICT (plan_id, feature_id) DO NOTHING;
|
||||
|
||||
-- Plan-Limits: verein_starter
|
||||
INSERT INTO club_plan_limits (plan_id, feature_id, limit_value)
|
||||
SELECT 'verein_starter', f.id,
|
||||
CASE f.id
|
||||
WHEN 'exercises' THEN 500
|
||||
WHEN 'exercise_media' THEN 80
|
||||
WHEN 'training_units' THEN 200
|
||||
WHEN 'training_programs' THEN 30
|
||||
WHEN 'training_groups' THEN 30
|
||||
WHEN 'active_members' THEN 80
|
||||
WHEN 'ai_calls' THEN 30
|
||||
WHEN 'ai_pipeline' THEN 0
|
||||
WHEN 'wiki_import' THEN 0
|
||||
WHEN 'data_export' THEN 1
|
||||
END
|
||||
FROM features f
|
||||
WHERE f.app = 'shinkan'
|
||||
ON CONFLICT (plan_id, feature_id) DO NOTHING;
|
||||
|
||||
-- Plan-Limits: verein_pro (NULL = unbegrenzt wo sinnvoll)
|
||||
INSERT INTO club_plan_limits (plan_id, feature_id, limit_value)
|
||||
SELECT 'verein_pro', f.id,
|
||||
CASE f.id
|
||||
WHEN 'exercises' THEN NULL
|
||||
WHEN 'exercise_media' THEN 300
|
||||
WHEN 'training_units' THEN NULL
|
||||
WHEN 'training_programs' THEN NULL
|
||||
WHEN 'training_groups' THEN NULL
|
||||
WHEN 'active_members' THEN NULL
|
||||
WHEN 'ai_calls' THEN 200
|
||||
WHEN 'ai_pipeline' THEN 1
|
||||
WHEN 'wiki_import' THEN 0
|
||||
WHEN 'data_export' THEN 1
|
||||
END
|
||||
FROM features f
|
||||
WHERE f.app = 'shinkan'
|
||||
ON CONFLICT (plan_id, feature_id) DO NOTHING;
|
||||
|
||||
-- Plan-Limits: pilot
|
||||
INSERT INTO club_plan_limits (plan_id, feature_id, limit_value)
|
||||
SELECT 'pilot', f.id,
|
||||
CASE f.id
|
||||
WHEN 'exercises' THEN NULL
|
||||
WHEN 'exercise_media' THEN NULL
|
||||
WHEN 'training_units' THEN NULL
|
||||
WHEN 'training_programs' THEN NULL
|
||||
WHEN 'training_groups' THEN NULL
|
||||
WHEN 'active_members' THEN NULL
|
||||
WHEN 'ai_calls' THEN 100
|
||||
WHEN 'ai_pipeline' THEN 1
|
||||
WHEN 'wiki_import' THEN 0
|
||||
WHEN 'data_export' THEN 1
|
||||
END
|
||||
FROM features f
|
||||
WHERE f.app = 'shinkan'
|
||||
ON CONFLICT (plan_id, feature_id) DO NOTHING;
|
||||
|
||||
-- ── 6. Backfill: bestehende Vereine → Plan free ───────────────────────────
|
||||
INSERT INTO club_subscriptions (club_id, plan_id, status)
|
||||
SELECT c.id, 'free', 'active'
|
||||
FROM clubs c
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM club_subscriptions cs WHERE cs.club_id = c.id
|
||||
);
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
-- Migration 079: Planungs-KI Phase F — Stufenspezifikation (Prompt in ai_prompts, nicht im Code)
|
||||
|
||||
INSERT INTO ai_prompts (
|
||||
slug, display_name, description, template,
|
||||
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||
)
|
||||
SELECT
|
||||
'planning_progression_stage_spec',
|
||||
'Progressions-Roadmap Stufenspezifikation',
|
||||
'Phase C: Belastungsprofil, Übungstyp und Erfolgskriterien je Major Step.',
|
||||
$t$Du bist Assistent für Kampfsport-Trainer und spezifizierst didaktische Stufen eines Progressionsgraphen.
|
||||
|
||||
Anfrage: {{goal_query}}
|
||||
Zielanalyse: {{goal_analysis_json}}
|
||||
Major Steps: {{major_steps_json}}
|
||||
|
||||
Für jeden Major Step: messbares Lernziel, load_profile (z. B. koordination, präzision, kraft), exercise_type (kihon_einzel, partner_drill, kombination, kraft_auxiliary), success_criteria, anti_patterns (z. B. reine Kraft ohne Technikbezug).
|
||||
|
||||
Antworte NUR mit JSON:
|
||||
{
|
||||
"stage_specs": [
|
||||
{
|
||||
"major_step_index": 0,
|
||||
"learning_goal": "…",
|
||||
"load_profile": ["koordination", "gleichgewicht"],
|
||||
"exercise_type": "kihon_einzel",
|
||||
"success_criteria": ["…"],
|
||||
"anti_patterns": ["…"]
|
||||
}
|
||||
]
|
||||
}$t$,
|
||||
'training',
|
||||
'json',
|
||||
'{"type":"object","properties":{"stage_specs":{"type":"array"}}}'::jsonb,
|
||||
true,
|
||||
NULL,
|
||||
true,
|
||||
16
|
||||
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_progression_stage_spec');
|
||||
|
||||
UPDATE ai_prompts SET default_template = template
|
||||
WHERE slug = 'planning_progression_stage_spec'
|
||||
AND (default_template IS NULL OR TRIM(default_template) = '');
|
||||
225
backend/migrations/079_capabilities.sql
Normal file
225
backend/migrations/079_capabilities.sql
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
-- Migration 079: Capability-Registry + Rollen-Grants (M3 / CAPABILITY_CATALOG.v1.md C1)
|
||||
-- Account-Gates und Enforcement in Python (account_lifecycle.py, capabilities.py).
|
||||
-- Voraussetzung: Migration 078 (features.id TEXT). Kein FK auf features — vermeidet
|
||||
-- Startup-Abbruch wenn 078 noch aussteht oder features-Schema driftet (001 vs v9c).
|
||||
|
||||
DO $migration$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'features' AND column_name = 'limit_type'
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'Migration 079: features-Tabelle nicht v9c (limit_type fehlt). Zuerst 078_club_features_and_plans anwenden.';
|
||||
END IF;
|
||||
END
|
||||
$migration$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS capabilities (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
domain TEXT NOT NULL,
|
||||
min_account_state TEXT NOT NULL DEFAULT 'active_member'
|
||||
CHECK (min_account_state IN (
|
||||
'unverified', 'verified_pending_club', 'active_member', 'platform_admin'
|
||||
)),
|
||||
linked_feature_id TEXT,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_capabilities_domain ON capabilities(domain) WHERE active = true;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_role_capability_grants (
|
||||
role_code TEXT NOT NULL,
|
||||
capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (role_code, capability_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_role_cap_grants_cap ON club_role_capability_grants(capability_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portal_role_capability_grants (
|
||||
portal_role TEXT NOT NULL,
|
||||
capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (portal_role, capability_id)
|
||||
);
|
||||
|
||||
-- ── Seed: Capabilities (v1 Katalog §5) ───────────────────────────────────────
|
||||
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id) VALUES
|
||||
('account.settings.read', 'Einstellungen lesen', 'account', 'unverified', NULL),
|
||||
('account.settings.update', 'Einstellungen ändern', 'account', 'unverified', NULL),
|
||||
('account.password.change', 'Passwort ändern', 'account', 'unverified', NULL),
|
||||
('account.resend_verification', 'Verifizierung erneut senden', 'account', 'unverified', NULL),
|
||||
('club.directory.read', 'Vereinsverzeichnis', 'club', 'verified_pending_club', NULL),
|
||||
('club.join_request.create', 'Vereinsbeitritt beantragen', 'club', 'verified_pending_club', NULL),
|
||||
('club.join_request.withdraw', 'Beitrittsantrag zurückziehen', 'club', 'verified_pending_club', NULL),
|
||||
('club.join_request.read_own', 'Eigene Beitrittsanträge', 'club', 'verified_pending_club', NULL),
|
||||
('org.club.read', 'Vereine lesen', 'org', 'active_member', NULL),
|
||||
('org.club.create', 'Verein anlegen', 'org', 'platform_admin', NULL),
|
||||
('org.club.update', 'Verein bearbeiten', 'org', 'active_member', NULL),
|
||||
('org.club.delete', 'Verein löschen', 'org', 'platform_admin', NULL),
|
||||
('org.structure.manage', 'Vereinsstruktur verwalten', 'org', 'active_member', 'training_groups'),
|
||||
('org.members.read', 'Mitgliederliste', 'org', 'active_member', NULL),
|
||||
('org.members.manage', 'Mitglieder verwalten', 'org', 'active_member', 'active_members'),
|
||||
('org.members.directory', 'Mitglieder-Verzeichnis', 'org', 'active_member', NULL),
|
||||
('org.join_request.review', 'Beitrittsanträge prüfen', 'org', 'active_member', NULL),
|
||||
('org.inbox.read', 'Posteingang', 'org', 'active_member', NULL),
|
||||
('exercises.read', 'Übungen lesen', 'exercises', 'active_member', NULL),
|
||||
('exercises.create', 'Übung anlegen', 'exercises', 'active_member', 'exercises'),
|
||||
('exercises.update', 'Übung bearbeiten', 'exercises', 'active_member', NULL),
|
||||
('exercises.delete', 'Übung löschen', 'exercises', 'active_member', NULL),
|
||||
('exercises.bulk_metadata', 'Übungen Stapel-Metadaten', 'exercises', 'active_member', NULL),
|
||||
('exercises.ai.suggest', 'KI-Vorschlag Übung', 'exercises', 'active_member', 'ai_calls'),
|
||||
('exercises.ai.regenerate', 'KI neu generieren', 'exercises', 'active_member', 'ai_calls'),
|
||||
('exercises.media.read', 'Übungsmedien lesen', 'exercises', 'active_member', NULL),
|
||||
('exercises.media.upload', 'Übungsmedien hochladen', 'exercises', 'active_member', 'exercise_media'),
|
||||
('exercises.variants.manage', 'Übungsvarianten', 'exercises', 'active_member', NULL),
|
||||
('media.library.read', 'Medienbibliothek lesen', 'media', 'active_member', NULL),
|
||||
('media.library.upload', 'Medienbibliothek Upload', 'media', 'active_member', 'exercise_media'),
|
||||
('media.library.update', 'Medienbibliothek bearbeiten', 'media', 'active_member', NULL),
|
||||
('media.library.lifecycle', 'Medien-Lifecycle', 'media', 'active_member', NULL),
|
||||
('media.rights.declare', 'Medienrechte erklären', 'media', 'active_member', NULL),
|
||||
('media.admin.rights_review', 'Medienrechte Review (Plattform)', 'media', 'platform_admin', NULL),
|
||||
('modules.read', 'Trainingsmodule lesen', 'modules', 'active_member', NULL),
|
||||
('modules.create', 'Trainingsmodul anlegen', 'modules', 'active_member', 'training_programs'),
|
||||
('modules.update', 'Trainingsmodul bearbeiten', 'modules', 'active_member', NULL),
|
||||
('modules.delete', 'Trainingsmodul löschen', 'modules', 'active_member', NULL),
|
||||
('framework.read', 'Rahmenprogramme lesen', 'framework', 'active_member', NULL),
|
||||
('framework.create', 'Rahmenprogramm anlegen', 'framework', 'active_member', 'training_programs'),
|
||||
('framework.update', 'Rahmenprogramm bearbeiten', 'framework', 'active_member', NULL),
|
||||
('framework.delete', 'Rahmenprogramm löschen', 'framework', 'active_member', NULL),
|
||||
('plan_templates.read', 'Planungsvorlagen lesen', 'planning', 'active_member', NULL),
|
||||
('plan_templates.manage', 'Planungsvorlagen verwalten', 'planning', 'active_member', NULL),
|
||||
('progression.read', 'Progressionspfade lesen', 'progression', 'active_member', NULL),
|
||||
('progression.manage', 'Progressionspfade verwalten', 'progression', 'active_member', NULL),
|
||||
('planning.calendar.read', 'Planungskalender lesen', 'planning', 'active_member', NULL),
|
||||
('planning.units.create', 'Trainingseinheit anlegen', 'planning', 'active_member', 'training_units'),
|
||||
('planning.units.update', 'Trainingseinheit bearbeiten', 'planning', 'active_member', NULL),
|
||||
('planning.units.delete', 'Trainingseinheit löschen', 'planning', 'active_member', NULL),
|
||||
('planning.units.run', 'Training durchführen', 'planning', 'active_member', NULL),
|
||||
('planning.coach.execute', 'Coach ausführen', 'planning', 'active_member', NULL),
|
||||
('planning.ai.suggest', 'Planungs-KI Suggest', 'planning', 'active_member', 'ai_calls'),
|
||||
('planning.ai.progression_path', 'Planungs-KI Progressionspfad', 'planning', 'active_member', 'ai_calls'),
|
||||
('skills.catalog.read', 'Fähigkeitenkatalog', 'skills', 'active_member', NULL),
|
||||
('skills.discovery.read', 'Fähigkeiten-Discovery', 'skills', 'active_member', NULL),
|
||||
('skill_profiles.read', 'Skill-Profile lesen', 'skills', 'active_member', NULL),
|
||||
('governance.content_report.create', 'Inhalt melden', 'governance', 'active_member', NULL),
|
||||
('governance.content_report.review', 'Meldungen prüfen', 'governance', 'active_member', NULL),
|
||||
('platform.admin.access', 'Plattform-Admin-Bereich', 'platform', 'platform_admin', NULL),
|
||||
('platform.users.manage', 'Nutzer verwalten', 'platform', 'platform_admin', NULL),
|
||||
('platform.catalogs.manage', 'Kataloge verwalten', 'platform', 'platform_admin', NULL),
|
||||
('platform.maturity_models.manage', 'Reifegradmodelle', 'platform', 'platform_admin', NULL),
|
||||
('platform.wiki_import.execute', 'Wiki-Import', 'platform', 'platform_admin', 'wiki_import'),
|
||||
('platform.ai_prompts.manage', 'KI-Prompts verwalten', 'platform', 'platform_admin', NULL),
|
||||
('platform.exercise_enrichment.execute', 'Übungs-Anreicherung KI', 'platform', 'platform_admin', 'ai_calls'),
|
||||
('platform.user_content.moderate', 'Nutzer-Inhalte moderieren', 'platform', 'platform_admin', NULL),
|
||||
('platform.legal_documents.manage', 'Rechtstexte verwalten', 'platform', 'platform_admin', NULL),
|
||||
('platform.media_storage.manage', 'Medienspeicher verwalten', 'platform', 'platform_admin', NULL),
|
||||
('platform.club_creation.approve', 'Vereinsgründung freigeben', 'platform', 'platform_admin', NULL)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ── Vereinsrollen-Grants (§6 — nur eingeschränkte Capabilities) ─────────────
|
||||
-- Konvention: keine Grant-Zeile = alle aktiven Mitglieder (min_account_state reicht).
|
||||
|
||||
INSERT INTO club_role_capability_grants (role_code, capability_id)
|
||||
SELECT r.role_code, c.id
|
||||
FROM (VALUES
|
||||
('club_admin', 'org.structure.manage'),
|
||||
('division_lead', 'org.structure.manage'),
|
||||
('club_admin', 'org.members.manage'),
|
||||
('club_admin', 'org.join_request.review'),
|
||||
('club_admin', 'org.inbox.read'),
|
||||
('club_admin', 'exercises.create'),
|
||||
('trainer', 'exercises.create'),
|
||||
('content_editor', 'exercises.create'),
|
||||
('division_lead', 'exercises.create'),
|
||||
('club_admin', 'exercises.update'),
|
||||
('trainer', 'exercises.update'),
|
||||
('content_editor', 'exercises.update'),
|
||||
('division_lead', 'exercises.update'),
|
||||
('club_admin', 'exercises.delete'),
|
||||
('club_admin', 'exercises.bulk_metadata'),
|
||||
('content_editor', 'exercises.bulk_metadata'),
|
||||
('club_admin', 'exercises.ai.suggest'),
|
||||
('trainer', 'exercises.ai.suggest'),
|
||||
('content_editor', 'exercises.ai.suggest'),
|
||||
('division_lead', 'exercises.ai.suggest'),
|
||||
('club_admin', 'exercises.ai.regenerate'),
|
||||
('trainer', 'exercises.ai.regenerate'),
|
||||
('content_editor', 'exercises.ai.regenerate'),
|
||||
('division_lead', 'exercises.ai.regenerate'),
|
||||
('club_admin', 'exercises.media.upload'),
|
||||
('trainer', 'exercises.media.upload'),
|
||||
('content_editor', 'exercises.media.upload'),
|
||||
('club_admin', 'exercises.variants.manage'),
|
||||
('trainer', 'exercises.variants.manage'),
|
||||
('content_editor', 'exercises.variants.manage'),
|
||||
('club_admin', 'media.library.upload'),
|
||||
('trainer', 'media.library.upload'),
|
||||
('content_editor', 'media.library.upload'),
|
||||
('club_admin', 'media.library.update'),
|
||||
('trainer', 'media.library.update'),
|
||||
('content_editor', 'media.library.update'),
|
||||
('club_admin', 'media.library.lifecycle'),
|
||||
('trainer', 'media.library.lifecycle'),
|
||||
('club_admin', 'media.rights.declare'),
|
||||
('trainer', 'media.rights.declare'),
|
||||
('club_admin', 'modules.create'),
|
||||
('trainer', 'modules.create'),
|
||||
('content_editor', 'modules.create'),
|
||||
('club_admin', 'modules.update'),
|
||||
('trainer', 'modules.update'),
|
||||
('content_editor', 'modules.update'),
|
||||
('club_admin', 'modules.delete'),
|
||||
('club_admin', 'framework.create'),
|
||||
('trainer', 'framework.create'),
|
||||
('club_admin', 'framework.update'),
|
||||
('trainer', 'framework.update'),
|
||||
('club_admin', 'framework.delete'),
|
||||
('club_admin', 'plan_templates.manage'),
|
||||
('trainer', 'plan_templates.manage'),
|
||||
('club_admin', 'progression.manage'),
|
||||
('trainer', 'progression.manage'),
|
||||
('content_editor', 'progression.manage'),
|
||||
('club_admin', 'planning.units.create'),
|
||||
('trainer', 'planning.units.create'),
|
||||
('division_lead', 'planning.units.create'),
|
||||
('club_admin', 'planning.units.update'),
|
||||
('trainer', 'planning.units.update'),
|
||||
('division_lead', 'planning.units.update'),
|
||||
('club_admin', 'planning.units.delete'),
|
||||
('trainer', 'planning.units.delete'),
|
||||
('club_admin', 'planning.units.run'),
|
||||
('trainer', 'planning.units.run'),
|
||||
('division_lead', 'planning.units.run'),
|
||||
('club_admin', 'planning.coach.execute'),
|
||||
('trainer', 'planning.coach.execute'),
|
||||
('club_admin', 'planning.ai.suggest'),
|
||||
('trainer', 'planning.ai.suggest'),
|
||||
('division_lead', 'planning.ai.suggest'),
|
||||
('club_admin', 'planning.ai.progression_path'),
|
||||
('trainer', 'planning.ai.progression_path'),
|
||||
('division_lead', 'planning.ai.progression_path'),
|
||||
('club_admin', 'skills.discovery.read'),
|
||||
('trainer', 'skills.discovery.read'),
|
||||
('content_editor', 'skills.discovery.read'),
|
||||
('club_admin', 'governance.content_report.review')
|
||||
) AS r(role_code, cap_id)
|
||||
JOIN capabilities c ON c.id = r.cap_id
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- org.club.update: club_admin (zusätzlich zu platform_admin via Bypass)
|
||||
INSERT INTO club_role_capability_grants (role_code, capability_id)
|
||||
VALUES ('club_admin', 'org.club.update')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ── Portal-Rollen ───────────────────────────────────────────────────────────
|
||||
INSERT INTO portal_role_capability_grants (portal_role, capability_id)
|
||||
SELECT 'admin', id FROM capabilities WHERE id = 'platform.admin.access'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO portal_role_capability_grants (portal_role, capability_id)
|
||||
SELECT 'superadmin', id FROM capabilities WHERE domain = 'platform'
|
||||
ON CONFLICT DO NOTHING;
|
||||
41
backend/migrations/080_club_creation_requests.sql
Normal file
41
backend/migrations/080_club_creation_requests.sql
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
-- Migration 080: Antrag auf Vereinsgründung (M7)
|
||||
-- Nutzer verified_pending_club stellt Antrag; Plattform-Admin legt Verein + Abo an.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_creation_requests (
|
||||
id SERIAL PRIMARY KEY,
|
||||
profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
proposed_name VARCHAR(200) NOT NULL,
|
||||
proposed_abbreviation VARCHAR(50),
|
||||
proposed_description TEXT,
|
||||
message TEXT,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'approved', 'rejected', 'withdrawn')),
|
||||
decided_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||
decided_at TIMESTAMP,
|
||||
created_club_id INT REFERENCES clubs(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_creation_requests_pending
|
||||
ON club_creation_requests (profile_id)
|
||||
WHERE status = 'pending';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_creation_requests_status
|
||||
ON club_creation_requests (status, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_creation_requests_profile
|
||||
ON club_creation_requests (profile_id);
|
||||
|
||||
DROP TRIGGER IF EXISTS club_creation_requests_update ON club_creation_requests;
|
||||
CREATE TRIGGER club_creation_requests_update
|
||||
BEFORE UPDATE ON club_creation_requests
|
||||
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
|
||||
|
||||
-- Capabilities (CAPABILITY_CATALOG.v1.md — club.creation_request.*)
|
||||
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id)
|
||||
VALUES
|
||||
('club.creation_request.create', 'Vereinsgründung beantragen', 'club', 'verified_pending_club', NULL),
|
||||
('club.creation_request.read_own', 'Eigene Gründungsanträge', 'club', 'verified_pending_club', NULL),
|
||||
('club.creation_request.withdraw', 'Gründungsantrag zurückziehen', 'club', 'verified_pending_club', NULL)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
13
backend/migrations/081_club_creation_request_superseded.sql
Normal file
13
backend/migrations/081_club_creation_request_superseded.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
-- Migration 081: Status superseded wenn freigegebener Verein gelöscht wurde
|
||||
|
||||
ALTER TABLE club_creation_requests
|
||||
DROP CONSTRAINT IF EXISTS club_creation_requests_status_check;
|
||||
|
||||
ALTER TABLE club_creation_requests
|
||||
ADD CONSTRAINT club_creation_requests_status_check
|
||||
CHECK (status IN ('pending', 'approved', 'rejected', 'withdrawn', 'superseded'));
|
||||
|
||||
-- Bestehende Drift: approved ohne Verein (ON DELETE SET NULL auf created_club_id)
|
||||
UPDATE club_creation_requests
|
||||
SET status = 'superseded', updated_at = NOW()
|
||||
WHERE status = 'approved' AND created_club_id IS NULL;
|
||||
36
backend/migrations/082_platform_club_feature_exemptions.sql
Normal file
36
backend/migrations/082_platform_club_feature_exemptions.sql
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
-- Migration 082: Plattform-/Profil-Ausnahmen vom Vereins-Kontingent (M5+)
|
||||
-- Superadmin & konfigurierbare Rollen/Profile verbrauchen kein club_feature_usage.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform_role_club_feature_exemptions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
portal_role TEXT NOT NULL,
|
||||
feature_id TEXT REFERENCES features(id) ON DELETE CASCADE,
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_platform_role_club_feat_exempt
|
||||
ON platform_role_club_feature_exemptions (portal_role, COALESCE(feature_id, '*'));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profile_club_feature_exemptions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
feature_id TEXT REFERENCES features(id) ON DELETE CASCADE,
|
||||
reason TEXT,
|
||||
set_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_profile_club_feat_exempt
|
||||
ON profile_club_feature_exemptions (profile_id, COALESCE(feature_id, '*'));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_profile_club_feat_exempt_profile
|
||||
ON profile_club_feature_exemptions (profile_id);
|
||||
|
||||
-- Superadmin: alle Vereins-Features ohne Kontingent-Verbrauch
|
||||
INSERT INTO platform_role_club_feature_exemptions (portal_role, feature_id, note)
|
||||
SELECT 'superadmin', NULL, 'Plattform-Administrator: kein Vereins-Kontingent'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM platform_role_club_feature_exemptions
|
||||
WHERE portal_role = 'superadmin' AND feature_id IS NULL
|
||||
);
|
||||
103
backend/migrations/083_capability_quota_bypass.sql
Normal file
103
backend/migrations/083_capability_quota_bypass.sql
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
-- Migration 083: Vereins-Kontingent-Bypass über Capability-System (kein Parallel-Schema)
|
||||
-- Ersetzt platform_role_club_feature_exemptions / profile_club_feature_exemptions aus 082.
|
||||
|
||||
-- Einzelprofil-Grants (ergänzt portal_role_capability_grants)
|
||||
CREATE TABLE IF NOT EXISTS profile_capability_grants (
|
||||
profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
|
||||
reason TEXT,
|
||||
granted_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (profile_id, capability_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_profile_capability_grants_cap
|
||||
ON profile_capability_grants(capability_id);
|
||||
|
||||
-- Bypass-Capabilities (CAPABILITY_CATALOG — konfigurierbar via portal/profile grants)
|
||||
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id)
|
||||
VALUES
|
||||
(
|
||||
'platform.club_quota.bypass',
|
||||
'Vereins-Kontingent umgehen (alle Features)',
|
||||
'platform',
|
||||
'platform_admin',
|
||||
NULL
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Superadmin: alle Plattform-Capabilities inkl. bypass (079-Seed deckt domain=platform ab)
|
||||
INSERT INTO portal_role_capability_grants (portal_role, capability_id)
|
||||
SELECT 'superadmin', 'platform.club_quota.bypass'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM portal_role_capability_grants
|
||||
WHERE portal_role = 'superadmin' AND capability_id = 'platform.club_quota.bypass'
|
||||
);
|
||||
|
||||
-- ── Daten aus 082 übernehmen (falls vorhanden) ─────────────────────────────
|
||||
DO $migrate082$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
cap_id TEXT;
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'platform_role_club_feature_exemptions'
|
||||
) THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
FOR r IN
|
||||
SELECT portal_role, feature_id, note
|
||||
FROM platform_role_club_feature_exemptions
|
||||
LOOP
|
||||
IF r.feature_id IS NULL THEN
|
||||
cap_id := 'platform.club_quota.bypass';
|
||||
ELSE
|
||||
cap_id := 'platform.club_quota.bypass.' || r.feature_id;
|
||||
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id)
|
||||
VALUES (
|
||||
cap_id,
|
||||
'Vereins-Kontingent umgehen: ' || r.feature_id,
|
||||
'quota_bypass',
|
||||
'active_member',
|
||||
r.feature_id
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
END IF;
|
||||
|
||||
INSERT INTO portal_role_capability_grants (portal_role, capability_id)
|
||||
VALUES (lower(trim(r.portal_role)), cap_id)
|
||||
ON CONFLICT DO NOTHING;
|
||||
END LOOP;
|
||||
|
||||
FOR r IN
|
||||
SELECT profile_id, feature_id, reason, set_by_profile_id
|
||||
FROM profile_club_feature_exemptions
|
||||
LOOP
|
||||
IF r.feature_id IS NULL THEN
|
||||
cap_id := 'platform.club_quota.bypass';
|
||||
ELSE
|
||||
cap_id := 'platform.club_quota.bypass.' || r.feature_id;
|
||||
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id)
|
||||
VALUES (
|
||||
cap_id,
|
||||
'Vereins-Kontingent umgehen: ' || r.feature_id,
|
||||
'quota_bypass',
|
||||
'active_member',
|
||||
r.feature_id
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
END IF;
|
||||
|
||||
INSERT INTO profile_capability_grants (
|
||||
profile_id, capability_id, reason, granted_by_profile_id
|
||||
)
|
||||
VALUES (r.profile_id, cap_id, r.reason, r.set_by_profile_id)
|
||||
ON CONFLICT DO NOTHING;
|
||||
END LOOP;
|
||||
|
||||
DROP TABLE IF EXISTS profile_club_feature_exemptions;
|
||||
DROP TABLE IF EXISTS platform_role_club_feature_exemptions;
|
||||
END
|
||||
$migrate082$;
|
||||
15
backend/migrations/084_rights_registry_module.sql
Normal file
15
backend/migrations/084_rights_registry_module.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
-- Migration 084: Modul-Registrierung für Rechte & Kontingente (Registry-first)
|
||||
-- capabilities/features mit module=NULL = Legacy-Katalog-Seed (nicht in Admin-Matrix).
|
||||
-- module IS NOT NULL = vom Modul bei Implementierung registriert.
|
||||
|
||||
ALTER TABLE capabilities
|
||||
ADD COLUMN IF NOT EXISTS module TEXT;
|
||||
|
||||
ALTER TABLE features
|
||||
ADD COLUMN IF NOT EXISTS module TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_capabilities_module
|
||||
ON capabilities(module) WHERE module IS NOT NULL AND active = true;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_features_module
|
||||
ON features(module) WHERE module IS NOT NULL AND active = true;
|
||||
181
backend/migrations/085_ai_prompt_exercise_planning_context.sql
Normal file
181
backend/migrations/085_ai_prompt_exercise_planning_context.sql
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
-- Migration 085: Planungskontext in Übungs-KI-Prompts (Phase D)
|
||||
-- Platzhalter: {{planning_context_json}}, {{#has_planning_context}} … {{/has_planning_context}}
|
||||
|
||||
UPDATE ai_prompts
|
||||
SET template = $s$Du bist Assistent fuer Kampfsport-Trainer.
|
||||
Erstelle eine kurze Kurzbeschreibung fuer Listen und Trainingsplaene.
|
||||
|
||||
Anforderungen:
|
||||
- Hochstens etwa 200 Zeichen (bei Bedarf gekuerzt fuer Mobile)
|
||||
- Kern: Welche Trainingsqualitaeten? Wie fuehrt man die Uebung kurz aus?
|
||||
- Sachlich, auf Deutsch
|
||||
|
||||
Uebung: {{exercise_title}}
|
||||
Fokuskontext: {{exercise_focus_area}}
|
||||
Ziel (Fliesstext, kann HTML sein): {{exercise_goal}}
|
||||
Durchfuehrung (Fliesstext, kann HTML sein): {{exercise_execution}}
|
||||
{{#has_planning_context}}
|
||||
Planungskontext (JSON — Einordnung in Trainingsplan oder Progressionspfad):
|
||||
{{planning_context_json}}
|
||||
{{/has_planning_context}}
|
||||
|
||||
Antworte NUR mit der Kurzbeschreibung als einfachen Text (keine Markdown-Codeblocks, keine Anfuehrungszeichen um den ganzen Text).$s$,
|
||||
default_template = $s$Du bist Assistent fuer Kampfsport-Trainer.
|
||||
Erstelle eine kurze Kurzbeschreibung fuer Listen und Trainingsplaene.
|
||||
|
||||
Anforderungen:
|
||||
- Hochstens etwa 200 Zeichen (bei Bedarf gekuerzt fuer Mobile)
|
||||
- Kern: Welche Trainingsqualitaeten? Wie fuehrt man die Uebung kurz aus?
|
||||
- Sachlich, auf Deutsch
|
||||
|
||||
Uebung: {{exercise_title}}
|
||||
Fokuskontext: {{exercise_focus_area}}
|
||||
Ziel (Fliesstext, kann HTML sein): {{exercise_goal}}
|
||||
Durchfuehrung (Fliesstext, kann HTML sein): {{exercise_execution}}
|
||||
{{#has_planning_context}}
|
||||
Planungskontext (JSON — Einordnung in Trainingsplan oder Progressionspfad):
|
||||
{{planning_context_json}}
|
||||
{{/has_planning_context}}
|
||||
|
||||
Antworte NUR mit der Kurzbeschreibung als einfachen Text (keine Markdown-Codeblocks, keine Anfuehrungszeichen um den ganzen Text).$s$
|
||||
WHERE slug = 'exercise_summary';
|
||||
|
||||
UPDATE ai_prompts
|
||||
SET template = $j$Du bist Assistent fuer Kampfsport-Trainer.
|
||||
Ordne diese Uebung dem globalen Skill-Katalog zu.
|
||||
|
||||
Daten zur Uebung:
|
||||
Titel: {{exercise_title}}
|
||||
Fokuskontext (optional): {{exercise_focus_area}}
|
||||
Ziel (gekuerzt_plain): {{exercise_goal}}
|
||||
Durchfuehrung (gekuerzt_plain): {{exercise_execution}}
|
||||
{{#has_planning_context}}
|
||||
Planungskontext (JSON):
|
||||
{{planning_context_json}}
|
||||
{{/has_planning_context}}
|
||||
|
||||
Verfuegbare Faehigkeiten (Auswahl NUR ueber diese IDs — keine anderen IDs verwenden):
|
||||
{{skills_catalog}}
|
||||
|
||||
Waehle hoechstens 5 passende Skills. Für jede Faehigkeit:
|
||||
- skill_id: ganze Zahl aus der Liste
|
||||
- required_level: eines von basis, grundlagen, aufbau, fortgeschritten, optimierung
|
||||
- target_level: derselbe Wertvorrat
|
||||
- intensity: eines von niedrig, mittel, hoch
|
||||
- is_primary (optional): true fuer die Hauptfaehigkeit der Uebung, sondern false/weglassen
|
||||
|
||||
Antworte NUR mit einem JSON-Array ohne Erklaertext, keine Markdown-Fences.
|
||||
|
||||
Beispielformat:
|
||||
[{"skill_id": 1, "required_level": "grundlagen", "target_level": "aufbau", "intensity": "hoch", "is_primary": true}]
|
||||
|
||||
Wenn nichts gut passt, antworte mit [].$j$,
|
||||
default_template = $j$Du bist Assistent fuer Kampfsport-Trainer.
|
||||
Ordne diese Uebung dem globalen Skill-Katalog zu.
|
||||
|
||||
Daten zur Uebung:
|
||||
Titel: {{exercise_title}}
|
||||
Fokuskontext (optional): {{exercise_focus_area}}
|
||||
Ziel (gekuerzt_plain): {{exercise_goal}}
|
||||
Durchfuehrung (gekuerzt_plain): {{exercise_execution}}
|
||||
{{#has_planning_context}}
|
||||
Planungskontext (JSON):
|
||||
{{planning_context_json}}
|
||||
{{/has_planning_context}}
|
||||
|
||||
Verfuegbare Faehigkeiten (Auswahl NUR ueber diese IDs — keine anderen IDs verwenden):
|
||||
{{skills_catalog}}
|
||||
|
||||
Waehle hoechstens 5 passende Skills. Für jede Faehigkeit:
|
||||
- skill_id: ganze Zahl aus der Liste
|
||||
- required_level: eines von basis, grundlagen, aufbau, fortgeschritten, optimierung
|
||||
- target_level: derselbe Wertvorrat
|
||||
- intensity: eines von niedrig, mittel, hoch
|
||||
- is_primary (optional): true fuer die Hauptfaehigkeit der Uebung, sondern false/weglassen
|
||||
|
||||
Antworte NUR mit einem JSON-Array ohne Erklaertext, keine Markdown-Fences.
|
||||
|
||||
Beispielformat:
|
||||
[{"skill_id": 1, "required_level": "grundlagen", "target_level": "aufbau", "intensity": "hoch", "is_primary": true}]
|
||||
|
||||
Wenn nichts gut passt, antworte mit [].$j$
|
||||
WHERE slug = 'exercise_skill_suggestions';
|
||||
|
||||
UPDATE ai_prompts
|
||||
SET template = $t$Du bist Assistent fuer Kampfsport-Trainer.
|
||||
Ueberarbeite die Anleitung dieser Uebung: verbessere Formulierung, ergaenze fehlende Kernpunkte, kuerze ueberfluessige Passagen.
|
||||
Wichtig: Texte sollen praezise und nachvollziehbar bleiben — keine Fuellsaetze, keine Wiederholungen, kein Marketing.
|
||||
|
||||
Stil:
|
||||
- Deutsch, sachlich, direkt an Trainer gerichtet (Durchfuehrung: Imperativ oder klare Schritte)
|
||||
- Ziel: 1–3 kurze Absaetze (Kern des Trainingsziels)
|
||||
- Durchfuehrung: klare Schritte (nummerierte Liste oder kurze Absaetze)
|
||||
- Vorbereitung/Aufbau: nur wenn noetig (Raum, Material, Aufbau) — sonst leerer String
|
||||
- Trainer-Hinweise: Sicherheit, typische Fehler, Coaching-Tipps — knapp, Stichpunkte oder kurze Absaetze
|
||||
|
||||
Format (HTML fuer Rich-Text-Editor):
|
||||
- Erlaubt: <p>, <ul>, <ol>, <li>, <strong>, <em>, <br>
|
||||
- Keine Ueberschriften (h1–h6), keine Tabellen, kein Markdown, keine Code-Fences
|
||||
- Medienverweise {{exerciseMedia:ID}} aus den Eingabetexten UNVERAENDERT an passender Stelle uebernehmen
|
||||
|
||||
Eingabe:
|
||||
Titel: {{exercise_title}}
|
||||
Fokuskontext: {{exercise_focus_area}}
|
||||
|
||||
Ziel (Plaintext, Ausgang): {{exercise_goal}}
|
||||
Durchfuehrung (Plaintext, Ausgang): {{exercise_execution}}
|
||||
Vorbereitung/Aufbau (Plaintext, Ausgang): {{exercise_preparation}}
|
||||
Trainer-Hinweise (Plaintext, Ausgang): {{exercise_trainer_notes}}
|
||||
{{#has_planning_context}}
|
||||
Planungskontext (JSON):
|
||||
{{planning_context_json}}
|
||||
{{/has_planning_context}}
|
||||
|
||||
Antworte NUR mit einem JSON-Objekt (kein Text davor/danach):
|
||||
{
|
||||
"goal": "<p>…</p>",
|
||||
"execution": "<ol><li>…</li></ol>",
|
||||
"preparation": "<p>…</p> oder \"\"",
|
||||
"trainer_notes": "<ul><li>…</li></ul> oder \"\""
|
||||
}
|
||||
|
||||
Leere Felder als leerer String "" wenn nichts Sinnvolles ergibt.$t$,
|
||||
default_template = $t$Du bist Assistent fuer Kampfsport-Trainer.
|
||||
Ueberarbeite die Anleitung dieser Uebung: verbessere Formulierung, ergaenze fehlende Kernpunkte, kuerze ueberfluessige Passagen.
|
||||
Wichtig: Texte sollen praezise und nachvollziehbar bleiben — keine Fuellsaetze, keine Wiederholungen, kein Marketing.
|
||||
|
||||
Stil:
|
||||
- Deutsch, sachlich, direkt an Trainer gerichtet (Durchfuehrung: Imperativ oder klare Schritte)
|
||||
- Ziel: 1–3 kurze Absaetze (Kern des Trainingsziels)
|
||||
- Durchfuehrung: klare Schritte (nummerierte Liste oder kurze Absaetze)
|
||||
- Vorbereitung/Aufbau: nur wenn noetig (Raum, Material, Aufbau) — sonst leerer String
|
||||
- Trainer-Hinweise: Sicherheit, typische Fehler, Coaching-Tipps — knapp, Stichpunkte oder kurze Absaetze
|
||||
|
||||
Format (HTML fuer Rich-Text-Editor):
|
||||
- Erlaubt: <p>, <ul>, <ol>, <li>, <strong>, <em>, <br>
|
||||
- Keine Ueberschriften (h1–h6), keine Tabellen, kein Markdown, keine Code-Fences
|
||||
- Medienverweise {{exerciseMedia:ID}} aus den Eingabetexten UNVERAENDERT an passender Stelle uebernehmen
|
||||
|
||||
Eingabe:
|
||||
Titel: {{exercise_title}}
|
||||
Fokuskontext: {{exercise_focus_area}}
|
||||
|
||||
Ziel (Plaintext, Ausgang): {{exercise_goal}}
|
||||
Durchfuehrung (Plaintext, Ausgang): {{exercise_execution}}
|
||||
Vorbereitung/Aufbau (Plaintext, Ausgang): {{exercise_preparation}}
|
||||
Trainer-Hinweise (Plaintext, Ausgang): {{exercise_trainer_notes}}
|
||||
{{#has_planning_context}}
|
||||
Planungskontext (JSON):
|
||||
{{planning_context_json}}
|
||||
{{/has_planning_context}}
|
||||
|
||||
Antworte NUR mit einem JSON-Objekt (kein Text davor/danach):
|
||||
{
|
||||
"goal": "<p>…</p>",
|
||||
"execution": "<ol><li>…</li></ol>",
|
||||
"preparation": "<p>…</p> oder \"\"",
|
||||
"trainer_notes": "<ul><li>…</li></ul> oder \"\""
|
||||
}
|
||||
|
||||
Leere Felder als leerer String "" wenn nichts Sinnvolles ergibt.$t$
|
||||
WHERE slug = 'exercise_instruction_rewrite';
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
-- Migration 087: Planungs-KI — LLM Start/Ziel-Extraktion aus Trainer-Anfrage (Alternative zu Regex)
|
||||
|
||||
INSERT INTO ai_prompts (
|
||||
slug, display_name, description, template,
|
||||
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||
)
|
||||
SELECT
|
||||
'planning_progression_start_target',
|
||||
'Progressions-Roadmap Start/Ziel-Extraktion',
|
||||
'Versteht die Trainer-Anfrage und formuliert dedizierte Ausgangslage, Zielzustand und Ergänzungen (ohne Gruppen-Tracking).',
|
||||
$t$Du bist Assistent für Kampfsport-Trainer und analysierst eine Anfrage für einen didaktischen Progressionsgraphen.
|
||||
|
||||
Trainer-Anfrage (Ursprungstext):
|
||||
{{goal_query}}
|
||||
|
||||
Semantic Brief (heuristisch): {{semantic_brief_json}}
|
||||
|
||||
Bereits vom Trainer eingegebene Ergänzungen (falls vorhanden): {{user_notes}}
|
||||
|
||||
Aufgabe:
|
||||
1. **primary_topic** — Kern-Thema/Technik in kurzer, präziser Bezeichnung (z. B. „Kumite Beinarbeit“, „Mae Geri“).
|
||||
2. **start_situation** — Ausgangslage in eigenen Worten: Was kann der Athlet/die Gruppe *jetzt* (laut Anfrage oder sinnvoll ableitbar)? Konkret, beobachtbar, ohne Gruppenanalyse aus der Datenbank.
|
||||
3. **target_state** — Zielzustand in eigenen Worten: Was soll am Ende der Progression erreicht sein? Konkret, didaktisch nutzbar.
|
||||
4. **roadmap_notes** — Ergänzungen aus dem Ursprungstext: Fokus, Kontext (z. B. Kumite), besondere Anforderungen, Einschränkungen, die der Trainer erwähnt hat oder die für die Roadmap relevant sind. Nicht wiederholen, was bereits in start_situation/target_state steht.
|
||||
5. **extraction_notes** — Kurz (1–2 Sätze): Was war explizit vs. abgeleitet? Wo war die Anfrage unklar?
|
||||
|
||||
Regeln:
|
||||
- Keine Gruppenanalyse — nur das, was aus dem Text hervorgeht oder didaktisch naheliegend formuliert ist.
|
||||
- Formuliere start_situation und target_state **eigenständig und verständlich**, nicht nur Textfragmente kopieren.
|
||||
- Bei „von … bis …“: Start und Ziel aus diesem Bogen schärfen und präzise beschreiben.
|
||||
- Bei nur einem Thema ohne Bogen: start_situation und target_state didaktisch sinnvoll formulieren oder leer lassen, wenn nicht ableitbar — dann in extraction_notes erklären.
|
||||
- Antworte NUR mit JSON.
|
||||
|
||||
{
|
||||
"primary_topic": "…",
|
||||
"start_situation": "…",
|
||||
"target_state": "…",
|
||||
"roadmap_notes": "…",
|
||||
"extraction_notes": "…"
|
||||
}$t$,
|
||||
'training',
|
||||
'json',
|
||||
'{"type":"object","properties":{"primary_topic":{"type":"string"},"start_situation":{"type":"string"},"target_state":{"type":"string"},"roadmap_notes":{"type":"string"},"extraction_notes":{"type":"string"}}}'::jsonb,
|
||||
true,
|
||||
NULL,
|
||||
true,
|
||||
13
|
||||
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_progression_start_target');
|
||||
|
||||
UPDATE ai_prompts SET default_template = template
|
||||
WHERE slug = 'planning_progression_start_target'
|
||||
AND (default_template IS NULL OR TRIM(default_template) = '');
|
||||
217
backend/planning_exercise_form_context.py
Normal file
217
backend/planning_exercise_form_context.py
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
"""
|
||||
Planungs-KI Phase D: strukturierter Planungskontext für POST /exercises/ai/suggest.
|
||||
|
||||
Wird als ``planning_context_json`` in Übungs-Prompts (summary, skills, instructions) injiziert.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Mapping, Optional
|
||||
|
||||
_MAX_JSON_CHARS = 6000
|
||||
_MAX_STRING = 800
|
||||
|
||||
|
||||
def compact_planning_context_json(obj: Any) -> str:
|
||||
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def _trim_str(val: Any, *, limit: int = _MAX_STRING) -> Optional[str]:
|
||||
if val is None:
|
||||
return None
|
||||
s = str(val).strip()
|
||||
if not s:
|
||||
return None
|
||||
if len(s) > limit:
|
||||
return s[: limit - 1] + "…"
|
||||
return s
|
||||
|
||||
|
||||
def sanitize_planning_context_for_ai(ctx: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
|
||||
"""Reduziert Client-Payload auf prompt-taugliche, begrenzte Felder."""
|
||||
if not ctx:
|
||||
return {}
|
||||
out: Dict[str, Any] = {}
|
||||
for key, val in dict(ctx).items():
|
||||
if val is None:
|
||||
continue
|
||||
k = str(key).strip()
|
||||
if not k:
|
||||
continue
|
||||
if isinstance(val, str):
|
||||
t = _trim_str(val)
|
||||
if t:
|
||||
out[k] = t
|
||||
elif isinstance(val, (int, float, bool)):
|
||||
out[k] = val
|
||||
elif isinstance(val, list):
|
||||
items = []
|
||||
for item in val[:12]:
|
||||
if isinstance(item, str):
|
||||
t = _trim_str(item, limit=200)
|
||||
if t:
|
||||
items.append(t)
|
||||
elif isinstance(item, (int, float, bool)):
|
||||
items.append(item)
|
||||
elif isinstance(item, dict):
|
||||
sub = sanitize_planning_context_for_ai(item)
|
||||
if sub:
|
||||
items.append(sub)
|
||||
if items:
|
||||
out[k] = items
|
||||
elif isinstance(val, dict):
|
||||
sub = sanitize_planning_context_for_ai(val)
|
||||
if sub:
|
||||
out[k] = sub
|
||||
raw = compact_planning_context_json(out)
|
||||
if len(raw) > _MAX_JSON_CHARS:
|
||||
out["truncated"] = True
|
||||
out.pop("path_steps_preview", None)
|
||||
raw = compact_planning_context_json(out)
|
||||
if len(raw) > _MAX_JSON_CHARS:
|
||||
return {"source": out.get("source"), "truncated": True, "goal_query": out.get("goal_query")}
|
||||
return out
|
||||
|
||||
|
||||
def planning_context_prompt_variables(
|
||||
planning_context: Optional[Mapping[str, Any]],
|
||||
) -> Dict[str, str]:
|
||||
cleaned = sanitize_planning_context_for_ai(planning_context)
|
||||
if not cleaned:
|
||||
return {"planning_context_json": "-", "has_planning_context": ""}
|
||||
return {
|
||||
"planning_context_json": compact_planning_context_json(cleaned),
|
||||
"has_planning_context": "true",
|
||||
}
|
||||
|
||||
|
||||
def build_progression_gap_snapshot(
|
||||
*,
|
||||
goal_analysis: Optional[Mapping[str, Any]] = None,
|
||||
resolved_structured: Optional[Mapping[str, Any]] = None,
|
||||
stage_spec: Optional[Mapping[str, Any]] = None,
|
||||
semantic_brief: Optional[Mapping[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Kompakter Roadmap-Kontext für Lücken-Übungen (Start, Ziel, Stufe, Fähigkeiten-Hinweise)."""
|
||||
ga = dict(goal_analysis or {})
|
||||
rs = dict(resolved_structured or {})
|
||||
spec = dict(stage_spec or {})
|
||||
brief = dict(semantic_brief or {})
|
||||
|
||||
start = _trim_str(rs.get("start_situation") or ga.get("start_assumption"))
|
||||
target = _trim_str(rs.get("target_state") or ga.get("target_state"))
|
||||
notes = _trim_str(rs.get("roadmap_notes"))
|
||||
topic = _trim_str(ga.get("primary_topic") or brief.get("primary_topic"))
|
||||
|
||||
skill_hints: List[str] = []
|
||||
for item in (brief.get("must_phrases") or [])[:4]:
|
||||
t = _trim_str(item, limit=120)
|
||||
if t:
|
||||
skill_hints.append(t)
|
||||
arc = brief.get("development_arc")
|
||||
if isinstance(arc, list) and arc:
|
||||
skill_hints.append(f"Entwicklungsbogen: {' → '.join(str(x) for x in arc[:5])}")
|
||||
|
||||
success_path = [
|
||||
_trim_str(x, limit=200)
|
||||
for x in (ga.get("success_criteria") or [])
|
||||
if _trim_str(x, limit=200)
|
||||
][:4]
|
||||
stage_success = [
|
||||
_trim_str(x, limit=200)
|
||||
for x in (spec.get("success_criteria") or [])
|
||||
if _trim_str(x, limit=200)
|
||||
][:4]
|
||||
load_profile = [
|
||||
_trim_str(x, limit=80)
|
||||
for x in (spec.get("load_profile") or [])
|
||||
if _trim_str(x, limit=80)
|
||||
][:6]
|
||||
anti_patterns = [
|
||||
_trim_str(x, limit=200)
|
||||
for x in (spec.get("anti_patterns") or [])
|
||||
if _trim_str(x, limit=200)
|
||||
][:3]
|
||||
|
||||
snap: Dict[str, Any] = {
|
||||
"primary_topic": topic,
|
||||
"start_situation": start,
|
||||
"target_state": target,
|
||||
"roadmap_notes": notes,
|
||||
"stage_learning_goal": _trim_str(
|
||||
spec.get("learning_goal"), limit=1200
|
||||
),
|
||||
"stage_phase": _trim_str(spec.get("phase")),
|
||||
"stage_exercise_type": _trim_str(spec.get("exercise_type")),
|
||||
"stage_load_profile": load_profile or None,
|
||||
"stage_success_criteria": stage_success or None,
|
||||
"stage_anti_patterns": anti_patterns or None,
|
||||
"path_success_criteria": success_path or None,
|
||||
"skill_hints": skill_hints or None,
|
||||
}
|
||||
return {k: v for k, v in snap.items() if v is not None and v != "" and v != []}
|
||||
|
||||
|
||||
def build_progression_path_gap_planning_context(
|
||||
*,
|
||||
goal_query: str,
|
||||
primary_topic: Optional[str] = None,
|
||||
progression_graph_id: Optional[int] = None,
|
||||
offer: Optional[Mapping[str, Any]] = None,
|
||||
neighbor_before: Optional[Mapping[str, Any]] = None,
|
||||
neighbor_after: Optional[Mapping[str, Any]] = None,
|
||||
path_step_count: int = 0,
|
||||
major_step_count: Optional[int] = None,
|
||||
roadmap_phase: Optional[str] = None,
|
||||
roadmap_learning_goal: Optional[str] = None,
|
||||
goal_analysis: Optional[Mapping[str, Any]] = None,
|
||||
resolved_structured: Optional[Mapping[str, Any]] = None,
|
||||
stage_spec: Optional[Mapping[str, Any]] = None,
|
||||
semantic_brief: Optional[Mapping[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Kontext für KI-Neuanlage aus Progressionsgraph-Pfad-Lücke."""
|
||||
offer = offer or {}
|
||||
gap = offer.get("gap") if isinstance(offer.get("gap"), dict) else {}
|
||||
major_idx = offer.get("roadmap_major_step_index")
|
||||
if major_idx is None and isinstance(gap, dict):
|
||||
major_idx = gap.get("roadmap_major_step_index")
|
||||
|
||||
ctx: Dict[str, Any] = {
|
||||
"source": "progression_path_gap_fill",
|
||||
"goal_query": _trim_str(goal_query, limit=2000),
|
||||
"primary_topic": _trim_str(primary_topic),
|
||||
"progression_graph_id": progression_graph_id,
|
||||
"gap_source": _trim_str(offer.get("source")),
|
||||
"gap_phase": _trim_str(offer.get("phase") or gap.get("expected_phase")),
|
||||
"roadmap_major_step_index": major_idx,
|
||||
"roadmap_phase": _trim_str(roadmap_phase or offer.get("phase")),
|
||||
"roadmap_learning_goal": _trim_str(
|
||||
roadmap_learning_goal or offer.get("title_hint") or gap.get("learning_goal"),
|
||||
limit=1200,
|
||||
),
|
||||
"neighbor_before_title": _trim_str(
|
||||
(neighbor_before or {}).get("title") or offer.get("from_title")
|
||||
),
|
||||
"neighbor_after_title": _trim_str(
|
||||
(neighbor_after or {}).get("title") or offer.get("to_title")
|
||||
),
|
||||
"path_step_count": path_step_count,
|
||||
"major_step_count": major_step_count,
|
||||
}
|
||||
snap = build_progression_gap_snapshot(
|
||||
goal_analysis=goal_analysis,
|
||||
resolved_structured=resolved_structured,
|
||||
stage_spec=stage_spec,
|
||||
semantic_brief=semantic_brief,
|
||||
)
|
||||
ctx.update(snap)
|
||||
return sanitize_planning_context_for_ai(ctx)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_progression_gap_snapshot",
|
||||
"build_progression_path_gap_planning_context",
|
||||
"compact_planning_context_json",
|
||||
"planning_context_prompt_variables",
|
||||
"sanitize_planning_context_for_ai",
|
||||
]
|
||||
|
|
@ -12,7 +12,8 @@ from ai_prompt_job import run_exercise_form_ai_suggestion
|
|||
from exercise_ai import strip_html_to_plain
|
||||
|
||||
from planning_exercise_path_qa import find_step_pair_index
|
||||
from planning_exercise_semantics import PlanningSemanticBrief
|
||||
from planning_exercise_form_context import build_progression_gap_snapshot
|
||||
from planning_exercise_semantics import PlanningSemanticBrief, brief_to_summary_dict
|
||||
|
||||
_logger = logging.getLogger("shinkan.planning_exercise_path_ai_fill")
|
||||
|
||||
|
|
@ -258,14 +259,97 @@ def collect_gap_fill_specs(
|
|||
return specs[:5]
|
||||
|
||||
|
||||
def build_gap_fill_goal_text(
|
||||
*,
|
||||
goal_query: str,
|
||||
brief: PlanningSemanticBrief,
|
||||
spec: Mapping[str, Any],
|
||||
step_a: Optional[Mapping[str, Any]] = None,
|
||||
step_b: Optional[Mapping[str, Any]] = None,
|
||||
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Ausführlicher Zieltext für KI-Neuanlage aus Pfad-, Roadmap- und Stufen-Kontext."""
|
||||
topic = (brief.primary_topic or "Technik").strip()
|
||||
phase = spec.get("phase") or "vertiefung"
|
||||
from_title = (step_a or {}).get("title") or spec.get("from_title") or "vorherigem Schritt"
|
||||
to_title = (step_b or {}).get("title") or spec.get("to_title") or "nächstem Schritt"
|
||||
arc = ", ".join(brief.development_arc or []) or "einstieg → grundlage → vertiefung → anwendung → perfektion"
|
||||
snap = dict(roadmap_snapshot or {})
|
||||
if not snap:
|
||||
snap = build_progression_gap_snapshot(semantic_brief=brief_to_summary_dict(brief))
|
||||
|
||||
parts = [
|
||||
f"Planungsziel (gesamter Pfad): {goal_query}",
|
||||
f"Hauptthema: {snap.get('primary_topic') or topic}",
|
||||
]
|
||||
if snap.get("start_situation"):
|
||||
parts.append(f"Voraussetzung / Ausgangslage (Progression): {snap['start_situation']}")
|
||||
if snap.get("target_state"):
|
||||
parts.append(f"Gesamtziel der Progression: {snap['target_state']}")
|
||||
if snap.get("roadmap_notes"):
|
||||
parts.append(f"Ergänzender Kontext: {snap['roadmap_notes']}")
|
||||
stage_goal = snap.get("stage_learning_goal") or spec.get("title_hint")
|
||||
if stage_goal:
|
||||
parts.append(f"Lernziel dieser Roadmap-Stufe: {stage_goal}")
|
||||
parts.extend(
|
||||
[
|
||||
f"Entwicklungsphase dieser Übung: {snap.get('stage_phase') or phase}",
|
||||
f"Erwarteter Entwicklungsbogen: {arc}",
|
||||
f"Einordnung: didaktische Zwischenstufe zwischen „{from_title}“ und „{to_title}“.",
|
||||
]
|
||||
)
|
||||
if snap.get("stage_load_profile"):
|
||||
parts.append(f"Belastungsschwerpunkte: {', '.join(snap['stage_load_profile'])}")
|
||||
if snap.get("stage_success_criteria"):
|
||||
parts.append(
|
||||
"Erfolgskriterien dieser Stufe: "
|
||||
+ "; ".join(str(x) for x in snap["stage_success_criteria"][:4])
|
||||
)
|
||||
if snap.get("stage_anti_patterns"):
|
||||
parts.append(
|
||||
"Vermeiden: " + "; ".join(str(x) for x in snap["stage_anti_patterns"][:3])
|
||||
)
|
||||
if snap.get("skill_hints"):
|
||||
parts.append(
|
||||
"Fähigkeiten-/Fokus-Hinweise: "
|
||||
+ "; ".join(str(x) for x in snap["skill_hints"][:4])
|
||||
)
|
||||
if spec.get("rationale"):
|
||||
parts.append(f"Qualitätsprüfung: {spec['rationale']}")
|
||||
if spec.get("sketch"):
|
||||
parts.append(f"Skizze: {spec['sketch']}")
|
||||
parts.append(
|
||||
"Die Übung muss die Stufe didaktisch erfüllen: klare Voraussetzungen, messbares Stufenziel, "
|
||||
"Bezug zum Gesamtpfad — keine generische Kraftübung ohne Technikbezug. "
|
||||
"Konkrete Durchführung, Ziel und Trainerhinweise ausformulieren."
|
||||
)
|
||||
return "\n\n".join(parts)[:8000]
|
||||
|
||||
|
||||
def build_gap_fill_offer(
|
||||
*,
|
||||
spec: Mapping[str, Any],
|
||||
steps: Sequence[Mapping[str, Any]],
|
||||
goal_query: str = "",
|
||||
brief: Optional[PlanningSemanticBrief] = None,
|
||||
proposal: Optional[Mapping[str, Any]] = None,
|
||||
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
idx = int(spec.get("insert_after_index") or 0)
|
||||
offer_id = f"{spec.get('source')}-{idx}-{uuid.uuid4().hex[:8]}"
|
||||
step_a = steps[idx] if idx < len(steps) else None
|
||||
step_b = steps[idx + 1] if idx + 1 < len(steps) else None
|
||||
goal_for_ai = ""
|
||||
if brief and goal_query:
|
||||
goal_for_ai = build_gap_fill_goal_text(
|
||||
goal_query=goal_query,
|
||||
brief=brief,
|
||||
spec=spec,
|
||||
step_a=step_a,
|
||||
step_b=step_b,
|
||||
roadmap_snapshot=roadmap_snapshot,
|
||||
)
|
||||
ctx_preview = dict(roadmap_snapshot) if roadmap_snapshot else None
|
||||
offer: Dict[str, Any] = {
|
||||
"offer_id": offer_id,
|
||||
"source": spec.get("source"),
|
||||
|
|
@ -273,11 +357,15 @@ def build_gap_fill_offer(
|
|||
"replace_step_index": spec.get("replace_step_index"),
|
||||
"title_hint": spec.get("title_hint"),
|
||||
"sketch": spec.get("sketch"),
|
||||
"goal_for_ai": goal_for_ai or spec.get("sketch"),
|
||||
"context_preview": ctx_preview,
|
||||
"phase": spec.get("phase"),
|
||||
"rationale": spec.get("rationale"),
|
||||
"has_ai_payload": False,
|
||||
"from_title": (steps[idx].get("title") if idx < len(steps) else None),
|
||||
"to_title": (steps[idx + 1].get("title") if idx + 1 < len(steps) else None),
|
||||
"from_title": (step_a or {}).get("title"),
|
||||
"to_title": (step_b or {}).get("title"),
|
||||
"primary_topic": (brief.primary_topic if brief else None),
|
||||
"roadmap_major_step_index": spec.get("roadmap_major_step_index"),
|
||||
}
|
||||
if proposal:
|
||||
offer["has_ai_payload"] = True
|
||||
|
|
@ -298,6 +386,7 @@ def apply_gap_fill_after_qa(
|
|||
include_ai_calls: bool = True,
|
||||
max_ai_proposals: int = 3,
|
||||
auto_insert_proposals: bool = False,
|
||||
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
"""
|
||||
Erzeugt gap_fill_offers für die UI; optional KI-Vorschläge einfügen.
|
||||
|
|
@ -317,7 +406,14 @@ def apply_gap_fill_after_qa(
|
|||
step_a = out[idx]
|
||||
step_b = out[idx + 1]
|
||||
if step_a.get("is_ai_proposal") or step_b.get("is_ai_proposal"):
|
||||
offer = build_gap_fill_offer(spec=spec, steps=out, proposal=None)
|
||||
offer = build_gap_fill_offer(
|
||||
spec=spec,
|
||||
steps=out,
|
||||
goal_query=goal_query,
|
||||
brief=brief,
|
||||
proposal=None,
|
||||
roadmap_snapshot=roadmap_snapshot,
|
||||
)
|
||||
offers.append(offer)
|
||||
continue
|
||||
|
||||
|
|
@ -338,7 +434,14 @@ def apply_gap_fill_after_qa(
|
|||
sketch_hint=str(spec.get("sketch") or ""),
|
||||
)
|
||||
|
||||
offer = build_gap_fill_offer(spec=spec, steps=out, proposal=proposal)
|
||||
offer = build_gap_fill_offer(
|
||||
spec=spec,
|
||||
steps=out,
|
||||
goal_query=goal_query,
|
||||
brief=brief,
|
||||
proposal=proposal,
|
||||
roadmap_snapshot=roadmap_snapshot,
|
||||
)
|
||||
offers.append(offer)
|
||||
|
||||
if proposal and auto_insert_proposals:
|
||||
|
|
@ -389,6 +492,7 @@ def insert_ai_proposals_for_gaps(
|
|||
|
||||
__all__ = [
|
||||
"apply_gap_fill_after_qa",
|
||||
"build_gap_fill_goal_text",
|
||||
"build_gap_fill_offer",
|
||||
"collect_gap_fill_specs",
|
||||
"insert_ai_proposals_for_gaps",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
"""
|
||||
Planungs-KI Phase C3/E: Pfad-Vorschläge für Progressionsgraphen.
|
||||
Planungs-KI Phase C3/E/F: Pfad-Vorschläge für Progressionsgraphen.
|
||||
|
||||
Ziel-Freitext → semantisch gewichtete Schritte → Lücken/Brücken → optional LLM-QA.
|
||||
Legacy: retrieval-first. Phase F: optional Roadmap-Preview (A→B→C) parallel — siehe
|
||||
planning_progression_roadmap.py und PLANNING_PROGRESSION_ROADMAP_SPEC.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
|
||||
from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Tuple
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
|
@ -19,9 +20,14 @@ from planning_exercise_path_qa import (
|
|||
detect_path_gaps,
|
||||
insert_bridge_exercises,
|
||||
parse_llm_suggested_new_exercises,
|
||||
strip_off_topic_steps_from_path,
|
||||
try_llm_qa_progression_path,
|
||||
)
|
||||
from planning_exercise_path_ai_fill import apply_gap_fill_after_qa, collect_gap_fill_specs
|
||||
from planning_exercise_path_ai_fill import (
|
||||
apply_gap_fill_after_qa,
|
||||
build_gap_fill_offer,
|
||||
collect_gap_fill_specs,
|
||||
)
|
||||
from planning_exercise_retrieval import run_multistage_planning_retrieval
|
||||
from planning_exercise_semantics import (
|
||||
PlanningSemanticBrief,
|
||||
|
|
@ -44,6 +50,21 @@ from planning_exercise_suggest import (
|
|||
_normalize_query,
|
||||
resolve_planning_exercise_intent,
|
||||
)
|
||||
from planning_exercise_form_context import build_progression_gap_snapshot
|
||||
from planning_progression_roadmap import (
|
||||
MajorStep,
|
||||
ProgressionRoadmapContext,
|
||||
RoadmapOverridePayload,
|
||||
RoadmapStructuredInput,
|
||||
StageSpecArtifact,
|
||||
build_roadmap_unfilled_gap_specs,
|
||||
progression_roadmap_to_api_dict,
|
||||
resolve_step_exercise_kind_filter,
|
||||
roadmap_context_from_override,
|
||||
run_progression_roadmap_pipeline,
|
||||
run_start_target_resolve_only,
|
||||
stage_spec_retrieval_query,
|
||||
)
|
||||
from routers.training_planning import _has_planning_role
|
||||
|
||||
|
||||
|
|
@ -55,10 +76,71 @@ class ProgressionPathSuggestRequest(BaseModel):
|
|||
include_llm_path_qa: bool = True
|
||||
include_path_reorder: bool = True
|
||||
include_ai_gap_fill: bool = True
|
||||
include_roadmap_preview: bool = False
|
||||
include_llm_roadmap: bool = True
|
||||
include_llm_start_target: bool = True
|
||||
roadmap_first: bool = False
|
||||
roadmap_only: bool = False
|
||||
start_target_only: bool = False
|
||||
roadmap_override: Optional[RoadmapOverridePayload] = None
|
||||
start_situation: Optional[str] = Field(default=None, max_length=2000)
|
||||
target_state: Optional[str] = Field(default=None, max_length=2000)
|
||||
roadmap_notes: Optional[str] = Field(default=None, max_length=2000)
|
||||
progression_graph_id: Optional[int] = Field(default=None, ge=1)
|
||||
exercise_kind_any: Optional[List[str]] = None
|
||||
|
||||
|
||||
def _roadmap_gap_snapshot_for_spec(
|
||||
roadmap_ctx: Optional[ProgressionRoadmapContext],
|
||||
spec: Mapping[str, Any],
|
||||
*,
|
||||
semantic_brief: PlanningSemanticBrief,
|
||||
) -> Dict[str, Any]:
|
||||
"""Roadmap-Kontext für KI-Lücken-Übung (Start, Ziel, Stufenspec)."""
|
||||
major_idx = spec.get("roadmap_major_step_index")
|
||||
stage_spec_dict: Optional[Dict[str, Any]] = None
|
||||
if roadmap_ctx and major_idx is not None:
|
||||
for s in roadmap_ctx.stage_specs or []:
|
||||
if int(s.major_step_index) == int(major_idx):
|
||||
stage_spec_dict = s.model_dump()
|
||||
if roadmap_ctx.roadmap:
|
||||
for m in roadmap_ctx.roadmap.major_steps:
|
||||
if m.index == int(major_idx):
|
||||
stage_spec_dict["phase"] = m.phase
|
||||
break
|
||||
break
|
||||
ga = roadmap_ctx.goal_analysis.model_dump() if roadmap_ctx and roadmap_ctx.goal_analysis else None
|
||||
rs = (
|
||||
roadmap_ctx.resolved_structured.model_dump()
|
||||
if roadmap_ctx and roadmap_ctx.resolved_structured
|
||||
else None
|
||||
)
|
||||
brief_summary = (
|
||||
roadmap_ctx.semantic_brief
|
||||
if roadmap_ctx and roadmap_ctx.semantic_brief
|
||||
else brief_to_summary_dict(semantic_brief)
|
||||
)
|
||||
return build_progression_gap_snapshot(
|
||||
goal_analysis=ga,
|
||||
resolved_structured=rs,
|
||||
stage_spec=stage_spec_dict,
|
||||
semantic_brief=brief_summary,
|
||||
)
|
||||
|
||||
|
||||
def _roadmap_structured_from_body(body: ProgressionPathSuggestRequest) -> Optional[RoadmapStructuredInput]:
|
||||
start = (body.start_situation or "").strip() or None
|
||||
target = (body.target_state or "").strip() or None
|
||||
notes = (body.roadmap_notes or "").strip() or None
|
||||
if not any([start, target, notes]):
|
||||
return None
|
||||
return RoadmapStructuredInput(
|
||||
start_situation=start,
|
||||
target_state=target,
|
||||
roadmap_notes=notes,
|
||||
)
|
||||
|
||||
|
||||
def _pick_best_path_hit(
|
||||
hits: List[Dict[str, Any]],
|
||||
used_exercise_ids: Set[int],
|
||||
|
|
@ -160,8 +242,12 @@ def _run_path_step_retrieval(
|
|||
step_b: Optional[Dict[str, Any]] = None,
|
||||
path_target_profile: Optional[PlanningTargetProfile] = None,
|
||||
path_intent: Optional[str] = None,
|
||||
step_query_override: Optional[str] = None,
|
||||
step_phase_override: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], PlanningTargetProfile, Dict[str, Any], str]:
|
||||
step_query = step_retrieval_query(semantic_brief, goal_query, step_index, max_steps)
|
||||
step_query = step_query_override or step_retrieval_query(
|
||||
semantic_brief, goal_query, step_index, max_steps
|
||||
)
|
||||
if bridge_mode and step_a and step_b:
|
||||
phase = step_phase_for_index(semantic_brief, step_index, max_steps)
|
||||
parts = [semantic_brief.primary_topic or semantic_brief.retrieval_query or goal_query]
|
||||
|
|
@ -191,7 +277,8 @@ def _run_path_step_retrieval(
|
|||
"has_planning_reference": bool(planned_ids or anchor_id or bridge_mode),
|
||||
"semantic_brief": semantic_brief,
|
||||
"retrieval_query": step_query,
|
||||
"path_step_phase": step_phase_for_index(semantic_brief, step_index, max_steps),
|
||||
"path_step_phase": step_phase_override
|
||||
or step_phase_for_index(semantic_brief, step_index, max_steps),
|
||||
}
|
||||
pack = apply_progression_context_to_pack(
|
||||
cur,
|
||||
|
|
@ -322,6 +409,130 @@ def _make_bridge_search_fn(
|
|||
return _bridge_search
|
||||
|
||||
|
||||
def _annotate_roadmap_step(
|
||||
step: Dict[str, Any],
|
||||
*,
|
||||
stage_spec: StageSpecArtifact,
|
||||
major_step: Optional[MajorStep],
|
||||
) -> Dict[str, Any]:
|
||||
reasons = list(step.get("reasons") or [])
|
||||
learning_goal = (stage_spec.learning_goal or "").strip()
|
||||
if learning_goal:
|
||||
roadmap_reason = f"Roadmap: {learning_goal[:120]}"
|
||||
if roadmap_reason not in reasons:
|
||||
reasons.insert(0, roadmap_reason)
|
||||
step["reasons"] = reasons[:4]
|
||||
step["roadmap_major_step_index"] = stage_spec.major_step_index
|
||||
step["roadmap_phase"] = major_step.phase if major_step else None
|
||||
step["roadmap_learning_goal"] = learning_goal or None
|
||||
step["roadmap_match_source"] = "stage_spec"
|
||||
return step
|
||||
|
||||
|
||||
def _build_steps_roadmap_first(
|
||||
cur,
|
||||
*,
|
||||
tenant: TenantContext,
|
||||
body: ProgressionPathSuggestRequest,
|
||||
goal_query: str,
|
||||
max_steps: int,
|
||||
semantic_brief: PlanningSemanticBrief,
|
||||
path_target_profile: PlanningTargetProfile,
|
||||
path_intent: str,
|
||||
roadmap_ctx: ProgressionRoadmapContext,
|
||||
) -> Tuple[List[Dict[str, Any]], List[Tuple[int, StageSpecArtifact]]]:
|
||||
"""Retrieval pro stage_spec statt iterativem Pfad-Bau (Phase F3)."""
|
||||
stage_specs = list(roadmap_ctx.stage_specs or [])[:max_steps]
|
||||
if not stage_specs and roadmap_ctx.roadmap:
|
||||
stage_specs = [
|
||||
StageSpecArtifact(
|
||||
major_step_index=m.index,
|
||||
learning_goal=m.learning_goal,
|
||||
)
|
||||
for m in roadmap_ctx.roadmap.major_steps[:max_steps]
|
||||
]
|
||||
|
||||
major_by_index: Dict[int, MajorStep] = {}
|
||||
if roadmap_ctx.roadmap:
|
||||
major_by_index = {m.index: m for m in roadmap_ctx.roadmap.major_steps}
|
||||
|
||||
used: Set[int] = set()
|
||||
steps: List[Dict[str, Any]] = []
|
||||
planned_ids: List[int] = []
|
||||
anchor_id: Optional[int] = None
|
||||
anchor_variant_id: Optional[int] = None
|
||||
unfilled: List[Tuple[int, StageSpecArtifact]] = []
|
||||
|
||||
for step_index, stage_spec in enumerate(stage_specs):
|
||||
major = major_by_index.get(stage_spec.major_step_index)
|
||||
step_query = stage_spec_retrieval_query(
|
||||
semantic_brief=semantic_brief,
|
||||
goal_query=goal_query,
|
||||
stage_spec=stage_spec,
|
||||
major_step=major,
|
||||
)
|
||||
step_kind = resolve_step_exercise_kind_filter(stage_spec, body.exercise_kind_any)
|
||||
|
||||
hits, _, _, _ = _run_path_step_retrieval(
|
||||
cur,
|
||||
tenant=tenant,
|
||||
goal_query=goal_query,
|
||||
step_index=step_index,
|
||||
max_steps=max_steps,
|
||||
planned_ids=planned_ids,
|
||||
anchor_id=anchor_id,
|
||||
anchor_variant_id=anchor_variant_id,
|
||||
progression_graph_id=body.progression_graph_id,
|
||||
include_llm_intent=body.include_llm_intent and step_index == 0,
|
||||
exercise_kind_any=step_kind,
|
||||
semantic_brief=semantic_brief,
|
||||
path_target_profile=path_target_profile,
|
||||
path_intent=path_intent,
|
||||
step_query_override=step_query,
|
||||
step_phase_override=major.phase if major else None,
|
||||
)
|
||||
|
||||
hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief)
|
||||
if not hit and step_query != goal_query:
|
||||
hits, _, _, _ = _run_path_step_retrieval(
|
||||
cur,
|
||||
tenant=tenant,
|
||||
goal_query=goal_query,
|
||||
step_index=step_index,
|
||||
max_steps=max_steps,
|
||||
planned_ids=planned_ids,
|
||||
anchor_id=anchor_id,
|
||||
anchor_variant_id=anchor_variant_id,
|
||||
progression_graph_id=body.progression_graph_id,
|
||||
include_llm_intent=False,
|
||||
exercise_kind_any=step_kind,
|
||||
semantic_brief=semantic_brief,
|
||||
path_target_profile=path_target_profile,
|
||||
path_intent=path_intent,
|
||||
step_query_override=goal_query,
|
||||
step_phase_override=major.phase if major else None,
|
||||
)
|
||||
hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief)
|
||||
|
||||
if not hit:
|
||||
unfilled.append((step_index, stage_spec))
|
||||
continue
|
||||
|
||||
step = _annotate_roadmap_step(
|
||||
_hit_to_path_step(hit),
|
||||
stage_spec=stage_spec,
|
||||
major_step=major,
|
||||
)
|
||||
steps.append(step)
|
||||
eid = int(step["exercise_id"])
|
||||
used.add(eid)
|
||||
planned_ids.append(eid)
|
||||
anchor_id = eid
|
||||
anchor_variant_id = step.get("variant_id")
|
||||
|
||||
return steps, unfilled
|
||||
|
||||
|
||||
def suggest_progression_path(
|
||||
cur,
|
||||
*,
|
||||
|
|
@ -344,6 +555,97 @@ def suggest_progression_path(
|
|||
cur, goal_query, semantic_brief
|
||||
)
|
||||
|
||||
roadmap_first = bool(body.roadmap_first)
|
||||
roadmap_only = bool(body.roadmap_only)
|
||||
start_target_only = bool(body.start_target_only)
|
||||
include_roadmap = (
|
||||
roadmap_first or body.include_roadmap_preview or roadmap_only or start_target_only
|
||||
)
|
||||
progression_roadmap: Optional[Dict[str, Any]] = None
|
||||
roadmap_ctx: Optional[ProgressionRoadmapContext] = None
|
||||
roadmap_edited = False
|
||||
roadmap_structured = _roadmap_structured_from_body(body)
|
||||
|
||||
if body.roadmap_override is not None:
|
||||
try:
|
||||
roadmap_ctx = roadmap_context_from_override(
|
||||
goal_query,
|
||||
max_steps=max_steps,
|
||||
semantic_brief=semantic_brief,
|
||||
override=body.roadmap_override,
|
||||
structured=roadmap_structured,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)
|
||||
progression_roadmap["roadmap_edited"] = True
|
||||
roadmap_edited = True
|
||||
max_steps = int(roadmap_ctx.max_steps)
|
||||
roadmap_first = True
|
||||
elif start_target_only:
|
||||
roadmap_ctx = run_start_target_resolve_only(
|
||||
goal_query,
|
||||
semantic_brief=semantic_brief,
|
||||
cur=cur,
|
||||
include_llm_start_target=body.include_llm_start_target,
|
||||
structured=roadmap_structured,
|
||||
)
|
||||
progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)
|
||||
elif include_roadmap:
|
||||
roadmap_ctx = run_progression_roadmap_pipeline(
|
||||
goal_query,
|
||||
max_steps=max_steps,
|
||||
semantic_brief=semantic_brief,
|
||||
cur=cur,
|
||||
include_llm_roadmap=body.include_llm_roadmap,
|
||||
include_llm_start_target=body.include_llm_start_target,
|
||||
structured=roadmap_structured,
|
||||
)
|
||||
progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)
|
||||
|
||||
if start_target_only:
|
||||
return {
|
||||
"goal_query": goal_query,
|
||||
"max_steps_requested": max_steps,
|
||||
"steps": [],
|
||||
"step_count": 0,
|
||||
"target_profile_summary": None,
|
||||
"semantic_brief_summary": brief_to_summary_dict(semantic_brief),
|
||||
"semantic_llm_applied": semantic_llm_applied,
|
||||
"query_intent_summary": {},
|
||||
"progression_graph_id": body.progression_graph_id,
|
||||
"path_qa": None,
|
||||
"gap_fill_offers": [],
|
||||
"progression_roadmap": progression_roadmap,
|
||||
"roadmap_first": False,
|
||||
"roadmap_only": False,
|
||||
"start_target_only": True,
|
||||
"roadmap_edited": False,
|
||||
"roadmap_unfilled_count": 0,
|
||||
"retrieval_phase": "start_target_only",
|
||||
}
|
||||
|
||||
if roadmap_only:
|
||||
return {
|
||||
"goal_query": goal_query,
|
||||
"max_steps_requested": max_steps,
|
||||
"steps": [],
|
||||
"step_count": 0,
|
||||
"target_profile_summary": None,
|
||||
"semantic_brief_summary": brief_to_summary_dict(semantic_brief),
|
||||
"semantic_llm_applied": semantic_llm_applied,
|
||||
"query_intent_summary": {},
|
||||
"progression_graph_id": body.progression_graph_id,
|
||||
"path_qa": None,
|
||||
"gap_fill_offers": [],
|
||||
"progression_roadmap": progression_roadmap,
|
||||
"roadmap_first": False,
|
||||
"roadmap_only": True,
|
||||
"roadmap_edited": roadmap_edited,
|
||||
"roadmap_unfilled_count": 0,
|
||||
"retrieval_phase": "roadmap_only",
|
||||
}
|
||||
|
||||
path_target_profile, first_intent_summary, path_intent = _build_path_target_profile(
|
||||
cur,
|
||||
goal_query=goal_query,
|
||||
|
|
@ -351,41 +653,89 @@ def suggest_progression_path(
|
|||
include_llm_intent=body.include_llm_intent,
|
||||
)
|
||||
|
||||
roadmap_unfilled: List[Tuple[int, StageSpecArtifact]] = []
|
||||
roadmap_gap_offers: List[Dict[str, Any]] = []
|
||||
|
||||
used: Set[int] = set()
|
||||
steps: List[Dict[str, Any]] = []
|
||||
planned_ids: List[int] = []
|
||||
anchor_id: Optional[int] = None
|
||||
anchor_variant_id: Optional[int] = None
|
||||
|
||||
for step_index in range(max_steps):
|
||||
hits, _tp, _qis, _intent = _run_path_step_retrieval(
|
||||
if roadmap_first and roadmap_ctx is not None:
|
||||
steps, roadmap_unfilled = _build_steps_roadmap_first(
|
||||
cur,
|
||||
tenant=tenant,
|
||||
body=body,
|
||||
goal_query=goal_query,
|
||||
step_index=step_index,
|
||||
max_steps=max_steps,
|
||||
planned_ids=planned_ids,
|
||||
anchor_id=anchor_id,
|
||||
anchor_variant_id=anchor_variant_id,
|
||||
progression_graph_id=body.progression_graph_id,
|
||||
include_llm_intent=body.include_llm_intent,
|
||||
exercise_kind_any=body.exercise_kind_any,
|
||||
semantic_brief=semantic_brief,
|
||||
path_target_profile=path_target_profile,
|
||||
path_intent=path_intent,
|
||||
roadmap_ctx=roadmap_ctx,
|
||||
)
|
||||
planned_ids = [int(s["exercise_id"]) for s in steps if s.get("exercise_id") is not None]
|
||||
if planned_ids:
|
||||
anchor_id = planned_ids[-1]
|
||||
anchor_variant_id = steps[-1].get("variant_id")
|
||||
if body.include_ai_gap_fill and roadmap_unfilled:
|
||||
major_by_index = (
|
||||
{m.index: m for m in roadmap_ctx.roadmap.major_steps}
|
||||
if roadmap_ctx.roadmap
|
||||
else {}
|
||||
)
|
||||
roadmap_gap_specs = build_roadmap_unfilled_gap_specs(
|
||||
unfilled_specs=roadmap_unfilled,
|
||||
major_steps_by_index=major_by_index,
|
||||
steps=steps,
|
||||
brief=semantic_brief,
|
||||
goal_query=goal_query,
|
||||
goal_analysis=roadmap_ctx.goal_analysis if roadmap_ctx else None,
|
||||
resolved_structured=roadmap_ctx.resolved_structured if roadmap_ctx else None,
|
||||
)
|
||||
for spec in roadmap_gap_specs:
|
||||
roadmap_gap_offers.append(
|
||||
build_gap_fill_offer(
|
||||
spec=spec,
|
||||
steps=steps,
|
||||
goal_query=goal_query,
|
||||
brief=semantic_brief,
|
||||
proposal=None,
|
||||
roadmap_snapshot=_roadmap_gap_snapshot_for_spec(
|
||||
roadmap_ctx, spec, semantic_brief=semantic_brief
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
for step_index in range(max_steps):
|
||||
hits, _tp, _qis, _intent = _run_path_step_retrieval(
|
||||
cur,
|
||||
tenant=tenant,
|
||||
goal_query=goal_query,
|
||||
step_index=step_index,
|
||||
max_steps=max_steps,
|
||||
planned_ids=planned_ids,
|
||||
anchor_id=anchor_id,
|
||||
anchor_variant_id=anchor_variant_id,
|
||||
progression_graph_id=body.progression_graph_id,
|
||||
include_llm_intent=body.include_llm_intent,
|
||||
exercise_kind_any=body.exercise_kind_any,
|
||||
semantic_brief=semantic_brief,
|
||||
path_target_profile=path_target_profile,
|
||||
path_intent=path_intent,
|
||||
)
|
||||
|
||||
hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief)
|
||||
if not hit:
|
||||
break
|
||||
hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief)
|
||||
if not hit:
|
||||
break
|
||||
|
||||
step = _hit_to_path_step(hit)
|
||||
steps.append(step)
|
||||
eid = int(step["exercise_id"])
|
||||
used.add(eid)
|
||||
planned_ids.append(eid)
|
||||
anchor_id = eid
|
||||
anchor_variant_id = step.get("variant_id")
|
||||
step = _hit_to_path_step(hit)
|
||||
steps.append(step)
|
||||
eid = int(step["exercise_id"])
|
||||
used.add(eid)
|
||||
planned_ids.append(eid)
|
||||
anchor_id = eid
|
||||
anchor_variant_id = step.get("variant_id")
|
||||
|
||||
if len(steps) < 2:
|
||||
raise HTTPException(
|
||||
|
|
@ -398,15 +748,24 @@ def suggest_progression_path(
|
|||
ai_proposals: List[Dict[str, Any]] = []
|
||||
gap_fill_offers: List[Dict[str, Any]] = []
|
||||
off_topic_steps: List[Dict[str, Any]] = []
|
||||
stripped_off_topic: List[Dict[str, Any]] = []
|
||||
llm_qa: Optional[Dict[str, Any]] = None
|
||||
llm_qa_applied = False
|
||||
reorder_applied = False
|
||||
reorder_notes: List[str] = []
|
||||
|
||||
roadmap_qa_mode: Optional[str] = None
|
||||
if body.include_path_qa:
|
||||
gaps = detect_path_gaps(cur, steps, brief=semantic_brief)
|
||||
if roadmap_first:
|
||||
roadmap_qa_mode = "roadmap_first_lite"
|
||||
gaps = detect_path_gaps(
|
||||
cur,
|
||||
steps,
|
||||
brief=semantic_brief,
|
||||
roadmap_first=roadmap_first,
|
||||
)
|
||||
unfilled_gaps: List[Dict[str, Any]] = []
|
||||
if gaps:
|
||||
if gaps and not roadmap_first:
|
||||
bridge_fn = _make_bridge_search_fn(
|
||||
cur,
|
||||
tenant=tenant,
|
||||
|
|
@ -427,6 +786,8 @@ def suggest_progression_path(
|
|||
brief=semantic_brief,
|
||||
bridge_search_fn=bridge_fn,
|
||||
)
|
||||
elif gaps and roadmap_first:
|
||||
unfilled_gaps = list(gaps)
|
||||
|
||||
if body.include_llm_path_qa:
|
||||
llm_qa, llm_qa_applied = try_llm_qa_progression_path(
|
||||
|
|
@ -438,7 +799,12 @@ def suggest_progression_path(
|
|||
bridge_inserts=bridge_inserts,
|
||||
)
|
||||
|
||||
if body.include_path_reorder and llm_qa_applied and llm_qa:
|
||||
if (
|
||||
body.include_path_reorder
|
||||
and not roadmap_first
|
||||
and llm_qa_applied
|
||||
and llm_qa
|
||||
):
|
||||
q_score = llm_qa.get("quality_score")
|
||||
try:
|
||||
q_val = float(q_score) if q_score is not None else None
|
||||
|
|
@ -448,6 +814,16 @@ def suggest_progression_path(
|
|||
steps, reorder_applied, reorder_notes = apply_llm_path_reorder(steps, llm_qa)
|
||||
|
||||
off_topic_steps = detect_off_topic_steps(cur, steps, brief=semantic_brief)
|
||||
steps, stripped_off_topic = strip_off_topic_steps_from_path(steps, off_topic_steps)
|
||||
if stripped_off_topic:
|
||||
off_topic_steps = []
|
||||
gaps = detect_path_gaps(
|
||||
cur,
|
||||
steps,
|
||||
brief=semantic_brief,
|
||||
roadmap_first=roadmap_first,
|
||||
)
|
||||
|
||||
llm_gap_specs = parse_llm_suggested_new_exercises(
|
||||
llm_qa,
|
||||
brief=semantic_brief,
|
||||
|
|
@ -455,39 +831,68 @@ def suggest_progression_path(
|
|||
)
|
||||
|
||||
if body.include_ai_gap_fill:
|
||||
fresh_large_gaps = [g for g in gaps if g.get("is_large_gap")]
|
||||
gap_specs = collect_gap_fill_specs(
|
||||
steps=steps,
|
||||
unfilled_gaps=unfilled_gaps,
|
||||
unfilled_gaps=fresh_large_gaps or unfilled_gaps,
|
||||
off_topic_steps=off_topic_steps,
|
||||
llm_specs=llm_gap_specs,
|
||||
brief=semantic_brief,
|
||||
goal_query=goal_query,
|
||||
)
|
||||
path_roadmap_snapshot = None
|
||||
if roadmap_ctx:
|
||||
path_roadmap_snapshot = build_progression_gap_snapshot(
|
||||
goal_analysis=(
|
||||
roadmap_ctx.goal_analysis.model_dump()
|
||||
if roadmap_ctx.goal_analysis
|
||||
else None
|
||||
),
|
||||
resolved_structured=(
|
||||
roadmap_ctx.resolved_structured.model_dump()
|
||||
if roadmap_ctx.resolved_structured
|
||||
else None
|
||||
),
|
||||
semantic_brief=roadmap_ctx.semantic_brief or brief_to_summary_dict(semantic_brief),
|
||||
)
|
||||
steps, ai_proposals, gap_fill_offers = apply_gap_fill_after_qa(
|
||||
cur,
|
||||
steps,
|
||||
gap_specs,
|
||||
goal_query=goal_query,
|
||||
brief=semantic_brief,
|
||||
include_ai_calls=True,
|
||||
max_ai_proposals=3,
|
||||
include_ai_calls=False,
|
||||
max_ai_proposals=0,
|
||||
auto_insert_proposals=False,
|
||||
roadmap_snapshot=path_roadmap_snapshot,
|
||||
)
|
||||
|
||||
if roadmap_gap_offers:
|
||||
seen_offer_ids = {o.get("offer_id") for o in gap_fill_offers}
|
||||
for offer in roadmap_gap_offers:
|
||||
if offer.get("offer_id") not in seen_offer_ids:
|
||||
gap_fill_offers.append(offer)
|
||||
|
||||
path_qa = build_path_qa_summary(
|
||||
gaps=gaps,
|
||||
bridge_inserts=bridge_inserts,
|
||||
ai_proposals=ai_proposals,
|
||||
gap_fill_offers=gap_fill_offers,
|
||||
off_topic_steps=off_topic_steps,
|
||||
stripped_off_topic=stripped_off_topic,
|
||||
llm_qa=llm_qa,
|
||||
llm_applied=llm_qa_applied,
|
||||
reorder_applied=reorder_applied,
|
||||
reorder_notes=reorder_notes,
|
||||
roadmap_qa_mode=roadmap_qa_mode,
|
||||
)
|
||||
|
||||
target_profile_summary = path_target_profile.to_summary_dict(cur)
|
||||
retrieval_parts = ["profile_v1", "full_library", "path_builder", "semantics"]
|
||||
if roadmap_first:
|
||||
retrieval_parts.append("roadmap_first")
|
||||
if roadmap_qa_mode:
|
||||
retrieval_parts.append(roadmap_qa_mode)
|
||||
if body.include_path_qa:
|
||||
retrieval_parts.append("path_qa")
|
||||
if llm_qa_applied:
|
||||
|
|
@ -498,6 +903,12 @@ def suggest_progression_path(
|
|||
retrieval_parts.append("ai_gap_fill")
|
||||
if gap_fill_offers:
|
||||
retrieval_parts.append("gap_fill_offers")
|
||||
if include_roadmap:
|
||||
retrieval_parts.append("roadmap_preview")
|
||||
if roadmap_edited:
|
||||
retrieval_parts.append("roadmap_edited")
|
||||
if roadmap_unfilled:
|
||||
retrieval_parts.append("roadmap_unfilled")
|
||||
|
||||
return {
|
||||
"goal_query": goal_query,
|
||||
|
|
@ -511,6 +922,11 @@ def suggest_progression_path(
|
|||
"progression_graph_id": body.progression_graph_id,
|
||||
"path_qa": path_qa,
|
||||
"gap_fill_offers": gap_fill_offers,
|
||||
"progression_roadmap": progression_roadmap,
|
||||
"roadmap_first": roadmap_first,
|
||||
"roadmap_only": False,
|
||||
"roadmap_edited": roadmap_edited,
|
||||
"roadmap_unfilled_count": len(roadmap_unfilled),
|
||||
"retrieval_phase": "+".join(retrieval_parts),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -141,21 +141,45 @@ def measure_step_transition_gap(
|
|||
}
|
||||
|
||||
|
||||
def is_roadmap_planned_neighbor_pair(
|
||||
step_a: Mapping[str, Any],
|
||||
step_b: Mapping[str, Any],
|
||||
) -> bool:
|
||||
"""Aufeinanderfolgende Major Steps aus roadmap_first — kein Skill-Übergangs-Lücke."""
|
||||
if step_a.get("roadmap_match_source") != "stage_spec":
|
||||
return False
|
||||
if step_b.get("roadmap_match_source") != "stage_spec":
|
||||
return False
|
||||
idx_a = step_a.get("roadmap_major_step_index")
|
||||
idx_b = step_b.get("roadmap_major_step_index")
|
||||
if idx_a is None or idx_b is None:
|
||||
return False
|
||||
try:
|
||||
return int(idx_b) == int(idx_a) + 1
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def detect_path_gaps(
|
||||
cur,
|
||||
steps: Sequence[Mapping[str, Any]],
|
||||
*,
|
||||
brief: PlanningSemanticBrief,
|
||||
roadmap_first: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
if len(steps) < 2:
|
||||
return []
|
||||
gaps: List[Dict[str, Any]] = []
|
||||
total_segments = len(steps) - 1
|
||||
for i in range(total_segments):
|
||||
step_a = steps[i]
|
||||
step_b = steps[i + 1]
|
||||
if roadmap_first and is_roadmap_planned_neighbor_pair(step_a, step_b):
|
||||
continue
|
||||
gap = measure_step_transition_gap(
|
||||
cur,
|
||||
steps[i],
|
||||
steps[i + 1],
|
||||
step_a,
|
||||
step_b,
|
||||
brief=brief,
|
||||
segment_index=i,
|
||||
total_segments=total_segments,
|
||||
|
|
@ -462,6 +486,33 @@ def parse_llm_suggested_new_exercises(
|
|||
return out
|
||||
|
||||
|
||||
def strip_off_topic_steps_from_path(
|
||||
steps: List[Dict[str, Any]],
|
||||
off_topic_steps: Sequence[Mapping[str, Any]],
|
||||
*,
|
||||
min_remaining: int = 2,
|
||||
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
"""Entfernt themenfremde Schritte aus dem Pfad (mindestens min_remaining bleiben)."""
|
||||
if not off_topic_steps or len(steps) <= min_remaining:
|
||||
return steps, []
|
||||
|
||||
by_index = {int(o["step_index"]): dict(o) for o in off_topic_steps if o.get("step_index") is not None}
|
||||
indices = sorted(by_index.keys(), reverse=True)
|
||||
if len(steps) - len(indices) < min_remaining:
|
||||
return steps, []
|
||||
|
||||
out = list(steps)
|
||||
removed: List[Dict[str, Any]] = []
|
||||
for idx in indices:
|
||||
if 0 <= idx < len(out):
|
||||
entry = dict(by_index[idx])
|
||||
entry["removed_title"] = out[idx].get("title")
|
||||
entry["removed_exercise_id"] = out[idx].get("exercise_id")
|
||||
removed.append(entry)
|
||||
out.pop(idx)
|
||||
return out, removed
|
||||
|
||||
|
||||
def find_step_pair_index(
|
||||
steps: Sequence[Mapping[str, Any]],
|
||||
from_exercise_id: int,
|
||||
|
|
@ -484,10 +535,12 @@ def build_path_qa_summary(
|
|||
ai_proposals: Sequence[Mapping[str, Any]],
|
||||
gap_fill_offers: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
off_topic_steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
stripped_off_topic: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
llm_qa: Optional[Mapping[str, Any]],
|
||||
llm_applied: bool,
|
||||
reorder_applied: bool = False,
|
||||
reorder_notes: Optional[Sequence[str]] = None,
|
||||
roadmap_qa_mode: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
offers = list(gap_fill_offers or [])
|
||||
off_topic = list(off_topic_steps or [])
|
||||
|
|
@ -502,9 +555,11 @@ def build_path_qa_summary(
|
|||
"gap_fill_offers": offers,
|
||||
"off_topic_count": len(off_topic),
|
||||
"off_topic_steps": off_topic,
|
||||
"stripped_off_topic_steps": list(stripped_off_topic or []),
|
||||
"llm_qa_applied": llm_applied,
|
||||
"reorder_applied": reorder_applied,
|
||||
"reorder_notes": list(reorder_notes or []),
|
||||
"roadmap_qa_mode": roadmap_qa_mode,
|
||||
}
|
||||
if llm_qa:
|
||||
summary["overall_ok"] = bool(llm_qa.get("overall_ok", True))
|
||||
|
|
@ -533,6 +588,8 @@ __all__ = [
|
|||
"build_path_qa_summary",
|
||||
"detect_off_topic_steps",
|
||||
"detect_path_gaps",
|
||||
"is_roadmap_planned_neighbor_pair",
|
||||
"strip_off_topic_steps_from_path",
|
||||
"find_step_pair_index",
|
||||
"insert_bridge_exercises",
|
||||
"measure_step_transition_gap",
|
||||
|
|
|
|||
1198
backend/planning_progression_roadmap.py
Normal file
1198
backend/planning_progression_roadmap.py
Normal file
File diff suppressed because it is too large
Load Diff
9
backend/rights_registrations/__init__.py
Normal file
9
backend/rights_registrations/__init__.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"""
|
||||
Modul-Registrierungen für Rechte & Kontingente.
|
||||
|
||||
Neues Feature: eigene Datei oder Eintrag hier importieren — kein Eintrag in 079-Katalog-Migration.
|
||||
"""
|
||||
from rights_registrations import club_creation # noqa: F401
|
||||
from rights_registrations import exercises # noqa: F401
|
||||
from rights_registrations import planning # noqa: F401
|
||||
from rights_registrations import platform # noqa: F401
|
||||
38
backend/rights_registrations/club_creation.py
Normal file
38
backend/rights_registrations/club_creation.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from rights_registry import CapabilityRegistration, register_capability
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
id="club.creation_request.create",
|
||||
name="Vereinsgründung beantragen",
|
||||
domain="club",
|
||||
module="club_creation_requests",
|
||||
min_account_state="verified_pending_club",
|
||||
)
|
||||
)
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
id="club.creation_request.read_own",
|
||||
name="Eigene Gründungsanträge",
|
||||
domain="club",
|
||||
module="club_creation_requests",
|
||||
min_account_state="verified_pending_club",
|
||||
)
|
||||
)
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
id="club.creation_request.withdraw",
|
||||
name="Gründungsantrag zurückziehen",
|
||||
domain="club",
|
||||
module="club_creation_requests",
|
||||
min_account_state="verified_pending_club",
|
||||
)
|
||||
)
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
id="platform.club_creation.approve",
|
||||
name="Vereinsgründung freigeben",
|
||||
domain="platform",
|
||||
module="club_creation_requests",
|
||||
min_account_state="platform_admin",
|
||||
)
|
||||
)
|
||||
90
backend/rights_registrations/exercises.py
Normal file
90
backend/rights_registrations/exercises.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Übungen-Modul: nur Rechte/Kontingente mit echter Endpoint-Verdrahtung."""
|
||||
from rights_registry import CapabilityRegistration, FeatureRegistration, register_capability, register_feature
|
||||
|
||||
_CLUB_WRITE_ROLES = (
|
||||
"club_admin",
|
||||
"trainer",
|
||||
"content_editor",
|
||||
"division_lead",
|
||||
)
|
||||
|
||||
register_feature(
|
||||
FeatureRegistration(
|
||||
id="ai_calls",
|
||||
name="KI-Aufrufe",
|
||||
module="exercises",
|
||||
category="ai",
|
||||
limit_type="count",
|
||||
reset_period="monthly",
|
||||
default_limit=0,
|
||||
description="KI-Aufrufe pro Monat (Suggest, Regenerate)",
|
||||
)
|
||||
)
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
id="exercises.ai.suggest",
|
||||
name="KI-Vorschlag Übung",
|
||||
domain="exercises",
|
||||
module="exercises",
|
||||
linked_feature_id="ai_calls",
|
||||
default_club_grants=tuple((r, "exercises.ai.suggest") for r in _CLUB_WRITE_ROLES),
|
||||
)
|
||||
)
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
id="exercises.ai.regenerate",
|
||||
name="KI neu generieren",
|
||||
domain="exercises",
|
||||
module="exercises",
|
||||
linked_feature_id="ai_calls",
|
||||
default_club_grants=tuple((r, "exercises.ai.regenerate") for r in _CLUB_WRITE_ROLES),
|
||||
)
|
||||
)
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
id="exercises.create",
|
||||
name="Übung anlegen",
|
||||
domain="exercises",
|
||||
module="exercises",
|
||||
linked_feature_id="exercises",
|
||||
default_club_grants=tuple((r, "exercises.create") for r in _CLUB_WRITE_ROLES),
|
||||
)
|
||||
)
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
id="exercises.media.upload",
|
||||
name="Übungsmedien hochladen",
|
||||
domain="exercises",
|
||||
module="exercises",
|
||||
linked_feature_id="exercise_media",
|
||||
default_club_grants=(
|
||||
("club_admin", "exercises.media.upload"),
|
||||
("trainer", "exercises.media.upload"),
|
||||
("content_editor", "exercises.media.upload"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
register_feature(
|
||||
FeatureRegistration(
|
||||
id="exercises",
|
||||
name="Übungen (Bestand)",
|
||||
module="exercises",
|
||||
category="content",
|
||||
limit_type="count",
|
||||
reset_period="never",
|
||||
default_limit=0,
|
||||
)
|
||||
)
|
||||
register_feature(
|
||||
FeatureRegistration(
|
||||
id="exercise_media",
|
||||
name="Übungsmedien",
|
||||
module="exercises",
|
||||
category="media",
|
||||
limit_type="count",
|
||||
reset_period="never",
|
||||
default_limit=0,
|
||||
)
|
||||
)
|
||||
24
backend/rights_registrations/planning.py
Normal file
24
backend/rights_registrations/planning.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from rights_registry import CapabilityRegistration, FeatureRegistration, register_capability, register_feature
|
||||
|
||||
_PLANNING_ROLES = ("club_admin", "trainer", "division_lead")
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
id="planning.ai.suggest",
|
||||
name="Planungs-KI Suggest",
|
||||
domain="planning",
|
||||
module="planning_exercise_suggest",
|
||||
linked_feature_id="ai_calls",
|
||||
default_club_grants=tuple((r, "planning.ai.suggest") for r in _PLANNING_ROLES),
|
||||
)
|
||||
)
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
id="planning.ai.progression_path",
|
||||
name="Planungs-KI Progressionspfad",
|
||||
domain="planning",
|
||||
module="planning_exercise_suggest",
|
||||
linked_feature_id="ai_calls",
|
||||
default_club_grants=tuple((r, "planning.ai.progression_path") for r in _PLANNING_ROLES),
|
||||
)
|
||||
)
|
||||
21
backend/rights_registrations/platform.py
Normal file
21
backend/rights_registrations/platform.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""Plattform-Modul: Admin-Zugang und Quota-Bypass (083)."""
|
||||
from rights_registry import CapabilityRegistration, register_capability
|
||||
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
id="platform.admin.access",
|
||||
name="Plattform-Admin-Bereich",
|
||||
domain="platform",
|
||||
module="platform",
|
||||
min_account_state="platform_admin",
|
||||
)
|
||||
)
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
id="platform.club_quota.bypass",
|
||||
name="Vereins-Kontingent-Bypass",
|
||||
domain="quota_bypass",
|
||||
module="platform",
|
||||
min_account_state="platform_admin",
|
||||
)
|
||||
)
|
||||
159
backend/rights_registry.py
Normal file
159
backend/rights_registry.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"""
|
||||
Registry-first: Module melden Rechte (capabilities) und Kontingente (features) bei Implementierung an.
|
||||
|
||||
Kein vollständiger Vorab-Katalog — nur was ein Modul wirklich liefert, erscheint konfigurierbar
|
||||
in Admin „Rollen & Rechte“ (Filter: module IS NOT NULL).
|
||||
|
||||
Spez: docs/working/RIGHTS_AND_FEATURES_REGISTRY.md
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from db import get_db, get_cursor
|
||||
|
||||
GrantPair = Tuple[str, str] # (role_code, capability_id)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapabilityRegistration:
|
||||
id: str
|
||||
name: str
|
||||
domain: str
|
||||
module: str
|
||||
min_account_state: str = "active_member"
|
||||
linked_feature_id: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
default_club_grants: Sequence[GrantPair] = field(default_factory=tuple)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeatureRegistration:
|
||||
id: str
|
||||
name: str
|
||||
module: str
|
||||
category: str = "general"
|
||||
limit_type: str = "count"
|
||||
reset_period: str = "never"
|
||||
default_limit: Optional[int] = None
|
||||
description: Optional[str] = None
|
||||
enforcement_subject: str = "club"
|
||||
|
||||
|
||||
_CAPABILITY_REGISTRY: Dict[str, CapabilityRegistration] = {}
|
||||
_FEATURE_REGISTRY: Dict[str, FeatureRegistration] = {}
|
||||
|
||||
|
||||
def register_capability(defn: CapabilityRegistration) -> None:
|
||||
"""Modul deklariert ein Recht — wird beim Startup in die DB synchronisiert."""
|
||||
if not defn.module or not defn.id:
|
||||
raise ValueError("CapabilityRegistration: module und id sind Pflicht")
|
||||
_CAPABILITY_REGISTRY[defn.id] = defn
|
||||
|
||||
|
||||
def register_feature(defn: FeatureRegistration) -> None:
|
||||
"""Modul deklariert ein Vereins-Kontingent."""
|
||||
if not defn.module or not defn.id:
|
||||
raise ValueError("FeatureRegistration: module und id sind Pflicht")
|
||||
_FEATURE_REGISTRY[defn.id] = defn
|
||||
|
||||
|
||||
def registered_capabilities() -> Dict[str, CapabilityRegistration]:
|
||||
return dict(_CAPABILITY_REGISTRY)
|
||||
|
||||
|
||||
def registered_features() -> Dict[str, FeatureRegistration]:
|
||||
return dict(_FEATURE_REGISTRY)
|
||||
|
||||
|
||||
def _upsert_capability(cur, defn: CapabilityRegistration) -> None:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO capabilities (
|
||||
id, name, description, domain, min_account_state,
|
||||
linked_feature_id, active, module, updated_at
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, true, %s, NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
domain = EXCLUDED.domain,
|
||||
min_account_state = EXCLUDED.min_account_state,
|
||||
linked_feature_id = EXCLUDED.linked_feature_id,
|
||||
active = true,
|
||||
module = EXCLUDED.module,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(
|
||||
defn.id,
|
||||
defn.name,
|
||||
defn.description,
|
||||
defn.domain,
|
||||
defn.min_account_state,
|
||||
defn.linked_feature_id,
|
||||
defn.module,
|
||||
),
|
||||
)
|
||||
for role_code, cap_id in defn.default_club_grants:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO club_role_capability_grants (role_code, capability_id)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(role_code, cap_id),
|
||||
)
|
||||
|
||||
|
||||
def _upsert_feature(cur, defn: FeatureRegistration) -> None:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO features (
|
||||
id, app, name, description, category, limit_type,
|
||||
reset_period, default_limit, enforcement_subject, active, module
|
||||
)
|
||||
VALUES (%s, 'shinkan', %s, %s, %s, %s, %s, %s, %s, true, %s)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
category = EXCLUDED.category,
|
||||
limit_type = EXCLUDED.limit_type,
|
||||
reset_period = EXCLUDED.reset_period,
|
||||
default_limit = EXCLUDED.default_limit,
|
||||
enforcement_subject = EXCLUDED.enforcement_subject,
|
||||
active = true,
|
||||
module = EXCLUDED.module
|
||||
""",
|
||||
(
|
||||
defn.id,
|
||||
defn.name,
|
||||
defn.description,
|
||||
defn.category,
|
||||
defn.limit_type,
|
||||
defn.reset_period,
|
||||
defn.default_limit,
|
||||
defn.enforcement_subject,
|
||||
defn.module,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def sync_rights_registry_to_db() -> Dict[str, int]:
|
||||
"""
|
||||
Startup: registrierte Module → DB. Admin-Matrix zeigt nur Einträge mit module.
|
||||
"""
|
||||
import rights_registrations # noqa: F401 — lädt alle Modul-Registrierungen
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
for defn in _CAPABILITY_REGISTRY.values():
|
||||
_upsert_capability(cur, defn)
|
||||
for defn in _FEATURE_REGISTRY.values():
|
||||
_upsert_feature(cur, defn)
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"capabilities": len(_CAPABILITY_REGISTRY),
|
||||
"features": len(_FEATURE_REGISTRY),
|
||||
}
|
||||
603
backend/routers/admin_rights.py
Normal file
603
backend/routers/admin_rights.py
Normal file
|
|
@ -0,0 +1,603 @@
|
|||
"""
|
||||
Superadmin: Rollen & Rechte — Capability-Grants, Kontingent-Bypass, Vereins-Kontingente.
|
||||
|
||||
Ein Router für das Rechtesystem (M6). Kein paralleles Exemption-Schema.
|
||||
"""
|
||||
import os
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from auth import require_auth
|
||||
from club_quota_bypass import (
|
||||
QUOTA_BYPASS_ALL,
|
||||
ensure_quota_bypass_capability,
|
||||
list_quota_bypass_grants,
|
||||
quota_bypass_capability_id_for_feature,
|
||||
)
|
||||
from capabilities import capability_enforcement_enabled
|
||||
from capability_enforcement_audit import (
|
||||
enforcement_status_for_capability,
|
||||
feature_consume_status,
|
||||
)
|
||||
from club_features import club_feature_enforcement_enabled
|
||||
from club_tenancy import is_superadmin
|
||||
from db import get_db, get_cursor, r2d
|
||||
|
||||
router = APIRouter(prefix="/api/admin/rights", tags=["admin_rights"])
|
||||
|
||||
PORTAL_ROLES = ("user", "trainer", "admin", "superadmin")
|
||||
CLUB_ROLES = ("club_admin", "trainer", "division_lead", "content_editor")
|
||||
|
||||
|
||||
def _require_superadmin(session: dict) -> None:
|
||||
if not is_superadmin(session.get("role")):
|
||||
raise HTTPException(status_code=403, detail="Nur Super-Administratoren")
|
||||
|
||||
|
||||
def _resolve_quota_bypass_capability_id(cur, feature_id: Optional[str]) -> str:
|
||||
fid = (feature_id or "").strip() or None
|
||||
if not fid:
|
||||
return QUOTA_BYPASS_ALL
|
||||
cur.execute("SELECT 1 FROM features WHERE id = %s", (fid,))
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=400, detail="Unbekanntes Feature")
|
||||
return ensure_quota_bypass_capability(cur, fid)
|
||||
|
||||
|
||||
class PlanLimitItem(BaseModel):
|
||||
feature_id: str
|
||||
limit_value: Optional[int] = Field(
|
||||
None,
|
||||
description="NULL = unbegrenzt; 0 = deaktiviert (boolean/count)",
|
||||
)
|
||||
|
||||
|
||||
class PlanLimitsBody(BaseModel):
|
||||
limits: List[PlanLimitItem]
|
||||
|
||||
|
||||
class ClubSubscriptionBody(BaseModel):
|
||||
plan_id: str
|
||||
status: str = Field(default="active", pattern="^(active|trial|past_due|cancelled)$")
|
||||
|
||||
|
||||
class PortalCapabilityGrantBody(BaseModel):
|
||||
portal_role: str = Field(..., min_length=1, max_length=50)
|
||||
capability_id: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class ClubRoleCapabilityGrantBody(BaseModel):
|
||||
role_code: str = Field(..., min_length=1, max_length=50)
|
||||
capability_id: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class QuotaBypassPortalBody(BaseModel):
|
||||
portal_role: str = Field(..., min_length=1, max_length=50)
|
||||
feature_id: Optional[str] = Field(
|
||||
None,
|
||||
description="Feature-ID oder leer = alle Vereins-Features (platform.club_quota.bypass)",
|
||||
)
|
||||
|
||||
|
||||
class QuotaBypassProfileBody(BaseModel):
|
||||
feature_id: Optional[str] = Field(None, description="Feature-ID oder leer = alle Features")
|
||||
reason: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
|
||||
# ── Enforcement-Diagnose (Superadmin) ────────────────────────────────────────
|
||||
|
||||
@router.get("/enforcement-status")
|
||||
def get_enforcement_status(session: dict = Depends(require_auth)):
|
||||
"""Prüft ob Hard-Block-Env im laufenden Container/Prozess ankommt."""
|
||||
_require_superadmin(session)
|
||||
raw = os.getenv("CLUB_FEATURE_ENFORCE", "0")
|
||||
return {
|
||||
"club_feature_enforce_active": club_feature_enforcement_enabled(),
|
||||
"club_feature_enforce_raw": raw,
|
||||
"capability_enforce_active": capability_enforcement_enabled(),
|
||||
"capability_enforce_raw": os.getenv("CAPABILITY_ENFORCE", "0"),
|
||||
"hints": [
|
||||
"Nach .env-Änderung: docker compose up -d backend (Container neu erstellen).",
|
||||
"Superadmin hat Quota-Bypass — KI-Limit-Test als Trainer, nicht als Superadmin.",
|
||||
"Ohne aktiven Verein (X-Active-Club-Id) blockiert Enforce ebenfalls.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── Capability-Matrix (Rollen → Fähigkeiten) ─────────────────────────────────
|
||||
|
||||
@router.get("/capability-matrix")
|
||||
def get_capability_matrix(session: dict = Depends(require_auth)):
|
||||
_require_superadmin(session)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name, domain, min_account_state, linked_feature_id, module
|
||||
FROM capabilities
|
||||
WHERE active = true AND module IS NOT NULL
|
||||
ORDER BY module, domain, id
|
||||
"""
|
||||
)
|
||||
capabilities = []
|
||||
for row in cur.fetchall():
|
||||
cap = r2d(row)
|
||||
cap["enforcement"] = enforcement_status_for_capability(cap.get("id"))
|
||||
if cap.get("linked_feature_id"):
|
||||
cap["feature_consume"] = feature_consume_status(cap["linked_feature_id"])
|
||||
capabilities.append(cap)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT portal_role, capability_id
|
||||
FROM portal_role_capability_grants
|
||||
ORDER BY portal_role, capability_id
|
||||
"""
|
||||
)
|
||||
portal_grants = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT role_code, capability_id
|
||||
FROM club_role_capability_grants
|
||||
ORDER BY role_code, capability_id
|
||||
"""
|
||||
)
|
||||
club_role_grants = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
return {
|
||||
"portal_roles": list(PORTAL_ROLES),
|
||||
"club_roles": list(CLUB_ROLES),
|
||||
"capabilities": capabilities,
|
||||
"portal_grants": portal_grants,
|
||||
"club_role_grants": club_role_grants,
|
||||
"registry_only": True,
|
||||
"hint": (
|
||||
"Nur vom Modul registrierte Rechte (capabilities.module). "
|
||||
"Legacy-Katalog-Seed ohne module erscheint nicht."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/capability-grants/portal-roles", status_code=201)
|
||||
def add_portal_capability_grant(body: PortalCapabilityGrantBody, session: dict = Depends(require_auth)):
|
||||
_require_superadmin(session)
|
||||
role = body.portal_role.strip().lower()
|
||||
cap_id = body.capability_id.strip()
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT domain FROM capabilities WHERE id = %s AND active = true", (cap_id,))
|
||||
cap = cur.fetchone()
|
||||
if not cap:
|
||||
raise HTTPException(status_code=400, detail="Unbekannte Capability")
|
||||
domain = (cap.get("domain") or "").lower()
|
||||
if domain not in ("platform", "quota_bypass") and not cap_id.startswith("platform."):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Portal-Grants nur für domain=platform oder quota_bypass",
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO portal_role_capability_grants (portal_role, capability_id)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING portal_role, capability_id
|
||||
""",
|
||||
(role, cap_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=409, detail="Grant existiert bereits")
|
||||
conn.commit()
|
||||
return r2d(row)
|
||||
|
||||
|
||||
@router.delete("/capability-grants/portal-roles")
|
||||
def delete_portal_capability_grant(
|
||||
portal_role: str = Query(...),
|
||||
capability_id: str = Query(...),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
_require_superadmin(session)
|
||||
role = portal_role.strip().lower()
|
||||
cap_id = capability_id.strip()
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM portal_role_capability_grants
|
||||
WHERE portal_role = %s AND capability_id = %s
|
||||
RETURNING portal_role, capability_id
|
||||
""",
|
||||
(role, cap_id),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Grant nicht gefunden")
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/capability-grants/club-roles", status_code=201)
|
||||
def add_club_role_capability_grant(
|
||||
body: ClubRoleCapabilityGrantBody,
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
_require_superadmin(session)
|
||||
role = body.role_code.strip().lower()
|
||||
cap_id = body.capability_id.strip()
|
||||
|
||||
if role not in CLUB_ROLES:
|
||||
raise HTTPException(status_code=400, detail="Unbekannte Vereinsrolle")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT domain FROM capabilities
|
||||
WHERE id = %s AND active = true AND domain NOT IN ('platform', 'quota_bypass')
|
||||
""",
|
||||
(cap_id,),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=400, detail="Capability nicht für Vereinsrollen")
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO club_role_capability_grants (role_code, capability_id)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING role_code, capability_id
|
||||
""",
|
||||
(role, cap_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=409, detail="Grant existiert bereits")
|
||||
conn.commit()
|
||||
return r2d(row)
|
||||
|
||||
|
||||
@router.delete("/capability-grants/club-roles/by-capability")
|
||||
def clear_club_capability_grants(
|
||||
capability_id: str = Query(...),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
"""Alle Rollen-Grants einer Capability entfernen → wieder offen für alle Mitglieder."""
|
||||
_require_superadmin(session)
|
||||
cap_id = capability_id.strip()
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM club_role_capability_grants
|
||||
WHERE capability_id = %s
|
||||
""",
|
||||
(cap_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return {"ok": True, "capability_id": cap_id}
|
||||
|
||||
|
||||
@router.delete("/capability-grants/club-roles")
|
||||
def delete_club_role_capability_grant(
|
||||
role_code: str = Query(...),
|
||||
capability_id: str = Query(...),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
_require_superadmin(session)
|
||||
role = role_code.strip().lower()
|
||||
cap_id = capability_id.strip()
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM club_role_capability_grants
|
||||
WHERE role_code = %s AND capability_id = %s
|
||||
RETURNING role_code, capability_id
|
||||
""",
|
||||
(role, cap_id),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Grant nicht gefunden")
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Kontingent-Bypass (Capability-Grants) ───────────────────────────────────
|
||||
|
||||
@router.get("/quota-bypass")
|
||||
def list_quota_bypass(session: dict = Depends(require_auth)):
|
||||
_require_superadmin(session)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
return list_quota_bypass_grants(cur)
|
||||
|
||||
|
||||
@router.post("/quota-bypass/portal-roles", status_code=201)
|
||||
def add_quota_bypass_portal_grant(body: QuotaBypassPortalBody, session: dict = Depends(require_auth)):
|
||||
_require_superadmin(session)
|
||||
role = body.portal_role.strip().lower()
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cap_id = _resolve_quota_bypass_capability_id(cur, body.feature_id)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM portal_role_capability_grants
|
||||
WHERE portal_role = %s AND capability_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(role, cap_id),
|
||||
)
|
||||
if cur.fetchone():
|
||||
raise HTTPException(status_code=409, detail="Grant existiert bereits")
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO portal_role_capability_grants (portal_role, capability_id)
|
||||
VALUES (%s, %s)
|
||||
RETURNING portal_role, capability_id
|
||||
""",
|
||||
(role, cap_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
out = r2d(row)
|
||||
out["capability_id"] = cap_id
|
||||
out["feature_id"] = (body.feature_id or "").strip() or None
|
||||
return out
|
||||
|
||||
|
||||
@router.delete("/quota-bypass/portal-roles")
|
||||
def delete_quota_bypass_portal_grant(
|
||||
portal_role: str = Query(...),
|
||||
capability_id: Optional[str] = Query(None),
|
||||
feature_id: Optional[str] = Query(None),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
_require_superadmin(session)
|
||||
role = portal_role.strip().lower()
|
||||
cap_id = capability_id
|
||||
if not cap_id:
|
||||
cap_id = (
|
||||
QUOTA_BYPASS_ALL
|
||||
if not (feature_id or "").strip()
|
||||
else quota_bypass_capability_id_for_feature(feature_id.strip())
|
||||
)
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM portal_role_capability_grants
|
||||
WHERE portal_role = %s AND capability_id = %s
|
||||
RETURNING portal_role, capability_id
|
||||
""",
|
||||
(role, cap_id),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Grant nicht gefunden")
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/quota-bypass/profiles/{profile_id}", status_code=201)
|
||||
def add_quota_bypass_profile_grant(
|
||||
profile_id: int,
|
||||
body: QuotaBypassProfileBody,
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
_require_superadmin(session)
|
||||
admin_pid = int(session["profile_id"])
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT 1 FROM profiles WHERE id = %s", (profile_id,))
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
|
||||
|
||||
cap_id = _resolve_quota_bypass_capability_id(cur, body.feature_id)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM profile_capability_grants
|
||||
WHERE profile_id = %s AND capability_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(profile_id, cap_id),
|
||||
)
|
||||
if cur.fetchone():
|
||||
raise HTTPException(status_code=409, detail="Grant existiert bereits")
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO profile_capability_grants (
|
||||
profile_id, capability_id, reason, granted_by_profile_id
|
||||
)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING profile_id, capability_id, reason, granted_by_profile_id, created_at
|
||||
""",
|
||||
(profile_id, cap_id, (body.reason or "").strip() or None, admin_pid),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
return r2d(row)
|
||||
|
||||
|
||||
@router.delete("/quota-bypass/profiles")
|
||||
def delete_quota_bypass_profile_grant(
|
||||
profile_id: int = Query(...),
|
||||
capability_id: Optional[str] = Query(None),
|
||||
feature_id: Optional[str] = Query(None),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
_require_superadmin(session)
|
||||
cap_id = capability_id
|
||||
if not cap_id:
|
||||
cap_id = (
|
||||
QUOTA_BYPASS_ALL
|
||||
if not (feature_id or "").strip()
|
||||
else quota_bypass_capability_id_for_feature(feature_id.strip())
|
||||
)
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM profile_capability_grants
|
||||
WHERE profile_id = %s AND capability_id = %s
|
||||
RETURNING profile_id, capability_id
|
||||
""",
|
||||
(profile_id, cap_id),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Grant nicht gefunden")
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Vereins-Kontingente (Pläne & Zuordnung) ─────────────────────────────────
|
||||
|
||||
@router.get("/club-plans/matrix")
|
||||
def get_club_plans_matrix(session: dict = Depends(require_auth)):
|
||||
"""Aktive Vereinspläne, club-scoped Features und Limit-Matrix."""
|
||||
_require_superadmin(session)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name, description, sort_order, active
|
||||
FROM club_plans
|
||||
WHERE active = true
|
||||
ORDER BY sort_order, id
|
||||
"""
|
||||
)
|
||||
plans = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name, description, category, limit_type, reset_period, default_limit, module
|
||||
FROM features
|
||||
WHERE app = 'shinkan' AND active = true AND enforcement_subject = 'club'
|
||||
AND module IS NOT NULL
|
||||
ORDER BY module, category, id
|
||||
"""
|
||||
)
|
||||
features = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT plan_id, feature_id, limit_value
|
||||
FROM club_plan_limits
|
||||
WHERE plan_id IN (SELECT id FROM club_plans WHERE active = true)
|
||||
"""
|
||||
)
|
||||
limits: Dict[str, Dict[str, Optional[int]]] = {}
|
||||
for row in cur.fetchall():
|
||||
pid = row["plan_id"]
|
||||
fid = row["feature_id"]
|
||||
limits.setdefault(pid, {})[fid] = row.get("limit_value")
|
||||
|
||||
return {"plans": plans, "features": features, "limits": limits}
|
||||
|
||||
|
||||
@router.put("/club-plans/{plan_id}/limits")
|
||||
def update_club_plan_limits(
|
||||
plan_id: str,
|
||||
body: PlanLimitsBody,
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
_require_superadmin(session)
|
||||
plan_id = plan_id.strip()
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT 1 FROM club_plans WHERE id = %s AND active = true", (plan_id,))
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Plan nicht gefunden")
|
||||
|
||||
for item in body.limits:
|
||||
fid = item.feature_id.strip()
|
||||
cur.execute(
|
||||
"SELECT 1 FROM features WHERE id = %s AND app = 'shinkan'",
|
||||
(fid,),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=400, detail=f"Unbekanntes Feature: {fid}")
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO club_plan_limits (plan_id, feature_id, limit_value)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (plan_id, feature_id)
|
||||
DO UPDATE SET limit_value = EXCLUDED.limit_value
|
||||
""",
|
||||
(plan_id, fid, item.limit_value),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"ok": True, "plan_id": plan_id, "updated": len(body.limits)}
|
||||
|
||||
|
||||
@router.get("/club-subscriptions")
|
||||
def list_club_subscriptions(session: dict = Depends(require_auth)):
|
||||
_require_superadmin(session)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT c.id AS club_id, c.name AS club_name,
|
||||
cs.plan_id, cs.status, cs.started_at, cs.ends_at
|
||||
FROM clubs c
|
||||
LEFT JOIN club_subscriptions cs ON cs.club_id = c.id
|
||||
ORDER BY lower(c.name), c.id
|
||||
"""
|
||||
)
|
||||
rows = []
|
||||
for r in cur.fetchall():
|
||||
d = r2d(r)
|
||||
if not d.get("plan_id"):
|
||||
d["plan_id"] = "free"
|
||||
d["status"] = "active"
|
||||
rows.append(d)
|
||||
return rows
|
||||
|
||||
|
||||
@router.put("/clubs/{club_id}/subscription")
|
||||
def update_club_subscription(
|
||||
club_id: int,
|
||||
body: ClubSubscriptionBody,
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
_require_superadmin(session)
|
||||
plan_id = body.plan_id.strip()
|
||||
status = body.status.strip().lower()
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT 1 FROM clubs WHERE id = %s", (club_id,))
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
|
||||
|
||||
cur.execute("SELECT 1 FROM club_plans WHERE id = %s AND active = true", (plan_id,))
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=400, detail="Unbekannter Plan")
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO club_subscriptions (club_id, plan_id, status)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (club_id)
|
||||
DO UPDATE SET plan_id = EXCLUDED.plan_id, status = EXCLUDED.status, updated_at = NOW()
|
||||
RETURNING club_id, plan_id, status
|
||||
""",
|
||||
(club_id, plan_id, status),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
return r2d(row)
|
||||
398
backend/routers/club_creation_requests.py
Normal file
398
backend/routers/club_creation_requests.py
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
"""
|
||||
Anträge auf Vereinsgründung: Nutzer stellt Antrag, Plattform-Admin legt Verein + Abo an.
|
||||
"""
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from account_lifecycle import assert_min_account_state
|
||||
from capabilities import probe_capability
|
||||
from club_tenancy import is_platform_admin
|
||||
from db import get_db, get_cursor, r2d
|
||||
from tenant_context import TenantContext, get_tenant_context
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["club_creation_requests"])
|
||||
|
||||
_FREE_PLAN_ID = "free"
|
||||
|
||||
|
||||
def _has_active_membership(cur, profile_id: int) -> bool:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM club_members
|
||||
WHERE profile_id = %s AND status = 'active'
|
||||
LIMIT 1
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def _club_name_taken(cur, name: str, *, exclude_club_id: Optional[int] = None) -> bool:
|
||||
n = (name or "").strip()
|
||||
if not n:
|
||||
return False
|
||||
if exclude_club_id is not None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM clubs
|
||||
WHERE lower(trim(name)) = lower(trim(%s)) AND id <> %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(n, exclude_club_id),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM clubs
|
||||
WHERE lower(trim(name)) = lower(trim(%s))
|
||||
LIMIT 1
|
||||
""",
|
||||
(n,),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def _provision_club_for_founder(
|
||||
cur,
|
||||
*,
|
||||
founder_profile_id: int,
|
||||
name: str,
|
||||
abbreviation: Optional[str],
|
||||
description: Optional[str],
|
||||
) -> int:
|
||||
"""Legt Verein, Mitgliedschaft (club_admin+trainer) und Free-Abo an."""
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO clubs (name, abbreviation, description, status, primary_admin_profile_id)
|
||||
VALUES (%s, %s, %s, 'active', %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(name, abbreviation, description, founder_profile_id),
|
||||
)
|
||||
club_id = int(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
|
||||
""",
|
||||
(founder_profile_id, 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),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO club_subscriptions (club_id, plan_id, status)
|
||||
VALUES (%s, %s, 'active')
|
||||
ON CONFLICT (club_id) DO NOTHING
|
||||
""",
|
||||
(club_id, _FREE_PLAN_ID),
|
||||
)
|
||||
return club_id
|
||||
|
||||
|
||||
class CreationRequestCreate(BaseModel):
|
||||
proposed_name: str = Field(..., min_length=2, max_length=200)
|
||||
proposed_abbreviation: Optional[str] = Field(None, max_length=50)
|
||||
proposed_description: Optional[str] = Field(None, max_length=5000)
|
||||
message: Optional[str] = Field(None, max_length=2000)
|
||||
|
||||
|
||||
def _normalize_creation_request_row(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Approved ohne Verein → superseded (z. B. nach Vereinslöschung, FK SET NULL)."""
|
||||
d = dict(row)
|
||||
if d.get("status") == "approved" and not d.get("created_club_id"):
|
||||
d["status"] = "superseded"
|
||||
return d
|
||||
|
||||
|
||||
def _response_one(cur, req_id: int, viewer_profile_id: int) -> Dict[str, Any]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT r.*, c.name AS created_club_name
|
||||
FROM club_creation_requests r
|
||||
LEFT JOIN clubs c ON c.id = r.created_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 _normalize_creation_request_row(r2d(row))
|
||||
|
||||
|
||||
def _assert_platform_admin(tenant: TenantContext) -> None:
|
||||
if not is_platform_admin(tenant.global_role):
|
||||
raise HTTPException(status_code=403, detail="Nur Plattform-Administratoren")
|
||||
|
||||
|
||||
@router.get("/me/club-creation-requests")
|
||||
def get_my_creation_requests(tenant: TenantContext = Depends(get_tenant_context)):
|
||||
assert_min_account_state(tenant, "verified_pending_club", endpoint="GET /me/club-creation-requests")
|
||||
pid = tenant.profile_id
|
||||
with get_db() as conn:
|
||||
probe_capability(
|
||||
tenant,
|
||||
"club.creation_request.read_own",
|
||||
action="read",
|
||||
endpoint="GET /me/club-creation-requests",
|
||||
conn=conn,
|
||||
)
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT r.*, c.name AS created_club_name
|
||||
FROM club_creation_requests r
|
||||
LEFT JOIN clubs c ON c.id = r.created_club_id
|
||||
WHERE r.profile_id = %s
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT 50
|
||||
""",
|
||||
(pid,),
|
||||
)
|
||||
return [_normalize_creation_request_row(r2d(r)) for r in cur.fetchall()]
|
||||
|
||||
|
||||
@router.post("/me/club-creation-requests", status_code=201)
|
||||
def create_my_creation_request(
|
||||
body: CreationRequestCreate,
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
assert_min_account_state(tenant, "verified_pending_club", endpoint="POST /me/club-creation-requests")
|
||||
pid = tenant.profile_id
|
||||
name = body.proposed_name.strip()
|
||||
abbr = (body.proposed_abbreviation or "").strip() or None
|
||||
desc = (body.proposed_description or "").strip() or None
|
||||
msg = (body.message or "").strip() or None
|
||||
|
||||
with get_db() as conn:
|
||||
probe_capability(
|
||||
tenant,
|
||||
"club.creation_request.create",
|
||||
action="create",
|
||||
endpoint="POST /me/club-creation-requests",
|
||||
conn=conn,
|
||||
)
|
||||
cur = get_cursor(conn)
|
||||
|
||||
if _has_active_membership(cur, pid):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Du bist bereits Vereinsmitglied — Gründungsantrag nicht möglich",
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM club_creation_requests
|
||||
WHERE profile_id = %s AND status = 'pending'
|
||||
LIMIT 1
|
||||
""",
|
||||
(pid,),
|
||||
)
|
||||
if cur.fetchone():
|
||||
raise HTTPException(status_code=409, detail="Es liegt bereits ein offener Gründungsantrag vor")
|
||||
|
||||
if _club_name_taken(cur, name):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Ein Verein mit diesem Namen existiert bereits — bitte anderen Namen wählen",
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO club_creation_requests (
|
||||
profile_id, proposed_name, proposed_abbreviation,
|
||||
proposed_description, message, status
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, 'pending')
|
||||
RETURNING id
|
||||
""",
|
||||
(pid, name, abbr, desc, 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-creation-requests/{request_id}")
|
||||
def withdraw_my_creation_request(request_id: int, tenant: TenantContext = Depends(get_tenant_context)):
|
||||
assert_min_account_state(
|
||||
tenant, "verified_pending_club", endpoint="DELETE /me/club-creation-requests/{id}"
|
||||
)
|
||||
pid = tenant.profile_id
|
||||
with get_db() as conn:
|
||||
probe_capability(
|
||||
tenant,
|
||||
"club.creation_request.withdraw",
|
||||
action="withdraw",
|
||||
endpoint="DELETE /me/club-creation-requests/{id}",
|
||||
conn=conn,
|
||||
)
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE club_creation_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("/admin/club-creation-requests")
|
||||
def list_admin_creation_requests(tenant: TenantContext = Depends(get_tenant_context)):
|
||||
_assert_platform_admin(tenant)
|
||||
with get_db() as conn:
|
||||
probe_capability(
|
||||
tenant,
|
||||
"platform.club_creation.approve",
|
||||
action="list",
|
||||
endpoint="GET /admin/club-creation-requests",
|
||||
conn=conn,
|
||||
)
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT r.*,
|
||||
p.name AS applicant_name,
|
||||
p.email AS applicant_email,
|
||||
c.name AS created_club_name
|
||||
FROM club_creation_requests r
|
||||
INNER JOIN profiles p ON p.id = r.profile_id
|
||||
LEFT JOIN clubs c ON c.id = r.created_club_id
|
||||
WHERE r.status = 'pending'
|
||||
ORDER BY r.created_at ASC
|
||||
"""
|
||||
)
|
||||
return [r2d(row) for row in cur.fetchall()]
|
||||
|
||||
|
||||
@router.post("/admin/club-creation-requests/{request_id}/approve")
|
||||
def approve_creation_request(request_id: int, tenant: TenantContext = Depends(get_tenant_context)):
|
||||
_assert_platform_admin(tenant)
|
||||
admin_pid = tenant.profile_id
|
||||
|
||||
with get_db() as conn:
|
||||
probe_capability(
|
||||
tenant,
|
||||
"platform.club_creation.approve",
|
||||
action="approve",
|
||||
endpoint="POST /admin/club-creation-requests/{id}/approve",
|
||||
conn=conn,
|
||||
)
|
||||
cur = get_cursor(conn)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, profile_id, proposed_name, proposed_abbreviation,
|
||||
proposed_description, status
|
||||
FROM club_creation_requests
|
||||
WHERE id = %s
|
||||
""",
|
||||
(request_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 = int(row["profile_id"])
|
||||
name = (row["proposed_name"] or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Vorgeschlagener Vereinsname fehlt")
|
||||
|
||||
if _has_active_membership(cur, applicant_id):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Antragsteller ist bereits Vereinsmitglied — Freigabe nicht möglich",
|
||||
)
|
||||
|
||||
if _club_name_taken(cur, name):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Ein Verein mit diesem Namen existiert bereits",
|
||||
)
|
||||
|
||||
club_id = _provision_club_for_founder(
|
||||
cur,
|
||||
founder_profile_id=applicant_id,
|
||||
name=name,
|
||||
abbreviation=row.get("proposed_abbreviation"),
|
||||
description=row.get("proposed_description"),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE club_creation_requests
|
||||
SET status = 'approved',
|
||||
decided_by_profile_id = %s,
|
||||
decided_at = NOW(),
|
||||
created_club_id = %s,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s AND status = 'pending'
|
||||
RETURNING id
|
||||
""",
|
||||
(admin_pid, club_id, request_id),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=409, detail="Antrag konnte nicht freigegeben werden")
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {"ok": True, "club_id": club_id, "profile_id": applicant_id}
|
||||
|
||||
|
||||
@router.post("/admin/club-creation-requests/{request_id}/reject")
|
||||
def reject_creation_request(request_id: int, tenant: TenantContext = Depends(get_tenant_context)):
|
||||
_assert_platform_admin(tenant)
|
||||
admin_pid = tenant.profile_id
|
||||
|
||||
with get_db() as conn:
|
||||
probe_capability(
|
||||
tenant,
|
||||
"platform.club_creation.approve",
|
||||
action="reject",
|
||||
endpoint="POST /admin/club-creation-requests/{id}/reject",
|
||||
conn=conn,
|
||||
)
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE club_creation_requests
|
||||
SET status = 'rejected',
|
||||
decided_by_profile_id = %s,
|
||||
decided_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = %s AND status = 'pending'
|
||||
RETURNING id
|
||||
""",
|
||||
(admin_pid, request_id),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Offener Antrag nicht gefunden")
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
|
@ -336,6 +336,16 @@ def delete_club(club_id: int, tenant: TenantContext = Depends(get_tenant_context
|
|||
if not cur.fetchone():
|
||||
raise HTTPException(404, "Verein nicht gefunden")
|
||||
|
||||
# Gründungsanträge: Freigabe verliert Gültigkeit wenn Verein entfernt wird
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE club_creation_requests
|
||||
SET status = 'superseded', updated_at = NOW()
|
||||
WHERE created_club_id = %s AND status = 'approved'
|
||||
""",
|
||||
(club_id,),
|
||||
)
|
||||
|
||||
# Delete (CASCADE handles divisions and groups)
|
||||
cur.execute("DELETE FROM clubs WHERE id = %s", (club_id,))
|
||||
conn.commit()
|
||||
|
|
|
|||
|
|
@ -37,6 +37,14 @@ from media_rights import assert_rights_for_exercise_link, validate_rights_declar
|
|||
from media_legal_hold import assert_not_under_legal_hold
|
||||
from ai_prompt_context import ExerciseFormAiFocusRow, ExerciseFormAiPromptContext
|
||||
from ai_prompt_job import run_exercise_form_ai_suggestion
|
||||
from account_lifecycle import assert_min_account_state
|
||||
from capabilities import probe_capability
|
||||
from club_features import (
|
||||
consume_club_feature_with_usage,
|
||||
merge_feature_usage_into_response,
|
||||
probe_club_feature_access,
|
||||
resolve_club_id_for_probe,
|
||||
)
|
||||
|
||||
from exercise_rich_text import (
|
||||
RICH_HTML_EXERCISE_FIELDS,
|
||||
|
|
@ -377,6 +385,10 @@ class ExerciseAiSuggestBody(BaseModel):
|
|||
include_summary: bool = True
|
||||
include_skills: bool = True
|
||||
include_instructions: bool = False
|
||||
planning_context: Optional[dict] = Field(
|
||||
default=None,
|
||||
description="Optionaler Planungskontext (Einheit, Pfad, Roadmap-Stufe) für KI-Neuanlage",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_include_any(self):
|
||||
|
|
@ -395,6 +407,7 @@ class ExerciseAiSuggestBody(BaseModel):
|
|||
trainer_notes=self.trainer_notes,
|
||||
focus_area_hint=self.focus_area_hint,
|
||||
focus_areas_context=self.focus_areas_context,
|
||||
planning_context=self.planning_context,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -2317,7 +2330,24 @@ def exercise_ai_suggest_endpoint(
|
|||
KI-Vorschlaege (Kurzfassung und/oder Skill-Zuordnung) ohne Speichern.
|
||||
OPENROUTER_API_KEY erforderlich.
|
||||
"""
|
||||
_ = tenant.profile_id
|
||||
assert_min_account_state(tenant, "active_member", endpoint="POST /exercises/ai/suggest")
|
||||
club_id = resolve_club_id_for_probe(tenant)
|
||||
probe_capability(
|
||||
tenant,
|
||||
"exercises.ai.suggest",
|
||||
action="suggest",
|
||||
club_id=club_id,
|
||||
endpoint="POST /exercises/ai/suggest",
|
||||
)
|
||||
probe_club_feature_access(
|
||||
feature_id="ai_calls",
|
||||
action="suggest",
|
||||
club_id=club_id,
|
||||
profile_id=tenant.profile_id,
|
||||
portal_role=tenant.global_role,
|
||||
endpoint="POST /exercises/ai/suggest",
|
||||
tenant=tenant,
|
||||
)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
payload = run_exercise_form_ai_suggestion(
|
||||
|
|
@ -2327,6 +2357,17 @@ def exercise_ai_suggest_endpoint(
|
|||
want_skills=body.include_skills,
|
||||
want_instructions=body.include_instructions,
|
||||
)
|
||||
usage = consume_club_feature_with_usage(
|
||||
feature_id="ai_calls",
|
||||
club_id=club_id,
|
||||
profile_id=tenant.profile_id,
|
||||
portal_role=tenant.global_role,
|
||||
action="suggest",
|
||||
cur=cur,
|
||||
tenant=tenant,
|
||||
conn=conn,
|
||||
)
|
||||
payload = merge_feature_usage_into_response(payload, usage)
|
||||
return payload
|
||||
|
||||
|
||||
|
|
@ -2337,6 +2378,24 @@ def exercise_ai_regenerate_endpoint(
|
|||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
"""Neu-Anfrage KI fuer eine gespeicherte Uebung; schreibendes Ergebnis nur im Frontend (PUT)."""
|
||||
assert_min_account_state(tenant, "active_member", endpoint="POST /exercises/{id}/ai/regenerate")
|
||||
club_id = resolve_club_id_for_probe(tenant)
|
||||
probe_capability(
|
||||
tenant,
|
||||
"exercises.ai.regenerate",
|
||||
action="regenerate",
|
||||
club_id=club_id,
|
||||
endpoint="POST /exercises/{id}/ai/regenerate",
|
||||
)
|
||||
probe_club_feature_access(
|
||||
feature_id="ai_calls",
|
||||
action="regenerate",
|
||||
club_id=club_id,
|
||||
profile_id=tenant.profile_id,
|
||||
portal_role=tenant.global_role,
|
||||
endpoint="POST /exercises/{id}/ai/regenerate",
|
||||
tenant=tenant,
|
||||
)
|
||||
want_summary = "summary" in body.regenerate
|
||||
want_skills = "skills" in body.regenerate
|
||||
want_instructions = "instructions" in body.regenerate
|
||||
|
|
@ -2368,6 +2427,17 @@ def exercise_ai_regenerate_endpoint(
|
|||
want_skills=want_skills,
|
||||
want_instructions=want_instructions,
|
||||
)
|
||||
usage = consume_club_feature_with_usage(
|
||||
feature_id="ai_calls",
|
||||
club_id=club_id,
|
||||
profile_id=tenant.profile_id,
|
||||
portal_role=tenant.global_role,
|
||||
action="regenerate",
|
||||
cur=cur,
|
||||
tenant=tenant,
|
||||
conn=conn,
|
||||
)
|
||||
payload = merge_feature_usage_into_response(payload, usage)
|
||||
return payload
|
||||
|
||||
|
||||
|
|
@ -2421,6 +2491,25 @@ def create_exercise(
|
|||
if body.visibility == "club" and club_id is None:
|
||||
club_id = tenant.effective_club_id
|
||||
|
||||
if club_id is not None:
|
||||
assert_min_account_state(tenant, "active_member", endpoint="POST /exercises")
|
||||
probe_capability(
|
||||
tenant,
|
||||
"exercises.create",
|
||||
action="create",
|
||||
club_id=int(club_id),
|
||||
endpoint="POST /exercises",
|
||||
)
|
||||
probe_club_feature_access(
|
||||
feature_id="exercises",
|
||||
action="create",
|
||||
club_id=int(club_id),
|
||||
profile_id=profile_id,
|
||||
portal_role=tenant.global_role,
|
||||
endpoint="POST /exercises",
|
||||
tenant=tenant,
|
||||
)
|
||||
|
||||
# §11 Inline-Medien: Kurzsyntax → kanonisches Markup; Verweise erst nach Medien-Anlage möglich
|
||||
create_ids: set[int] = set()
|
||||
for fld in sorted(RICH_HTML_EXERCISE_FIELDS):
|
||||
|
|
@ -3214,6 +3303,34 @@ async def upload_exercise_media(
|
|||
cur = get_cursor(conn)
|
||||
_assert_can_edit_exercise(cur, exercise_id, tenant)
|
||||
|
||||
if has_file:
|
||||
cur.execute(
|
||||
"SELECT club_id FROM exercises WHERE id = %s",
|
||||
(exercise_id,),
|
||||
)
|
||||
ex_club = cur.fetchone()
|
||||
media_club_id = ex_club.get("club_id") if ex_club else None
|
||||
if media_club_id is not None:
|
||||
assert_min_account_state(tenant, "active_member", endpoint="POST /exercises/{id}/media")
|
||||
probe_capability(
|
||||
tenant,
|
||||
"exercises.media.upload",
|
||||
action="upload",
|
||||
club_id=int(media_club_id),
|
||||
endpoint="POST /exercises/{id}/media",
|
||||
conn=conn,
|
||||
)
|
||||
probe_club_feature_access(
|
||||
feature_id="exercise_media",
|
||||
action="upload",
|
||||
club_id=int(media_club_id),
|
||||
profile_id=profile_id,
|
||||
portal_role=tenant.global_role,
|
||||
endpoint="POST /exercises/{id}/media",
|
||||
tenant=tenant,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
if _count_exercise_media(cur, exercise_id) >= MAX_EXERCISE_MEDIA:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
|
|
|
|||
27
backend/routers/me_entitlements.py
Normal file
27
backend/routers/me_entitlements.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""
|
||||
GET /api/me/entitlements — effektive Capabilities + Feature-Kontingente (M4).
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from db import get_db, get_cursor
|
||||
from entitlements import build_me_entitlements
|
||||
from tenant_context import TenantContext, get_tenant_context
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["entitlements"])
|
||||
|
||||
|
||||
@router.get("/me/entitlements")
|
||||
def get_me_entitlements(
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
club_id: Optional[int] = Query(default=None, ge=1, description="Verein (Default: effective_club_id)"),
|
||||
):
|
||||
"""
|
||||
Effektive Rechte für Frontend: Account-Status, Capabilities, Feature-Limits.
|
||||
|
||||
Spez: CAPABILITY_CATALOG.v1.md §7.1
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
return build_me_entitlements(cur, tenant, club_id=club_id)
|
||||
|
|
@ -7,6 +7,14 @@ from db import get_db, get_cursor
|
|||
from tenant_context import TenantContext, get_tenant_context
|
||||
from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_planning_exercises
|
||||
from planning_exercise_path_builder import ProgressionPathSuggestRequest, suggest_progression_path
|
||||
from account_lifecycle import assert_min_account_state
|
||||
from capabilities import probe_capability
|
||||
from club_features import (
|
||||
consume_club_feature_with_usage,
|
||||
merge_feature_usage_into_response,
|
||||
probe_club_feature_access,
|
||||
resolve_club_id_for_probe,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/planning", tags=["planning_exercise_suggest"])
|
||||
|
||||
|
|
@ -16,9 +24,42 @@ def post_planning_exercise_suggest(
|
|||
body: PlanningExerciseSuggestRequest,
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
uses_ai = body.include_llm_intent or body.include_llm_rank
|
||||
club_id = resolve_club_id_for_probe(tenant) if uses_ai else None
|
||||
if uses_ai:
|
||||
assert_min_account_state(tenant, "active_member", endpoint="POST /planning/exercise-suggest")
|
||||
probe_capability(
|
||||
tenant,
|
||||
"planning.ai.suggest",
|
||||
action="planning_suggest",
|
||||
club_id=club_id,
|
||||
endpoint="POST /planning/exercise-suggest",
|
||||
)
|
||||
probe_club_feature_access(
|
||||
feature_id="ai_calls",
|
||||
action="planning_suggest",
|
||||
club_id=club_id,
|
||||
profile_id=tenant.profile_id,
|
||||
portal_role=tenant.global_role,
|
||||
endpoint="POST /planning/exercise-suggest",
|
||||
tenant=tenant,
|
||||
)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
return suggest_planning_exercises(cur, tenant=tenant, body=body)
|
||||
result = suggest_planning_exercises(cur, tenant=tenant, body=body)
|
||||
if uses_ai:
|
||||
usage = consume_club_feature_with_usage(
|
||||
feature_id="ai_calls",
|
||||
club_id=club_id,
|
||||
profile_id=tenant.profile_id,
|
||||
portal_role=tenant.global_role,
|
||||
action="planning_suggest",
|
||||
cur=cur,
|
||||
tenant=tenant,
|
||||
conn=conn,
|
||||
)
|
||||
result = merge_feature_usage_into_response(result, usage)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/progression-path-suggest")
|
||||
|
|
@ -26,6 +67,48 @@ def post_progression_path_suggest(
|
|||
body: ProgressionPathSuggestRequest,
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
uses_ai = (
|
||||
body.include_llm_intent
|
||||
or body.include_llm_path_qa
|
||||
or body.include_ai_gap_fill
|
||||
or body.include_llm_roadmap
|
||||
or body.include_llm_start_target
|
||||
or (body.start_target_only and body.include_llm_start_target)
|
||||
)
|
||||
club_id = resolve_club_id_for_probe(tenant) if uses_ai else None
|
||||
if uses_ai:
|
||||
assert_min_account_state(
|
||||
tenant, "active_member", endpoint="POST /planning/progression-path-suggest"
|
||||
)
|
||||
probe_capability(
|
||||
tenant,
|
||||
"planning.ai.progression_path",
|
||||
action="progression_path_suggest",
|
||||
club_id=club_id,
|
||||
endpoint="POST /planning/progression-path-suggest",
|
||||
)
|
||||
probe_club_feature_access(
|
||||
feature_id="ai_calls",
|
||||
action="progression_path_suggest",
|
||||
club_id=club_id,
|
||||
profile_id=tenant.profile_id,
|
||||
portal_role=tenant.global_role,
|
||||
endpoint="POST /planning/progression-path-suggest",
|
||||
tenant=tenant,
|
||||
)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
return suggest_progression_path(cur, tenant=tenant, body=body)
|
||||
result = suggest_progression_path(cur, tenant=tenant, body=body)
|
||||
if uses_ai:
|
||||
usage = consume_club_feature_with_usage(
|
||||
feature_id="ai_calls",
|
||||
club_id=club_id,
|
||||
profile_id=tenant.profile_id,
|
||||
portal_role=tenant.global_role,
|
||||
action="progression_path_suggest",
|
||||
cur=cur,
|
||||
tenant=tenant,
|
||||
conn=conn,
|
||||
)
|
||||
result = merge_feature_usage_into_response(result, usage)
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from club_tenancy import (
|
|||
is_superadmin,
|
||||
memberships_with_roles,
|
||||
)
|
||||
from capabilities import club_roles_in_club
|
||||
from tenant_context import resolve_tenant_context, TenantContext, get_tenant_context
|
||||
from models import ProfileCreate, ProfileUpdate
|
||||
|
||||
|
|
@ -118,6 +119,11 @@ def get_current_profile(
|
|||
invalid_header_policy="ignore",
|
||||
)
|
||||
data["effective_club_id"] = tenant.effective_club_id
|
||||
data["account_state"] = tenant.account_state
|
||||
if tenant.effective_club_id is not None:
|
||||
data["club_roles"] = club_roles_in_club(tenant, tenant.effective_club_id)
|
||||
else:
|
||||
data["club_roles"] = []
|
||||
return data
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ EXEMPT_ROUTERS: frozenset[str] = frozenset(
|
|||
"ai_prompts_admin.py", # Superadmin ai_prompts; require_auth + is_superadmin — kein Vereinsmandant
|
||||
"exercise_enrichment_admin.py", # Superadmin Batch-Übungs-Anreicherung KI; require_auth + is_superadmin — kein Vereinsmandant
|
||||
"admin_user_content.py", # Superadmin Moderation nutzerangelegter Inhalte; require_auth + is_superadmin — kein Vereinsmandant
|
||||
"admin_rights.py", # Superadmin Rollen/Rechte (Capabilities, Kontingent-Bypass, Pläne); require_auth + is_superadmin — kein Vereinsmandant
|
||||
"catalogs.py",
|
||||
"skills.py",
|
||||
"maturity_models.py",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional
|
|||
from fastapi import Depends, Header, HTTPException
|
||||
|
||||
from auth import require_auth, require_auth_flexible
|
||||
from account_lifecycle import resolve_account_state
|
||||
from club_tenancy import is_platform_admin, memberships_with_roles
|
||||
from db import get_db, get_cursor
|
||||
|
||||
|
|
@ -142,6 +143,8 @@ class TenantContext:
|
|||
effective_club_id: Optional[int]
|
||||
club_ids: frozenset[int]
|
||||
memberships: List[Dict[str, Any]]
|
||||
email_verified: bool = True
|
||||
account_state: str = "active_member"
|
||||
|
||||
|
||||
def resolve_tenant_context(
|
||||
|
|
@ -153,6 +156,7 @@ def resolve_tenant_context(
|
|||
memberships: Optional[List[Dict[str, Any]]] = None,
|
||||
stored_active_club_id: Optional[int] = None,
|
||||
invalid_header_policy: str = "reject",
|
||||
email_verified: Optional[bool] = None,
|
||||
) -> TenantContext:
|
||||
"""
|
||||
Mitgliedschaften: wenn nicht übergeben, lädt ``active_only=True`` aus der DB.
|
||||
|
|
@ -176,6 +180,21 @@ def resolve_tenant_context(
|
|||
|
||||
club_ids = frozenset(int(r["id"]) for r in membership_rows if r.get("id") is not None)
|
||||
|
||||
if email_verified is None:
|
||||
cur.execute(
|
||||
"SELECT COALESCE(email_verified, false) AS email_verified FROM profiles WHERE id = %s",
|
||||
(profile_id,),
|
||||
)
|
||||
prof_row = cur.fetchone()
|
||||
email_verified = bool(prof_row.get("email_verified")) if prof_row else False
|
||||
else:
|
||||
email_verified = bool(email_verified)
|
||||
account_state = resolve_account_state(
|
||||
email_verified=email_verified,
|
||||
global_role=role_lc,
|
||||
has_active_membership=len(club_ids) > 0,
|
||||
)
|
||||
|
||||
if is_platform_admin(role_lc):
|
||||
if header_cid is not None:
|
||||
if not _club_exists(cur, header_cid):
|
||||
|
|
@ -194,6 +213,8 @@ def resolve_tenant_context(
|
|||
effective_club_id=effective,
|
||||
club_ids=club_ids,
|
||||
memberships=membership_rows,
|
||||
email_verified=email_verified,
|
||||
account_state=account_state,
|
||||
)
|
||||
|
||||
chosen_header = header_cid
|
||||
|
|
@ -222,6 +243,8 @@ def resolve_tenant_context(
|
|||
effective_club_id=effective,
|
||||
club_ids=club_ids,
|
||||
memberships=membership_rows,
|
||||
email_verified=email_verified,
|
||||
account_state=account_state,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ def test_resolve_platform_admin_uses_stored_club_without_header(monkeypatch):
|
|||
header_raw=None,
|
||||
memberships=[{"id": 10}],
|
||||
stored_active_club_id=99,
|
||||
email_verified=True,
|
||||
)
|
||||
assert ctx.effective_club_id == 99
|
||||
|
||||
|
|
@ -110,6 +111,7 @@ def test_resolve_platform_admin_header_overrides_stored(monkeypatch):
|
|||
header_raw="5",
|
||||
memberships=[{"id": 10}],
|
||||
stored_active_club_id=99,
|
||||
email_verified=True,
|
||||
)
|
||||
assert ctx.effective_club_id == 5
|
||||
|
||||
|
|
@ -124,6 +126,7 @@ def test_resolve_platform_admin_no_header_stored_invalid(monkeypatch):
|
|||
header_raw=None,
|
||||
memberships=[{"id": 1}],
|
||||
stored_active_club_id=123,
|
||||
email_verified=True,
|
||||
)
|
||||
assert ctx.effective_club_id is None
|
||||
|
||||
|
|
@ -142,6 +145,7 @@ def test_resolve_trainer_club_ids_excludes_inactive_memberships():
|
|||
],
|
||||
stored_active_club_id=None,
|
||||
invalid_header_policy="ignore",
|
||||
email_verified=True,
|
||||
)
|
||||
assert ctx.club_ids == frozenset({20})
|
||||
assert ctx.effective_club_id == 20
|
||||
|
|
@ -157,6 +161,7 @@ def test_resolve_all_memberships_inactive_no_effective_club():
|
|||
memberships=[{"id": 10, "membership_status": "inactive"}],
|
||||
stored_active_club_id=10,
|
||||
invalid_header_policy="ignore",
|
||||
email_verified=True,
|
||||
)
|
||||
assert ctx.club_ids == frozenset()
|
||||
assert ctx.effective_club_id is None
|
||||
|
|
|
|||
86
backend/tests/test_account_capabilities.py
Normal file
86
backend/tests/test_account_capabilities.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""Unit-Tests für Account-Lifecycle und Capability-Helfer (ohne DB)."""
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from account_lifecycle import (
|
||||
account_state_satisfies,
|
||||
assert_min_account_state,
|
||||
resolve_account_state,
|
||||
)
|
||||
from capabilities import club_roles_in_club
|
||||
from tenant_context import TenantContext
|
||||
|
||||
|
||||
def test_resolve_account_state_platform_admin():
|
||||
assert (
|
||||
resolve_account_state(email_verified=False, global_role="superadmin", has_active_membership=False)
|
||||
== "platform_admin"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_account_state_unverified():
|
||||
assert (
|
||||
resolve_account_state(email_verified=False, global_role="trainer", has_active_membership=True)
|
||||
== "unverified"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_account_state_pending_club():
|
||||
assert (
|
||||
resolve_account_state(email_verified=True, global_role="user", has_active_membership=False)
|
||||
== "verified_pending_club"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_account_state_active_member():
|
||||
assert (
|
||||
resolve_account_state(email_verified=True, global_role="trainer", has_active_membership=True)
|
||||
== "active_member"
|
||||
)
|
||||
|
||||
|
||||
def test_account_state_satisfies():
|
||||
assert account_state_satisfies("active_member", "active_member")
|
||||
assert account_state_satisfies("active_member", "verified_pending_club")
|
||||
assert not account_state_satisfies("verified_pending_club", "active_member")
|
||||
assert account_state_satisfies("platform_admin", "active_member")
|
||||
|
||||
|
||||
def test_assert_min_account_state_blocks(monkeypatch):
|
||||
monkeypatch.setenv("ACCOUNT_GATE_ENFORCE", "1")
|
||||
tenant = TenantContext(
|
||||
profile_id=1,
|
||||
global_role="user",
|
||||
effective_club_id=None,
|
||||
club_ids=frozenset(),
|
||||
memberships=[],
|
||||
account_state="verified_pending_club",
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
assert_min_account_state(tenant, "active_member")
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_assert_min_account_state_off(monkeypatch):
|
||||
monkeypatch.setenv("ACCOUNT_GATE_ENFORCE", "0")
|
||||
tenant = TenantContext(
|
||||
profile_id=1,
|
||||
global_role="user",
|
||||
effective_club_id=None,
|
||||
club_ids=frozenset(),
|
||||
memberships=[],
|
||||
account_state="verified_pending_club",
|
||||
)
|
||||
assert_min_account_state(tenant, "active_member")
|
||||
|
||||
|
||||
def test_club_roles_in_club():
|
||||
tenant = TenantContext(
|
||||
profile_id=1,
|
||||
global_role="trainer",
|
||||
effective_club_id=5,
|
||||
club_ids=frozenset({5}),
|
||||
memberships=[{"id": 5, "roles": ["trainer", "club_admin"]}],
|
||||
)
|
||||
assert club_roles_in_club(tenant, 5) == ["trainer", "club_admin"]
|
||||
assert club_roles_in_club(tenant, 99) == []
|
||||
68
backend/tests/test_account_onboarding_gate.py
Normal file
68
backend/tests/test_account_onboarding_gate.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""Tests für account_onboarding_gate."""
|
||||
import pytest
|
||||
|
||||
from account_onboarding_gate import (
|
||||
check_api_onboarding_gate,
|
||||
is_public_api_path,
|
||||
normalize_api_path,
|
||||
)
|
||||
|
||||
|
||||
def test_public_directory_is_public():
|
||||
assert is_public_api_path("/api/clubs/public-directory")
|
||||
|
||||
|
||||
def test_exercises_blocked_for_pending():
|
||||
allowed, reason = check_api_onboarding_gate(
|
||||
path="/api/exercises",
|
||||
method="GET",
|
||||
profile_id=1,
|
||||
account_state="verified_pending_club",
|
||||
)
|
||||
assert not allowed
|
||||
assert reason == "account_state_verified_pending_club"
|
||||
|
||||
|
||||
def test_join_request_allowed_for_pending():
|
||||
allowed, _ = check_api_onboarding_gate(
|
||||
path="/api/me/club-join-requests",
|
||||
method="POST",
|
||||
profile_id=1,
|
||||
account_state="verified_pending_club",
|
||||
)
|
||||
assert allowed
|
||||
|
||||
|
||||
def test_creation_request_allowed_for_pending():
|
||||
allowed, _ = check_api_onboarding_gate(
|
||||
path="/api/me/club-creation-requests",
|
||||
method="POST",
|
||||
profile_id=1,
|
||||
account_state="verified_pending_club",
|
||||
)
|
||||
assert allowed
|
||||
|
||||
|
||||
def test_active_member_domain_ok():
|
||||
allowed, reason = check_api_onboarding_gate(
|
||||
path="/api/exercises",
|
||||
method="GET",
|
||||
profile_id=1,
|
||||
account_state="active_member",
|
||||
)
|
||||
assert allowed
|
||||
assert reason is None
|
||||
|
||||
|
||||
def test_profile_self_update_allowed_unverified():
|
||||
allowed, _ = check_api_onboarding_gate(
|
||||
path="/api/profiles/42",
|
||||
method="PUT",
|
||||
profile_id=42,
|
||||
account_state="unverified",
|
||||
)
|
||||
assert allowed
|
||||
|
||||
|
||||
def test_normalize_trailing_slash():
|
||||
assert normalize_api_path("/api/exercises/") == "/api/exercises"
|
||||
21
backend/tests/test_admin_rights.py
Normal file
21
backend/tests/test_admin_rights.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""M6: Admin-Rollen/Rechte-API — Zugriffskontrolle."""
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from routers.admin_rights import get_capability_matrix, _require_superadmin
|
||||
|
||||
|
||||
def test_require_superadmin_denies_admin():
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_require_superadmin({"role": "admin"})
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_require_superadmin_allows():
|
||||
_require_superadmin({"role": "superadmin"})
|
||||
|
||||
|
||||
def test_get_capability_matrix_requires_superadmin():
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
get_capability_matrix(session={"role": "trainer"})
|
||||
assert exc.value.status_code == 403
|
||||
138
backend/tests/test_club_feature_exemptions.py
Normal file
138
backend/tests/test_club_feature_exemptions.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
"""Kontingent-Bypass über Capability-Grants (kein Env-Hardcoding)."""
|
||||
import pytest
|
||||
|
||||
from club_quota_bypass import (
|
||||
QUOTA_BYPASS_ALL,
|
||||
is_club_feature_quota_bypassed,
|
||||
quota_bypass_access,
|
||||
)
|
||||
from club_features import consume_club_feature, probe_club_feature_access
|
||||
|
||||
|
||||
class _FakeCur:
|
||||
def __init__(self, *, portal_grants=None, profile_grants=None):
|
||||
self._portal_grants = set(portal_grants or ())
|
||||
self._profile_grants = set(profile_grants or ())
|
||||
self._last_sql = ""
|
||||
self._last_params = ()
|
||||
|
||||
def execute(self, sql, params=None):
|
||||
self._last_sql = (sql or "").lower()
|
||||
self._last_params = params or ()
|
||||
|
||||
def fetchone(self):
|
||||
if "portal_role_capability_grants" in self._last_sql:
|
||||
role, cap = self._last_params[:2]
|
||||
if (role, cap) in self._portal_grants:
|
||||
return {"1": 1}
|
||||
if "profile_capability_grants" in self._last_sql:
|
||||
pid, cap = self._last_params[:2]
|
||||
if (int(pid), cap) in self._profile_grants:
|
||||
return {"1": 1}
|
||||
return None
|
||||
|
||||
def fetchall(self):
|
||||
return []
|
||||
|
||||
|
||||
def test_portal_role_grant_bypasses():
|
||||
cur = _FakeCur(portal_grants={("superadmin", QUOTA_BYPASS_ALL)})
|
||||
assert is_club_feature_quota_bypassed(
|
||||
cur, profile_id=1, portal_role="superadmin", feature_id="ai_calls"
|
||||
)
|
||||
assert not is_club_feature_quota_bypassed(
|
||||
cur, profile_id=1, portal_role="trainer", feature_id="ai_calls"
|
||||
)
|
||||
|
||||
|
||||
def test_profile_grant_bypasses():
|
||||
cap = "platform.club_quota.bypass.ai_calls"
|
||||
cur = _FakeCur(profile_grants={(42, cap)})
|
||||
assert is_club_feature_quota_bypassed(
|
||||
cur, profile_id=42, portal_role="trainer", feature_id="ai_calls"
|
||||
)
|
||||
|
||||
|
||||
def test_probe_superadmin_bypasses_enforce(monkeypatch):
|
||||
monkeypatch.setenv("CLUB_FEATURE_ENFORCE", "1")
|
||||
monkeypatch.setattr("club_feature_logger.log_club_feature_usage", lambda **kwargs: None)
|
||||
|
||||
class Cur:
|
||||
def execute(self, *a, **k):
|
||||
pass
|
||||
|
||||
def fetchone(self):
|
||||
return {"1": 1}
|
||||
|
||||
def fetchall(self):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("club_features.get_cursor", lambda c: Cur())
|
||||
monkeypatch.setattr(
|
||||
"club_features.get_effective_club_plan",
|
||||
lambda cur, club_id: "free",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"club_quota_bypass._portal_role_has_grant",
|
||||
lambda cur, role, cap: role == "superadmin",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"club_quota_bypass._profile_has_grant",
|
||||
lambda cur, pid, cap: False,
|
||||
)
|
||||
|
||||
access = probe_club_feature_access(
|
||||
feature_id="ai_calls",
|
||||
action="suggest",
|
||||
club_id=5,
|
||||
profile_id=1,
|
||||
portal_role="superadmin",
|
||||
conn=object(),
|
||||
)
|
||||
assert access["allowed"] is True
|
||||
assert access["reason"] == "capability_quota_bypass"
|
||||
|
||||
|
||||
def test_consume_skips_for_bypass_grant(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
"club_features.increment_club_feature_usage",
|
||||
lambda *a, **k: calls.append(1),
|
||||
)
|
||||
|
||||
class Cur:
|
||||
def execute(self, *a, **k):
|
||||
pass
|
||||
|
||||
def fetchone(self):
|
||||
return {"1": 1}
|
||||
|
||||
def fetchall(self):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("club_features.get_cursor", lambda c: Cur())
|
||||
monkeypatch.setattr(
|
||||
"club_quota_bypass._portal_role_has_grant",
|
||||
lambda cur, role, cap: role == "superadmin",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"club_quota_bypass._profile_has_grant",
|
||||
lambda cur, pid, cap: False,
|
||||
)
|
||||
|
||||
consume_club_feature(
|
||||
feature_id="ai_calls",
|
||||
club_id=9,
|
||||
profile_id=1,
|
||||
portal_role="superadmin",
|
||||
conn=object(),
|
||||
)
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_quota_bypass_access_shape():
|
||||
row = quota_bypass_access(feature_id="ai_calls", club_id=3, plan_id="free")
|
||||
assert row["platform_exempt"] is True
|
||||
assert row["limit"] is None
|
||||
assert row["allowed"] is True
|
||||
assert row["reason"] == "capability_quota_bypass"
|
||||
65
backend/tests/test_club_feature_logger.py
Normal file
65
backend/tests/test_club_feature_logger.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"""Tests für club_feature_logger und probe (ohne DB)."""
|
||||
import json
|
||||
|
||||
from club_feature_logger import feature_usage_logger, log_club_feature_usage
|
||||
from club_features import club_feature_enforcement_enabled, probe_club_feature_access
|
||||
|
||||
|
||||
def test_log_club_feature_usage_json(monkeypatch):
|
||||
captured = []
|
||||
monkeypatch.setattr(feature_usage_logger, "info", lambda msg: captured.append(msg))
|
||||
access = {
|
||||
"allowed": False,
|
||||
"limit": 0,
|
||||
"used": 3,
|
||||
"remaining": 0,
|
||||
"reason": "feature_disabled",
|
||||
"plan_id": "free",
|
||||
}
|
||||
log_club_feature_usage(
|
||||
club_id=12,
|
||||
profile_id=7,
|
||||
feature_id="ai_calls",
|
||||
action="suggest",
|
||||
access=access,
|
||||
endpoint="POST /exercises/ai/suggest",
|
||||
)
|
||||
assert captured
|
||||
payload = json.loads(captured[-1])
|
||||
assert payload["club_id"] == 12
|
||||
assert payload["profile_id"] == 7
|
||||
assert payload["feature"] == "ai_calls"
|
||||
assert payload["allowed"] is False
|
||||
assert payload["plan_id"] == "free"
|
||||
assert payload["phase"] == "probe"
|
||||
|
||||
|
||||
def test_probe_no_club_context_logs_without_db(monkeypatch):
|
||||
# CI/Deploy kann CLUB_FEATURE_ENFORCE=1 setzen — Test prüft Probe-Modus (kein Hard-Block).
|
||||
monkeypatch.delenv("CLUB_FEATURE_ENFORCE", raising=False)
|
||||
|
||||
logged = []
|
||||
|
||||
def _capture(**kwargs):
|
||||
logged.append(kwargs)
|
||||
|
||||
monkeypatch.setattr("club_feature_logger.log_club_feature_usage", _capture)
|
||||
|
||||
access = probe_club_feature_access(
|
||||
feature_id="ai_calls",
|
||||
action="suggest",
|
||||
club_id=None,
|
||||
profile_id=1,
|
||||
endpoint="test",
|
||||
)
|
||||
assert access["reason"] == "no_club_context"
|
||||
assert access["allowed"] is True
|
||||
assert len(logged) == 1
|
||||
assert logged[0]["club_id"] is None
|
||||
|
||||
|
||||
def test_club_feature_enforcement_default_off(monkeypatch):
|
||||
monkeypatch.delenv("CLUB_FEATURE_ENFORCE", raising=False)
|
||||
assert club_feature_enforcement_enabled() is False
|
||||
monkeypatch.setenv("CLUB_FEATURE_ENFORCE", "1")
|
||||
assert club_feature_enforcement_enabled() is True
|
||||
225
backend/tests/test_club_feature_m5.py
Normal file
225
backend/tests/test_club_feature_m5.py
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
"""M5: ai_calls Verbrauch + Hard-Block (CLUB_FEATURE_ENFORCE)."""
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from club_features import (
|
||||
club_feature_enforcement_enabled,
|
||||
consume_club_feature,
|
||||
consume_club_feature_with_usage,
|
||||
merge_feature_usage_into_response,
|
||||
probe_club_feature_access,
|
||||
)
|
||||
|
||||
|
||||
def _fake_cur():
|
||||
class C:
|
||||
def execute(self, *a, **k):
|
||||
pass
|
||||
|
||||
def fetchone(self):
|
||||
return None
|
||||
|
||||
return C()
|
||||
|
||||
|
||||
def test_probe_blocks_when_enforce_and_limit_exceeded(monkeypatch):
|
||||
monkeypatch.setenv("CLUB_FEATURE_ENFORCE", "1")
|
||||
monkeypatch.setattr("club_features.get_cursor", lambda conn: _fake_cur())
|
||||
monkeypatch.setattr(
|
||||
"club_quota_bypass.is_club_feature_quota_bypassed",
|
||||
lambda *a, **k: False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"club_features.check_club_feature_access",
|
||||
lambda club_id, feature_id, conn=None: {
|
||||
"allowed": False,
|
||||
"limit": 0,
|
||||
"used": 0,
|
||||
"remaining": 0,
|
||||
"reason": "feature_disabled",
|
||||
"plan_id": "free",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr("club_feature_logger.log_club_feature_usage", lambda **kwargs: None)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
probe_club_feature_access(
|
||||
feature_id="ai_calls",
|
||||
action="suggest",
|
||||
club_id=12,
|
||||
profile_id=3,
|
||||
endpoint="POST /exercises/ai/suggest",
|
||||
conn=object(),
|
||||
)
|
||||
assert exc.value.status_code == 403
|
||||
assert "ai_calls" in str(exc.value.detail)
|
||||
|
||||
|
||||
def test_probe_allows_when_enforce_off(monkeypatch):
|
||||
monkeypatch.setenv("CLUB_FEATURE_ENFORCE", "0")
|
||||
monkeypatch.setattr("club_features.get_cursor", lambda conn: _fake_cur())
|
||||
monkeypatch.setattr(
|
||||
"club_quota_bypass.is_club_feature_quota_bypassed",
|
||||
lambda *a, **k: False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"club_features.check_club_feature_access",
|
||||
lambda club_id, feature_id, conn=None: {
|
||||
"allowed": False,
|
||||
"limit": 0,
|
||||
"used": 0,
|
||||
"remaining": 0,
|
||||
"reason": "feature_disabled",
|
||||
"plan_id": "free",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr("club_feature_logger.log_club_feature_usage", lambda **kwargs: None)
|
||||
|
||||
access = probe_club_feature_access(
|
||||
feature_id="ai_calls",
|
||||
action="suggest",
|
||||
club_id=12,
|
||||
profile_id=3,
|
||||
conn=object(),
|
||||
)
|
||||
assert access["allowed"] is False
|
||||
|
||||
|
||||
def test_consume_skips_without_club_id(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def _inc(*args, **kwargs):
|
||||
calls.append(1)
|
||||
|
||||
monkeypatch.setattr("club_features.increment_club_feature_usage", _inc)
|
||||
consume_club_feature(feature_id="ai_calls", club_id=None, profile_id=1)
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_consume_logs_usage_after_increment(monkeypatch):
|
||||
logs = []
|
||||
|
||||
monkeypatch.setattr("club_features.get_cursor", lambda conn: _fake_cur())
|
||||
monkeypatch.setattr(
|
||||
"club_quota_bypass.is_club_feature_quota_bypassed",
|
||||
lambda *a, **k: False,
|
||||
)
|
||||
monkeypatch.setattr("club_features.increment_club_feature_usage", lambda *a, **k: None)
|
||||
monkeypatch.setattr(
|
||||
"club_features.check_club_feature_access",
|
||||
lambda club_id, feature_id, conn=None: {
|
||||
"allowed": True,
|
||||
"used": 1,
|
||||
"limit": 30,
|
||||
"remaining": 29,
|
||||
"plan_id": "club",
|
||||
"reason": "within_limit",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"club_feature_logger.log_club_feature_usage",
|
||||
lambda **kwargs: logs.append(kwargs),
|
||||
)
|
||||
|
||||
consume_club_feature(
|
||||
feature_id="ai_calls",
|
||||
club_id=5,
|
||||
profile_id=9,
|
||||
portal_role="trainer",
|
||||
action="suggest",
|
||||
conn=object(),
|
||||
)
|
||||
assert len(logs) == 1
|
||||
assert logs[0]["phase"] == "consume"
|
||||
assert logs[0]["feature_id"] == "ai_calls"
|
||||
assert logs[0]["club_id"] == 5
|
||||
|
||||
|
||||
def test_consume_increments_once_per_call(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def _inc(club_id, feature_id, **kwargs):
|
||||
calls.append((club_id, feature_id, kwargs.get("action")))
|
||||
|
||||
monkeypatch.setattr("club_features.get_cursor", lambda conn: _fake_cur())
|
||||
monkeypatch.setattr(
|
||||
"club_quota_bypass.is_club_feature_quota_bypassed",
|
||||
lambda *a, **k: False,
|
||||
)
|
||||
monkeypatch.setattr("club_features.increment_club_feature_usage", _inc)
|
||||
monkeypatch.setattr(
|
||||
"club_features.check_club_feature_access",
|
||||
lambda club_id, feature_id, conn=None: {
|
||||
"allowed": True,
|
||||
"used": 1,
|
||||
"limit": 30,
|
||||
"remaining": 29,
|
||||
"plan_id": "club",
|
||||
"reason": "within_limit",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr("club_feature_logger.log_club_feature_usage", lambda **kwargs: None)
|
||||
consume_club_feature(
|
||||
feature_id="ai_calls",
|
||||
club_id=5,
|
||||
profile_id=9,
|
||||
portal_role="trainer",
|
||||
action="suggest",
|
||||
conn=object(),
|
||||
)
|
||||
assert calls == [(5, "ai_calls", "suggest")]
|
||||
|
||||
|
||||
def test_merge_feature_usage_into_response():
|
||||
out = merge_feature_usage_into_response(
|
||||
{"ok": True},
|
||||
{"ai_calls": {"used": 3, "limit": 30}},
|
||||
)
|
||||
assert out["ok"] is True
|
||||
assert out["feature_usage"]["ai_calls"]["used"] == 3
|
||||
assert merge_feature_usage_into_response({"x": 1}, None) == {"x": 1}
|
||||
|
||||
|
||||
def test_consume_with_usage_returns_snapshot(monkeypatch):
|
||||
monkeypatch.setattr("club_features.get_cursor", lambda conn: _fake_cur())
|
||||
monkeypatch.setattr(
|
||||
"club_quota_bypass.is_club_feature_quota_bypassed",
|
||||
lambda *a, **k: False,
|
||||
)
|
||||
monkeypatch.setattr("club_features.consume_club_feature", lambda **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
"club_features.club_feature_usage_for_api",
|
||||
lambda cur, **kwargs: {"used": 4, "limit": 30, "allowed": True},
|
||||
)
|
||||
|
||||
usage = consume_club_feature_with_usage(
|
||||
feature_id="ai_calls",
|
||||
club_id=7,
|
||||
profile_id=1,
|
||||
portal_role="trainer",
|
||||
action="suggest",
|
||||
cur=_fake_cur(),
|
||||
conn=object(),
|
||||
)
|
||||
assert usage["ai_calls"]["used"] == 4
|
||||
|
||||
|
||||
def test_probe_blocks_no_club_context_when_enforce(monkeypatch):
|
||||
monkeypatch.setenv("CLUB_FEATURE_ENFORCE", "1")
|
||||
monkeypatch.setattr("club_feature_logger.log_club_feature_usage", lambda **kwargs: None)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
probe_club_feature_access(
|
||||
feature_id="ai_calls",
|
||||
action="suggest",
|
||||
club_id=None,
|
||||
profile_id=3,
|
||||
endpoint="POST /exercises/ai/suggest",
|
||||
)
|
||||
assert exc.value.status_code == 403
|
||||
assert "Vereinskontext" in str(exc.value.detail)
|
||||
|
||||
|
||||
def test_club_feature_enforcement_env_default_off(monkeypatch):
|
||||
monkeypatch.delenv("CLUB_FEATURE_ENFORCE", raising=False)
|
||||
assert club_feature_enforcement_enabled() is False
|
||||
27
backend/tests/test_club_features.py
Normal file
27
backend/tests/test_club_features.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""Unit-Tests für club_features (ohne DB)."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from club_features import _calculate_next_reset, _normalize_limit
|
||||
|
||||
|
||||
def test_normalize_limit_none_and_negative():
|
||||
assert _normalize_limit(None) is None
|
||||
assert _normalize_limit(-1) is None
|
||||
assert _normalize_limit(0) == 0
|
||||
assert _normalize_limit(50) == 50
|
||||
|
||||
|
||||
def test_calculate_next_reset_never():
|
||||
assert _calculate_next_reset("never") is None
|
||||
|
||||
|
||||
def test_calculate_next_reset_monthly_december():
|
||||
ref = datetime(2026, 12, 15, 12, 0, tzinfo=timezone.utc)
|
||||
nxt = _calculate_next_reset("monthly", now=ref)
|
||||
assert nxt == datetime(2027, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_calculate_next_reset_monthly_mid_year():
|
||||
ref = datetime(2026, 6, 6, 12, 0, tzinfo=timezone.utc)
|
||||
nxt = _calculate_next_reset("monthly", now=ref)
|
||||
assert nxt == datetime(2026, 7, 1, tzinfo=timezone.utc)
|
||||
79
backend/tests/test_entitlements.py
Normal file
79
backend/tests/test_entitlements.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""Tests für GET /me/entitlements Zusammenstellung."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from entitlements import _serialize_reset_at, build_me_entitlements
|
||||
from tenant_context import TenantContext
|
||||
|
||||
|
||||
def test_serialize_reset_at():
|
||||
dt = datetime(2026, 7, 1, tzinfo=timezone.utc)
|
||||
assert _serialize_reset_at(dt) == "2026-07-01T00:00:00+00:00"
|
||||
assert _serialize_reset_at(None) is None
|
||||
|
||||
|
||||
def test_build_me_entitlements_no_club(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"entitlements.resolve_capabilities_map",
|
||||
lambda cur, tenant, club_id=None: {"exercises.read": False},
|
||||
)
|
||||
|
||||
tenant = TenantContext(
|
||||
profile_id=1,
|
||||
global_role="user",
|
||||
effective_club_id=None,
|
||||
club_ids=frozenset(),
|
||||
memberships=[],
|
||||
account_state="verified_pending_club",
|
||||
)
|
||||
out = build_me_entitlements(object(), tenant)
|
||||
assert out["account_state"] == "verified_pending_club"
|
||||
assert out["club_id"] is None
|
||||
assert out["features"] == {}
|
||||
assert out["capabilities"]["exercises.read"] is False
|
||||
|
||||
|
||||
def test_build_me_entitlements_with_club(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"entitlements.resolve_capabilities_map",
|
||||
lambda cur, tenant, club_id=None: {
|
||||
"exercises.read": True,
|
||||
"exercises.ai.suggest": True,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"entitlements.club_features_map",
|
||||
lambda cur, club_id, conn=None: {
|
||||
"plan_id": "free",
|
||||
"club_id": club_id,
|
||||
"features": {
|
||||
"ai_calls": {
|
||||
"allowed": False,
|
||||
"used": 0,
|
||||
"limit": 0,
|
||||
"remaining": 0,
|
||||
"reason": "feature_disabled",
|
||||
"reset_at": None,
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr("entitlements._club_exists", lambda cur, cid: True)
|
||||
monkeypatch.setattr(
|
||||
"entitlements.is_club_feature_quota_bypassed",
|
||||
lambda *a, **k: False,
|
||||
)
|
||||
|
||||
tenant = TenantContext(
|
||||
profile_id=3,
|
||||
global_role="trainer",
|
||||
effective_club_id=1,
|
||||
club_ids=frozenset({1}),
|
||||
memberships=[{"id": 1, "roles": ["trainer"]}],
|
||||
account_state="active_member",
|
||||
)
|
||||
out = build_me_entitlements(object(), tenant, club_id=1)
|
||||
assert out["club_id"] == 1
|
||||
assert out["plan_id"] == "free"
|
||||
assert out["club_roles"] == ["trainer"]
|
||||
assert out["features"]["ai_calls"]["limit"] == 0
|
||||
assert out["capabilities"]["exercises.ai.suggest"] is True
|
||||
80
backend/tests/test_planning_exercise_form_context.py
Normal file
80
backend/tests/test_planning_exercise_form_context.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Tests Planungs-KI Phase D — planning_context für suggestExerciseAi."""
|
||||
from planning_exercise_form_context import (
|
||||
build_progression_gap_snapshot,
|
||||
build_progression_path_gap_planning_context,
|
||||
planning_context_prompt_variables,
|
||||
sanitize_planning_context_for_ai,
|
||||
)
|
||||
|
||||
|
||||
def test_planning_context_prompt_variables_empty():
|
||||
vars_ = planning_context_prompt_variables(None)
|
||||
assert vars_["planning_context_json"] == "-"
|
||||
assert vars_["has_planning_context"] == ""
|
||||
|
||||
|
||||
def test_planning_context_prompt_variables_with_data():
|
||||
vars_ = planning_context_prompt_variables({"source": "test", "goal_query": "Mae Geri"})
|
||||
assert vars_["has_planning_context"] == "true"
|
||||
assert "Mae Geri" in vars_["planning_context_json"]
|
||||
|
||||
|
||||
def test_build_progression_path_gap_context():
|
||||
ctx = build_progression_path_gap_planning_context(
|
||||
goal_query="Mae Geri Perfektion",
|
||||
primary_topic="Mae Geri",
|
||||
progression_graph_id=3,
|
||||
offer={
|
||||
"source": "roadmap_unfilled",
|
||||
"phase": "vertiefung",
|
||||
"title_hint": "Koordination Mae Geri",
|
||||
"roadmap_major_step_index": 2,
|
||||
"from_title": "Schritt A",
|
||||
"to_title": "Schritt B",
|
||||
},
|
||||
neighbor_before={"title": "Schritt A"},
|
||||
neighbor_after={"title": "Schritt B"},
|
||||
path_step_count=4,
|
||||
major_step_count=5,
|
||||
)
|
||||
assert ctx["source"] == "progression_path_gap_fill"
|
||||
assert ctx["roadmap_major_step_index"] == 2
|
||||
assert ctx["neighbor_before_title"] == "Schritt A"
|
||||
|
||||
|
||||
def test_sanitize_truncates_long_strings():
|
||||
ctx = sanitize_planning_context_for_ai({"goal_query": "x" * 900})
|
||||
assert len(ctx["goal_query"]) <= 800
|
||||
|
||||
|
||||
def test_build_progression_gap_snapshot_includes_start_target_and_stage():
|
||||
snap = build_progression_gap_snapshot(
|
||||
goal_analysis={
|
||||
"primary_topic": "Kumite Beinarbeit",
|
||||
"start_assumption": "gleichförmige Steppbewegung",
|
||||
"target_state": "explosiver Angriff mit Ausweichen",
|
||||
"success_criteria": ["nachvollziehbarer Übergang"],
|
||||
},
|
||||
resolved_structured={"roadmap_notes": "Kindergruppe"},
|
||||
stage_spec={
|
||||
"learning_goal": "variable Rhythmen",
|
||||
"load_profile": ["timing", "distanz"],
|
||||
"success_criteria": ["Reaktion unter Druck"],
|
||||
"anti_patterns": ["statisches Stehen"],
|
||||
},
|
||||
semantic_brief={"must_phrases": ["Beinarbeit"], "development_arc": ["grundlage", "anwendung"]},
|
||||
)
|
||||
assert snap["start_situation"] == "gleichförmige Steppbewegung"
|
||||
assert snap["stage_learning_goal"] == "variable Rhythmen"
|
||||
assert "timing" in snap["stage_load_profile"]
|
||||
assert snap["roadmap_notes"] == "Kindergruppe"
|
||||
|
||||
|
||||
def test_gap_planning_context_carries_snapshot_fields():
|
||||
ctx = build_progression_path_gap_planning_context(
|
||||
goal_query="Kumite Beinarbeit",
|
||||
goal_analysis={"start_assumption": "Start", "target_state": "Ziel"},
|
||||
stage_spec={"learning_goal": "Stufenziel", "load_profile": ["koordination"]},
|
||||
)
|
||||
assert ctx["start_situation"] == "Start"
|
||||
assert ctx["stage_learning_goal"] == "Stufenziel"
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
"""Tests Planungs-KI Phase E3 — Lücken-Angebote und Off-Topic."""
|
||||
from planning_exercise_path_ai_fill import collect_gap_fill_specs
|
||||
from planning_exercise_path_qa import parse_llm_suggested_new_exercises
|
||||
from planning_exercise_path_ai_fill import (
|
||||
build_gap_fill_goal_text,
|
||||
build_gap_fill_offer,
|
||||
collect_gap_fill_specs,
|
||||
)
|
||||
from planning_exercise_path_qa import parse_llm_suggested_new_exercises, strip_off_topic_steps_from_path
|
||||
from planning_exercise_semantics import build_semantic_brief
|
||||
|
||||
|
||||
|
|
@ -62,3 +66,71 @@ def test_collect_gap_fill_specs_off_topic_and_unfilled():
|
|||
off = next(s for s in specs if s["source"] == "off_topic")
|
||||
assert off["replace_step_index"] == 2
|
||||
assert off["insert_after_index"] == 1
|
||||
|
||||
|
||||
def test_strip_off_topic_steps_from_path():
|
||||
steps = [
|
||||
{"exercise_id": 1, "title": "A"},
|
||||
{"exercise_id": 2, "title": "B"},
|
||||
{"exercise_id": 3, "title": "One Leg Squat"},
|
||||
{"exercise_id": 4, "title": "D"},
|
||||
]
|
||||
off_topic = [{"step_index": 2, "title": "One Leg Squat", "exercise_id": 3}]
|
||||
out, removed = strip_off_topic_steps_from_path(steps, off_topic)
|
||||
assert len(out) == 3
|
||||
assert len(removed) == 1
|
||||
assert removed[0]["removed_title"] == "One Leg Squat"
|
||||
assert [s["exercise_id"] for s in out] == [1, 2, 4]
|
||||
|
||||
|
||||
def test_build_gap_fill_goal_text_includes_topic():
|
||||
brief = build_semantic_brief("Mae Geri Perfektion")
|
||||
text = build_gap_fill_goal_text(
|
||||
goal_query="Mae Geri Perfektion",
|
||||
brief=brief,
|
||||
spec={"phase": "anwendung", "rationale": "Fehlt Kombinationstraining"},
|
||||
step_a={"title": "Kihon"},
|
||||
step_b={"title": "Kumite"},
|
||||
)
|
||||
assert "Mae Geri" in text or "mae geri" in text.lower()
|
||||
assert "anwendung" in text
|
||||
assert "Kihon" in text
|
||||
|
||||
|
||||
def test_build_gap_fill_goal_text_includes_roadmap_snapshot():
|
||||
brief = build_semantic_brief("Kumite Beinarbeit")
|
||||
text = build_gap_fill_goal_text(
|
||||
goal_query="Kumite Beinarbeit",
|
||||
brief=brief,
|
||||
spec={"phase": "vertiefung", "title_hint": "variable Rhythmen"},
|
||||
step_a={"title": "Schritt A"},
|
||||
step_b={"title": "Schritt B"},
|
||||
roadmap_snapshot={
|
||||
"start_situation": "gleichförmige Steppbewegung",
|
||||
"target_state": "explosiver Angriff",
|
||||
"stage_learning_goal": "variable Rhythmen und multidirektionale Kontrolle",
|
||||
"stage_load_profile": ["timing", "distanz"],
|
||||
"skill_hints": ["Beinarbeit"],
|
||||
},
|
||||
)
|
||||
assert "gleichförmige Steppbewegung" in text
|
||||
assert "explosiver Angriff" in text
|
||||
assert "variable Rhythmen" in text
|
||||
assert "timing" in text
|
||||
|
||||
|
||||
def test_build_gap_fill_offer_exposes_context_preview():
|
||||
brief = build_semantic_brief("Kumite Beinarbeit")
|
||||
offer = build_gap_fill_offer(
|
||||
spec={"source": "roadmap_unfilled", "phase": "vertiefung", "title_hint": "Rhythmen"},
|
||||
steps=[{"title": "A"}, {"title": "B"}],
|
||||
goal_query="Kumite Beinarbeit",
|
||||
brief=brief,
|
||||
roadmap_snapshot={
|
||||
"start_situation": "Steppbewegung",
|
||||
"target_state": "explosiver Angriff",
|
||||
"stage_learning_goal": "variable Rhythmen",
|
||||
},
|
||||
)
|
||||
assert offer["context_preview"]["start_situation"] == "Steppbewegung"
|
||||
assert "variable Rhythmen" in offer["goal_for_ai"]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
"""Tests Planungs-KI Phase C3/E — Pfad-Vorschläge."""
|
||||
from planning_exercise_path_builder import _pick_best_path_hit, _hit_to_path_step
|
||||
"""Tests Planungs-KI Phase C3/E/F — Pfad-Vorschläge."""
|
||||
from planning_exercise_path_builder import (
|
||||
_annotate_roadmap_step,
|
||||
_hit_to_path_step,
|
||||
_pick_best_path_hit,
|
||||
)
|
||||
from planning_progression_roadmap import MajorStep, StageSpecArtifact
|
||||
|
||||
|
||||
def test_pick_next_path_hit_skips_used():
|
||||
|
|
@ -23,3 +28,17 @@ def test_hit_to_path_step_maps_variant():
|
|||
assert step["exercise_id"] == 10
|
||||
assert step["variant_id"] == 7
|
||||
assert step["suggested_variant_name"] == "Leicht"
|
||||
|
||||
|
||||
def test_annotate_roadmap_step_adds_metadata():
|
||||
spec = StageSpecArtifact(major_step_index=1, learning_goal="Grundstellung Mae Geri")
|
||||
major = MajorStep(index=1, phase="grundlage", learning_goal=spec.learning_goal, consolidates=["m1"])
|
||||
step = _annotate_roadmap_step(
|
||||
{"exercise_id": 5, "title": "Test", "reasons": ["Bibliothek"]},
|
||||
stage_spec=spec,
|
||||
major_step=major,
|
||||
)
|
||||
assert step["roadmap_major_step_index"] == 1
|
||||
assert step["roadmap_phase"] == "grundlage"
|
||||
assert step["roadmap_match_source"] == "stage_spec"
|
||||
assert any("Roadmap:" in r for r in step["reasons"])
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
"""Tests Planungs-KI Phase E — Pfad-QA."""
|
||||
"""Tests Planungs-KI Phase E/F — Pfad-QA."""
|
||||
from planning_exercise_path_builder import _pick_best_path_hit
|
||||
from planning_exercise_semantics import build_semantic_brief
|
||||
from planning_exercise_path_qa import apply_llm_path_reorder
|
||||
from planning_exercise_path_qa import (
|
||||
apply_llm_path_reorder,
|
||||
detect_path_gaps,
|
||||
is_roadmap_planned_neighbor_pair,
|
||||
)
|
||||
|
||||
|
||||
def test_pick_best_path_hit_prefers_semantic_score():
|
||||
|
|
@ -62,6 +66,46 @@ def test_apply_llm_path_reorder_permutation():
|
|||
assert notes
|
||||
|
||||
|
||||
def test_is_roadmap_planned_neighbor_pair():
|
||||
a = {"roadmap_match_source": "stage_spec", "roadmap_major_step_index": 1}
|
||||
b = {"roadmap_match_source": "stage_spec", "roadmap_major_step_index": 2}
|
||||
c = {"roadmap_match_source": "stage_spec", "roadmap_major_step_index": 4}
|
||||
assert is_roadmap_planned_neighbor_pair(a, b) is True
|
||||
assert is_roadmap_planned_neighbor_pair(a, c) is False
|
||||
assert is_roadmap_planned_neighbor_pair({"exercise_id": 1}, b) is False
|
||||
|
||||
|
||||
def test_detect_path_gaps_skips_roadmap_neighbors():
|
||||
brief = build_semantic_brief("Mae Geri")
|
||||
steps = [
|
||||
{
|
||||
"exercise_id": 1,
|
||||
"title": "A",
|
||||
"roadmap_match_source": "stage_spec",
|
||||
"roadmap_major_step_index": 0,
|
||||
},
|
||||
{
|
||||
"exercise_id": 2,
|
||||
"title": "B",
|
||||
"roadmap_match_source": "stage_spec",
|
||||
"roadmap_major_step_index": 1,
|
||||
},
|
||||
]
|
||||
|
||||
class _FakeCur:
|
||||
def execute(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def fetchall(self):
|
||||
return []
|
||||
|
||||
def fetchone(self):
|
||||
return {"title": "X", "summary": "", "goal": ""}
|
||||
|
||||
gaps = detect_path_gaps(_FakeCur(), steps, brief=brief, roadmap_first=True)
|
||||
assert gaps == []
|
||||
|
||||
|
||||
def test_apply_llm_path_reorder_invalid_ignored():
|
||||
steps = [{"exercise_id": 1}, {"exercise_id": 2}]
|
||||
reordered, applied, _ = apply_llm_path_reorder(steps, {"ordered_step_indices": [0, 0]})
|
||||
|
|
|
|||
247
backend/tests/test_planning_progression_roadmap.py
Normal file
247
backend/tests/test_planning_progression_roadmap.py
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
"""Tests Planungs-KI Phase F — Progressions-Roadmap Pipeline."""
|
||||
from planning_progression_roadmap import (
|
||||
PROMPT_SLUG_GOAL_ANALYSIS,
|
||||
PROMPT_SLUG_ROADMAP,
|
||||
PROMPT_SLUG_STAGE_SPEC,
|
||||
PROMPT_SLUG_START_TARGET,
|
||||
MajorStep,
|
||||
RoadmapStructuredInput,
|
||||
StageSpecArtifact,
|
||||
build_goal_analysis,
|
||||
build_roadmap_unfilled_gap_specs,
|
||||
consolidate_micro_to_major,
|
||||
develop_micro_objectives,
|
||||
parse_start_target_from_goal_query,
|
||||
progression_roadmap_to_api_dict,
|
||||
resolve_roadmap_structured_input,
|
||||
resolve_step_exercise_kind_filter,
|
||||
run_progression_roadmap_pipeline,
|
||||
run_start_target_resolve_only,
|
||||
stage_spec_exercise_kind_filter,
|
||||
stage_spec_retrieval_query,
|
||||
normalize_major_steps_for_override,
|
||||
roadmap_context_from_override,
|
||||
RoadmapOverridePayload,
|
||||
)
|
||||
from planning_exercise_semantics import build_semantic_brief
|
||||
|
||||
KUMITE_GOAL = (
|
||||
"Kumite Beinarbeit von einer gleichartigen Steppbewegung bis zur dynamischen "
|
||||
"unvorhersehbaren Bewegung mit explosivartigem Angriff und ausweichen"
|
||||
)
|
||||
|
||||
|
||||
def test_run_progression_roadmap_pipeline_major_step_count():
|
||||
ctx = run_progression_roadmap_pipeline(
|
||||
"Von Erlernen bis zur Perfektion des Fußtritts Mae Geri",
|
||||
max_steps=5,
|
||||
)
|
||||
assert ctx.roadmap is not None
|
||||
assert len(ctx.roadmap.major_steps) == 5
|
||||
assert len(ctx.roadmap.micro_objectives) >= 6
|
||||
assert len(ctx.stage_specs) == 5
|
||||
assert ctx.goal_analysis is not None
|
||||
assert "Mae" in ctx.goal_analysis.primary_topic or "mae" in ctx.goal_analysis.primary_topic.lower()
|
||||
|
||||
|
||||
def test_consolidate_micro_to_major_reduces_count():
|
||||
brief = build_semantic_brief("Mae Geri")
|
||||
ga = build_goal_analysis("Mae Geri Perfektion", brief)
|
||||
micro = develop_micro_objectives(brief, goal_analysis=ga, min_count=8)
|
||||
majors, notes = consolidate_micro_to_major(micro, max_steps=5)
|
||||
assert len(majors) == 5
|
||||
if len(micro) > 5:
|
||||
assert notes
|
||||
assert all(m.learning_goal for m in majors)
|
||||
|
||||
|
||||
def test_major_steps_have_learning_goals():
|
||||
ctx = run_progression_roadmap_pipeline("Mae Geri Grundlagen", max_steps=3)
|
||||
for step in ctx.roadmap.major_steps:
|
||||
assert step.learning_goal.strip()
|
||||
assert step.consolidates
|
||||
|
||||
|
||||
def test_stage_spec_retrieval_query_includes_learning_goal():
|
||||
brief = build_semantic_brief("Mae Geri Perfektion")
|
||||
spec = StageSpecArtifact(
|
||||
major_step_index=1,
|
||||
learning_goal="Koordination und Präzision vertiefen",
|
||||
load_profile=["präzision"],
|
||||
exercise_type="kihon_einzel",
|
||||
)
|
||||
major = MajorStep(index=1, phase="vertiefung", learning_goal=spec.learning_goal, consolidates=["m3"])
|
||||
q = stage_spec_retrieval_query(
|
||||
semantic_brief=brief,
|
||||
goal_query="Mae Geri Perfektion",
|
||||
stage_spec=spec,
|
||||
major_step=major,
|
||||
)
|
||||
assert "vertiefung" in q.lower()
|
||||
assert "Koordination" in q or "Präzision" in q
|
||||
|
||||
|
||||
def test_stage_spec_exercise_kind_filter_maps_combination():
|
||||
spec = StageSpecArtifact(major_step_index=0, exercise_type="kombination")
|
||||
assert stage_spec_exercise_kind_filter(spec) == ["combination"]
|
||||
assert resolve_step_exercise_kind_filter(spec, ["simple"]) == ["simple"]
|
||||
|
||||
|
||||
def test_build_roadmap_unfilled_gap_specs():
|
||||
brief = build_semantic_brief("Mae Geri")
|
||||
spec = StageSpecArtifact(major_step_index=2, learning_goal="Anwendung im Partnerdrill")
|
||||
major = MajorStep(index=2, phase="anwendung", learning_goal=spec.learning_goal, consolidates=["m5"])
|
||||
specs = build_roadmap_unfilled_gap_specs(
|
||||
unfilled_specs=[(2, spec)],
|
||||
major_steps_by_index={2: major},
|
||||
steps=[{"exercise_id": 1, "title": "A"}, {"exercise_id": 2, "title": "B"}],
|
||||
brief=brief,
|
||||
goal_query="Mae Geri",
|
||||
)
|
||||
assert len(specs) == 1
|
||||
assert specs[0]["source"] == "roadmap_unfilled"
|
||||
assert specs[0]["phase"] == "anwendung"
|
||||
|
||||
|
||||
def test_normalize_major_steps_reindexes():
|
||||
majors = normalize_major_steps_for_override(
|
||||
[
|
||||
MajorStep(index=9, phase="einstieg", learning_goal="Einstieg", consolidates=[]),
|
||||
MajorStep(index=8, phase="perfektion", learning_goal="Ziel", consolidates=[]),
|
||||
],
|
||||
max_steps=5,
|
||||
)
|
||||
assert len(majors) == 2
|
||||
assert majors[0].index == 0
|
||||
assert majors[1].index == 1
|
||||
|
||||
|
||||
def test_roadmap_context_from_override():
|
||||
brief = build_semantic_brief("Mae Geri Perfektion")
|
||||
override = RoadmapOverridePayload(
|
||||
major_steps=[
|
||||
MajorStep(index=0, phase="einstieg", learning_goal="Mae Geri Einstieg", consolidates=[]),
|
||||
MajorStep(index=1, phase="grundlage", learning_goal="Stand und Hüfte", consolidates=[]),
|
||||
MajorStep(index=2, phase="perfektion", learning_goal="Präzision unter Belastung", consolidates=[]),
|
||||
]
|
||||
)
|
||||
ctx = roadmap_context_from_override(
|
||||
"Mae Geri Perfektion",
|
||||
max_steps=5,
|
||||
semantic_brief=brief,
|
||||
override=override,
|
||||
)
|
||||
assert ctx.pipeline_phase == "roadmap_v1_edited"
|
||||
assert len(ctx.roadmap.major_steps) == 3
|
||||
assert len(ctx.stage_specs) == 3
|
||||
assert ctx.stage_specs[1].learning_goal == "Stand und Hüfte"
|
||||
|
||||
|
||||
def test_api_dict_exposes_prompt_slug_catalog():
|
||||
ctx = run_progression_roadmap_pipeline("Mae Geri", max_steps=3, include_llm_roadmap=False)
|
||||
api = progression_roadmap_to_api_dict(ctx)
|
||||
assert api["prompt_slug_catalog"]["start_target"] == PROMPT_SLUG_START_TARGET
|
||||
assert api["prompt_slug_catalog"]["goal_analysis"] == PROMPT_SLUG_GOAL_ANALYSIS
|
||||
assert api["prompt_slug_catalog"]["roadmap"] == PROMPT_SLUG_ROADMAP
|
||||
assert api["prompt_slug_catalog"]["stage_spec"] == PROMPT_SLUG_STAGE_SPEC
|
||||
assert api["prompt_slugs"] == []
|
||||
|
||||
|
||||
def test_resolve_structured_user_overrides_regex():
|
||||
brief = build_semantic_brief(KUMITE_GOAL)
|
||||
structured = RoadmapStructuredInput(
|
||||
start_situation="Trainer-Start explizit",
|
||||
target_state="Trainer-Ziel explizit",
|
||||
)
|
||||
resolved, meta, llm_raw = resolve_roadmap_structured_input(
|
||||
KUMITE_GOAL, structured, brief=brief, include_llm=False
|
||||
)
|
||||
assert llm_raw is None
|
||||
assert resolved.start_situation == "Trainer-Start explizit"
|
||||
assert resolved.target_state == "Trainer-Ziel explizit"
|
||||
assert meta.start_source == "user"
|
||||
assert meta.target_source == "user"
|
||||
|
||||
|
||||
def test_resolve_structured_regex_fallback_without_llm():
|
||||
brief = build_semantic_brief(KUMITE_GOAL)
|
||||
resolved, meta, _ = resolve_roadmap_structured_input(
|
||||
KUMITE_GOAL, None, brief=brief, include_llm=False
|
||||
)
|
||||
assert meta.start_source == "regex"
|
||||
assert meta.target_source == "regex"
|
||||
assert "Steppbewegung" in (resolved.start_situation or "")
|
||||
assert "dynamischen" in (resolved.target_state or "")
|
||||
|
||||
|
||||
def test_run_start_target_resolve_only_no_major_steps():
|
||||
ctx = run_start_target_resolve_only(KUMITE_GOAL, include_llm_start_target=False)
|
||||
assert ctx.pipeline_phase == "start_target_only"
|
||||
assert ctx.roadmap is None
|
||||
assert ctx.goal_analysis is not None
|
||||
assert "Steppbewegung" in ctx.goal_analysis.start_assumption
|
||||
assert ctx.resolved_structured is not None
|
||||
|
||||
|
||||
def test_resolve_structured_merges_user_and_llm_notes():
|
||||
brief = build_semantic_brief("Kumite Beinarbeit")
|
||||
structured = RoadmapStructuredInput(roadmap_notes="Kindergruppe 10–12")
|
||||
resolved, meta, _ = resolve_roadmap_structured_input(
|
||||
"Kumite Beinarbeit",
|
||||
structured,
|
||||
brief=brief,
|
||||
include_llm=False,
|
||||
)
|
||||
assert resolved.roadmap_notes == "Kindergruppe 10–12"
|
||||
assert meta.notes_source == "user"
|
||||
|
||||
|
||||
def test_parse_start_target_kumite_beinarbeit():
|
||||
start, target = parse_start_target_from_goal_query(KUMITE_GOAL)
|
||||
assert start is not None
|
||||
assert "Steppbewegung" in start
|
||||
assert target is not None
|
||||
assert "dynamischen" in target
|
||||
assert "Angriff" in target
|
||||
|
||||
|
||||
def test_build_goal_analysis_uses_parsed_start_target():
|
||||
brief = build_semantic_brief(KUMITE_GOAL)
|
||||
ga = build_goal_analysis(KUMITE_GOAL, brief)
|
||||
assert "Kumite Beinarbeit" in ga.primary_topic
|
||||
assert "Steppbewegung" in ga.start_assumption
|
||||
assert "dynamischen" in ga.target_state
|
||||
assert "Voraussetzungen der Zielgruppe werden im Progressionsgraphen nicht analysiert" not in ga.start_assumption
|
||||
|
||||
|
||||
def test_build_goal_analysis_structured_fields_override():
|
||||
brief = build_semantic_brief("Kumite Beinarbeit")
|
||||
structured = RoadmapStructuredInput(
|
||||
start_situation="statische Vorwärtsbewegung im Partnerdrill",
|
||||
target_state="explosiver Gegenangriff nach unvorhersehbarer Beinarbeit",
|
||||
roadmap_notes="Kindergruppe 10–12 Jahre",
|
||||
)
|
||||
ga = build_goal_analysis("Kumite Beinarbeit", brief, structured=structured)
|
||||
assert ga.start_assumption == structured.start_situation
|
||||
assert ga.target_state == structured.target_state
|
||||
assert any("Kindergruppe" in c for c in ga.success_criteria)
|
||||
|
||||
|
||||
def test_develop_micro_objectives_start_target_kumite():
|
||||
brief = build_semantic_brief(KUMITE_GOAL)
|
||||
ga = build_goal_analysis(KUMITE_GOAL, brief)
|
||||
micro = develop_micro_objectives(brief, goal_analysis=ga, min_count=6)
|
||||
titles = [m.title for m in micro]
|
||||
assert any("Ausgang" in t for t in titles)
|
||||
assert any("Ziel" in t for t in titles)
|
||||
assert not any("Einstieg und Orientierung zum Thema" in t for t in titles)
|
||||
|
||||
|
||||
def test_pipeline_kumite_major_steps_not_generic_templates():
|
||||
ctx = run_progression_roadmap_pipeline(KUMITE_GOAL, max_steps=5, include_llm_roadmap=False)
|
||||
goals = [s.learning_goal for s in ctx.roadmap.major_steps]
|
||||
joined = " ".join(goals).lower()
|
||||
assert "kumite beinarbeit" in joined
|
||||
assert "steppbewegung" in joined or "ausgang" in joined
|
||||
assert "dynamisch" in joined or "ziel" in joined
|
||||
assert not any(g == "Grundstellung und Basisbewegung" for g in goals)
|
||||
33
backend/tests/test_rights_registry.py
Normal file
33
backend/tests/test_rights_registry.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"""Registry-first: Modul-Registrierungen."""
|
||||
import rights_registrations # noqa: F401
|
||||
from rights_registry import (
|
||||
CapabilityRegistration,
|
||||
registered_capabilities,
|
||||
registered_features,
|
||||
register_capability,
|
||||
)
|
||||
|
||||
|
||||
def test_exercises_module_registers_wired_capabilities():
|
||||
assert "exercises.ai.suggest" in registered_capabilities()
|
||||
assert registered_capabilities()["exercises.ai.suggest"].module == "exercises"
|
||||
|
||||
|
||||
def test_register_capability_requires_module():
|
||||
try:
|
||||
register_capability(
|
||||
CapabilityRegistration(
|
||||
id="test.no.module",
|
||||
name="Test",
|
||||
domain="test",
|
||||
module="",
|
||||
)
|
||||
)
|
||||
assert False, "expected ValueError"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def test_registered_features_include_ai_calls():
|
||||
assert "ai_calls" in registered_features()
|
||||
assert registered_features()["ai_calls"].module == "exercises"
|
||||
|
|
@ -1,18 +1,27 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.190"
|
||||
BUILD_DATE = "2026-05-23"
|
||||
DB_SCHEMA_VERSION = "20260531077"
|
||||
APP_VERSION = "0.8.213"
|
||||
BUILD_DATE = "2026-06-07"
|
||||
DB_SCHEMA_VERSION = "20260607087"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
||||
"auth": "1.2.3", # P-05b: reset-password min_length=8 via Pydantic PasswordResetConfirm
|
||||
"profiles": "1.8.0", # training_planning_prefs JSONB (Planungs-UI); Patch via ProfileUpdate + Json(), Migration 055
|
||||
"tenant_context": "1.0.5", # Plattform-Admin: effective_club ohne Header aus Profil active_club_id wenn Verein existiert
|
||||
"clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext
|
||||
"profiles": "1.8.1", # GET /profiles/me: account_state + club_roles
|
||||
"tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext
|
||||
"capabilities": "1.2.0", # Registry-first: module-Spalte; Admin nur registrierte Rechte
|
||||
"rights_registry": "1.0.0", # register_capability/feature + startup sync
|
||||
"account_lifecycle": "1.1.0", # Phase A: account_onboarding_gate API-Middleware
|
||||
"clubs": "0.4.2", # delete_club: Gründungsanträge → superseded
|
||||
"club_memberships": "1.0.1", # Depends(get_tenant_context)
|
||||
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
|
||||
"club_creation_requests": "1.0.1", # superseded wenn freigegebener Verein gelöscht
|
||||
"admin_users": "1.0.0", # GET /api/admin/users
|
||||
"club_features": "1.6.0",
|
||||
"admin_rights": "1.1.0", # Matrix UX, Enforcement-Audit, clear club grants by capability
|
||||
"capability_enforcement_audit": "1.0.0",
|
||||
"club_quota_bypass": "1.0.0", # platform.club_quota.bypass* + Admin-Grants-API
|
||||
"entitlements": "1.2.0", # capability_quota_bypass in Feature-Map für /me/entitlements
|
||||
"platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT)
|
||||
"media_rights": "1.3.1", # acting_profile_id in write_audit_log_entry auf Optional[int] (P-13 anonyme Meldungen)
|
||||
"media_assets": "1.18.1", # P-13: open_report_count in Listendaten (fuer Admins)
|
||||
|
|
@ -28,8 +37,8 @@ MODULE_VERSIONS = {
|
|||
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
|
||||
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||
"methods": "0.1.0",
|
||||
"exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay
|
||||
"planning_exercise_suggest": "0.16.0", # E3: gap_fill_offers, Off-Topic, QA→KI-Pipeline
|
||||
"exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume
|
||||
"planning_exercise_suggest": "0.21.1", # start_target_only + reicher gap-fill planning_context
|
||||
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
||||
"training_programs": "0.1.0",
|
||||
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
||||
|
|
@ -44,6 +53,69 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.209",
|
||||
"date": "2026-06-07",
|
||||
"changes": [
|
||||
"F3-Polish: roadmap_first — keine Brücken zwischen Major Steps, kein LLM-Reorder.",
|
||||
"Pfad-QS: Lücken nur noch bei Nicht-Roadmap-Übergängen; roadmap_qa_mode in Response.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.208",
|
||||
"date": "2026-06-07",
|
||||
"changes": [
|
||||
"Phase D: planning_context an POST /exercises/ai/suggest — Prompts Migration 085.",
|
||||
"Pfad-Builder + Planungs-Picker senden strukturierten Planungskontext bei KI-Neuanlage.",
|
||||
"Modul planning_exercise_form_context.py; Platzhalter planning_context_json in Übungs-Prompts.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.207",
|
||||
"date": "2026-06-07",
|
||||
"changes": [
|
||||
"Phase F4: Roadmap-Review — roadmap_only, roadmap_override auf progression-path-suggest.",
|
||||
"UI: Major Steps editierbar (Phase, Lernziel, Reihenfolge) vor Übungs-Match.",
|
||||
"Zwei-Schritt-Flow: Roadmap vorschlagen → Übungen matchen.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.206",
|
||||
"date": "2026-06-07",
|
||||
"changes": [
|
||||
"Phase F3: roadmap_first — Bibliotheks-Match pro stage_spec/Major Step statt iterativem Pfad.",
|
||||
"Gap-Angebote für unbesetzte Roadmap-Stufen (roadmap_unfilled).",
|
||||
"UI: Pfad-Builder sendet roadmap_first; Übungen an Roadmap gekoppelt.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.205",
|
||||
"date": "2026-06-07",
|
||||
"changes": [
|
||||
"Phase F2: Roadmap-LLM über konfigurierbare ai_prompts (078/079) — nur Slugs im Code.",
|
||||
"include_llm_roadmap auf progression-path-suggest; Fallback deterministisch.",
|
||||
"Response: prompt_slugs, prompt_slug_catalog, llm_*_applied.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.204",
|
||||
"date": "2026-06-07",
|
||||
"changes": [
|
||||
"Planungs-KI Phase F0: Roadmap-first Architektur — planning_progression_roadmap.py (A→B→C).",
|
||||
"API progression-path-suggest: include_roadmap_preview, progression_roadmap in Response.",
|
||||
"Doku: PLANNING_PROGRESSION_ROADMAP_SPEC, PLANNING_KI_ROADMAP; Migration 078 Prompts.",
|
||||
"UI: Didaktische Roadmap-Box im Pfad-Builder (Übergangsphase parallel zu Retrieval).",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.203",
|
||||
"date": "2026-06-07",
|
||||
"changes": [
|
||||
"Pfad-Builder E3-Fix: themenfremde Schritte (z. B. One Leg Squat) aus Pfad entfernen.",
|
||||
"Lücken-Angebote: kein Pre-KI-Call — voller Entwurf beim Klick mit goal_for_ai-Kontext.",
|
||||
"UI: Skills-Katalog im Preview, maxSteps beim Einfügen einhalten.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.190",
|
||||
"date": "2026-05-23",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ services:
|
|||
APP_URL: "${APP_URL:-https://dev.shinkan.jinkendo.de}"
|
||||
ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://dev.shinkan.jinkendo.de,http://192.168.2.49:3098}"
|
||||
ENVIRONMENT: "${ENVIRONMENT:-development}"
|
||||
# M5: Hard-Block Vereins-Kontingente (Default aus — in .env auf 1 setzen zum Testen)
|
||||
CLUB_FEATURE_ENFORCE: "${CLUB_FEATURE_ENFORCE:-1}"
|
||||
MEDIAWIKI_API_URL: "${MEDIAWIKI_API_URL:-https://karatetrainer.net/api.php}"
|
||||
MEDIAWIKI_USER: "${MEDIAWIKI_USER:-Jinkendo}"
|
||||
MEDIAWIKI_PASSWORD: "${MEDIAWIKI_PASSWORD:-CHANGE_ME}"
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ services:
|
|||
APP_URL: "${APP_URL:-https://shinkan.jinkendo.de}"
|
||||
ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://shinkan.jinkendo.de}"
|
||||
ENVIRONMENT: "${ENVIRONMENT:-production}"
|
||||
CLUB_FEATURE_ENFORCE: "${CLUB_FEATURE_ENFORCE:-1}"
|
||||
# MediaWiki/SMW Import — in dev-env.yml bereits gesetzt; Prod brauchte diese Zeilen ebenfalls,
|
||||
# sonst: leere MEDIAWIKI_API_URL im Container → Import bricht ab (auf Test/Dev war es immer gesetzt).
|
||||
MEDIAWIKI_API_URL: "${MEDIAWIKI_API_URL:-https://karatetrainer.net/api.php}"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||
|
||||
**Stand:** 2026-05-23
|
||||
**App-Version / DB-Schema:** App **`0.8.187`** (Planungs-KI Phase E2); DB **`20260531074`** — maßgeblich **`backend/version.py`**.
|
||||
**Stand:** 2026-06-07
|
||||
**App-Version / DB-Schema:** App **`0.8.208`** (Planungs-KI Phase D); DB **`20260606086`** — maßgeblich **`backend/version.py`**.
|
||||
|
||||
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
|
||||
|
||||
|
|
@ -106,15 +106,21 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
|||
| **C3** | Graph-Builder: Ziel → Pfad vorschlagen → in Graph speichern | ✅ **0.8.185** |
|
||||
| **E** | Semantik-Schicht (Brief, Phrasen-Score) + Pfad-QA (Lücken, Brücken, LLM-QS) | ✅ **0.8.186** |
|
||||
| **E2** | Pfad-Neuordnung (LLM) + KI-Neuanlage bei unüberbrückbaren Lücken | ✅ **0.8.187** |
|
||||
| **D** | `planning_context` an `suggestExerciseAi` (Neu-Anlage) | 🔲 |
|
||||
| **E3** | `gap_fill_offers`, Off-Topic-Strip, voller KI-Call bei Lücken | ✅ **0.8.203** |
|
||||
| **F0–F2** | Roadmap-Pipeline + LLM-Prompts (078/079) | ✅ **0.8.205** |
|
||||
| **F3** | `roadmap_first` — Retrieval + QA lite (keine Brücken/Reorder) | ✅ **0.8.209** |
|
||||
| **F4** | Roadmap-Review UI + `roadmap_override` | ✅ **0.8.207** |
|
||||
| **D** | `planning_context` an `suggestExerciseAi` (Neu-Anlage) | ✅ **0.8.208** |
|
||||
|
||||
**Backend:** `planning_exercise_suggest.py`, `planning_exercise_retrieval.py`, `planning_exercise_profiles.py`, `planning_exercise_target_pipeline.py`, `planning_exercise_progression.py` · Router `POST /api/planning/exercise-suggest`
|
||||
**Architektur-Entscheidung (2026-06-07):** Progressionsgraph = **Roadmap-first** (Ziel → Major Steps → Übungs-Match). **Keine Gruppenanalyse** im Graphen. Mitai Workflow-Engine **später** — jetzt `planning_progression_roadmap.py`. Spec: **`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`** · Roadmap: **`docs/architecture/PLANNING_KI_ROADMAP.md`**
|
||||
|
||||
**Frontend:** `ExercisePickerModal` (Planung) · **`ExercisesListPageRoot`** — Schalter „Neu mit KI-Assistent“: Planungs-KI-Suche + Neuanlage-Modal (statt „+ Neu“) · `TrainingUnitEditPage` — `planningContext`
|
||||
**Backend:** `planning_exercise_suggest.py`, `planning_exercise_retrieval.py`, `planning_exercise_path_builder.py`, **`planning_progression_roadmap.py`** · Router `POST /api/planning/exercise-suggest`, `POST /api/planning/progression-path-suggest` (`roadmap_first`, `include_roadmap_preview`)
|
||||
|
||||
**Frontend:** `ExerciseProgressionPathBuilder` — Roadmap-Box + Pfad je Major Step (`roadmap_first`) · `ExercisePickerModal` (Planung)
|
||||
|
||||
**Superadmin:** Übungs-Anreicherung (Skills) — `exercise_enrichment_admin` (**0.8.178+**), separater Admin-Flow
|
||||
|
||||
**Offen (Qualität):** Bibliothek durchgängig mit Skills (Enrichment-Datenarbeit); manuelle Graph-Auswahl in UI; Progressionsgraph-Builder; Skill-Discovery/Framework-Pfade im Pack (P3)
|
||||
**Offen (F4+):** Roadmap-UI editierbar; Trainingsplanung eigene Pipeline (Gruppenkontext); Enrichment
|
||||
|
||||
#### Übungs-KI Formular / Schnellanlage (Stand **0.8.171**)
|
||||
|
||||
|
|
@ -249,10 +255,11 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
|||
|
||||
### Planungs-KI (priorisiert)
|
||||
|
||||
1. **Graph-Auswahl UI:** Dropdown neben Auto-Match; Rahmen-Slot mit Default-Graph verknüpfen.
|
||||
2. **Enrichment:** Skills/Tags pro Technik (Feinauflösung statt nur Geri Waza).
|
||||
3. **D — Neu-Anlage:** `planning_context_json` an `POST /api/exercises/ai/suggest`.
|
||||
4. **E3:** KI-Vorschlag im UI direkt anlegen (Modal) · Embeddings für Freitext.
|
||||
1. **Phase F2:** LLM für Roadmap (Prompts **078**) + `roadmap_first` Retrieval aus `stage_specs`.
|
||||
2. **Phase F4:** Roadmap-Review UI (Major Steps editierbar vor Übungs-Match).
|
||||
3. **Enrichment:** Skills/Tags pro Technik (Feinauflösung statt nur Geri Waza).
|
||||
4. **D — Neu-Anlage:** `planning_context_json` an `POST /api/exercises/ai/suggest`.
|
||||
5. **Trainingsplanung G:** Kontext-Pack Gruppe/Historie — eigene Pipeline (`AI_PLANNING_KI_MULTISTAGE_FORECAST`); Mitai Workflow-Engine erst danach.
|
||||
|
||||
### Allgemein
|
||||
|
||||
|
|
|
|||
88
docs/architecture/PLANNING_KI_ROADMAP.md
Normal file
88
docs/architecture/PLANNING_KI_ROADMAP.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Planungs-KI — Produkt-Roadmap
|
||||
|
||||
**Stand:** 2026-06-07
|
||||
**App-Version:** ab **0.8.204** — maßgeblich `backend/version.py`
|
||||
|
||||
Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROADMAP.md`) und gilt **nur für KI-gestützte Trainingsplanungsunterstützung**.
|
||||
|
||||
**Leit-Spec:** `.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`
|
||||
|
||||
---
|
||||
|
||||
## Strategische Entscheidung (verbindlich)
|
||||
|
||||
1. **Progressionsgraph:** Planung **vom Ziel rückwärts** (Roadmap-first), nicht Bibliothek-first.
|
||||
2. **Keine Gruppenanalyse** im Graphen — Kontext = Zieltext, Thema, Schrittanzahl, optional Graph-Kanten.
|
||||
3. **Trainingsplanung** (Einheit, Rahmen, Abschnitt): eigene Pipeline später, **mit** Gruppenkontext — siehe `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` S0–S4.
|
||||
4. **Orchestrierung:** Workflow-**lite** jetzt (`planning_progression_roadmap.py`); Mitai Workflow-Engine **später**, wenn 2–3 Pipelines stabil sind.
|
||||
|
||||
---
|
||||
|
||||
## Phasen-Übersicht
|
||||
|
||||
| Phase | Domäne | Kurzbeschreibung | Status |
|
||||
|-------|--------|------------------|--------|
|
||||
| P0–P2 | Übungssuche | Kontext-Pack, Hybrid-Score, LLM-Rerank | ✅ |
|
||||
| A–C2 | Übungssuche | Voll-Library, Graph, Varianten | ✅ |
|
||||
| C3 | Progressionsgraph | Pfad-Builder (retrieval-first) | ✅ |
|
||||
| E–E3 | Progressionsgraph | Semantik, QA, Lücken-Angebote | ✅ |
|
||||
| **F0–F1** | Progressionsgraph | Roadmap-Pipeline Scaffold + API-Preview | 🔄 **0.8.204** |
|
||||
| **F2–F4** | Progressionsgraph | LLM Roadmap, roadmap-first Retrieval, UI Review | 🔲 |
|
||||
| D | Übungs-Neuanlage | `planning_context` an `suggestExerciseAi` | ✅ **0.8.208** |
|
||||
| G | Trainingsplanung | Kontext-Pack Gruppe/Historie, S0–S4 | 🔲 |
|
||||
| H | Plattform | Mitai-Workflow-Engine (optional) | 🔲 Backlog |
|
||||
|
||||
---
|
||||
|
||||
## Phase F — Progressions-Roadmap (aktiver Fokus)
|
||||
|
||||
### F0 — Foundation (0.8.204)
|
||||
|
||||
- [x] Spec `PLANNING_PROGRESSION_ROADMAP_SPEC.md`
|
||||
- [x] Modul `planning_progression_roadmap.py` (Pydantic, Pipeline-Skeleton)
|
||||
- [x] Migration **078** Prompt-Slugs (Zielanalyse, Roadmap)
|
||||
- [x] API: `include_roadmap_preview` auf `progression-path-suggest`
|
||||
- [x] Doku: HANDOVER, PLANNING_EXERCISE_SUGGEST_CONTEXT, MULTISTAGE_FORECAST
|
||||
|
||||
### F1 — Deterministische Roadmap
|
||||
|
||||
- [x] Phase A aus Semantic Brief
|
||||
- [x] Phase B: `micro_objectives` aus `development_arc` + Konsolidierung auf N
|
||||
- [x] Phase C: heuristische `stage_specs`
|
||||
- [ ] pytest für Konsolidierung
|
||||
|
||||
### F2 — LLM Roadmap (0.8.205)
|
||||
|
||||
- [x] Prompts **078/079** in `ai_prompts` — Code nur Slugs (`PROMPT_SLUG_*`)
|
||||
- [x] `include_llm_roadmap` + `load_and_render_ai_prompt` + JSON-Validierung
|
||||
- [x] Deterministischer Fallback wenn Prompt/OpenRouter fehlt
|
||||
- [ ] Response/UI: genutzte `prompt_slugs` sichtbar machen (Admin-Hinweis)
|
||||
|
||||
### F3 — roadmap-first (0.8.206)
|
||||
|
||||
- [x] Retrieval pro `major_step` + `stage_spec` statt iterativem Pfad-Bau
|
||||
- [x] Gap-Angebote für unbesetzte Roadmap-Stufen (`roadmap_unfilled`)
|
||||
- [x] QA/Lücken an Roadmap gekoppelt (`roadmap_first_lite`: keine Brücken/Reorder zwischen Major Steps)
|
||||
|
||||
### F4 — UI (0.8.207)
|
||||
|
||||
- [x] Roadmap-Review im `ExerciseProgressionPathBuilder`
|
||||
- [x] Major Steps editierbar (Phase, Lernziel, Reihenfolge) vor Übungs-Match
|
||||
- [x] API `roadmap_only` + `roadmap_override`
|
||||
|
||||
---
|
||||
|
||||
## Abhängigkeiten
|
||||
|
||||
| Von | Nach | Hinweis |
|
||||
|-----|------|---------|
|
||||
| F2 | Enrichment / Skills | Bessere Roadmap bei technikspezifischen Skills |
|
||||
| F3 | F2 | LLM-Roadmap oder stabile heuristische B |
|
||||
| G | F4 | Trainingsplanung kann Roadmap aus Graph referenzieren |
|
||||
| H | G + F4 | Workflow-Engine lohnt bei verzweigten Planungsflows |
|
||||
|
||||
---
|
||||
|
||||
## Pflege
|
||||
|
||||
Bei Abschluss einer Teilphase: diese Datei, `HANDOVER.md` §2.8, `PLANNING_EXERCISE_SUGGEST_CONTEXT.md` §24, Changelog in `version.py`.
|
||||
|
|
@ -14,6 +14,8 @@ Dieses Bündel ist die **Leitlinie für die große Refaktorierung** nach dem MVP
|
|||
| [`frontend/src/api/planning.js`](../../frontend/src/api/planning.js) | Phase 4: Trainingsplanung (Einheiten, Vorlagen, Module, Rahmen, KPIs) |
|
||||
| [BASELINE_SNAPSHOT.md](./BASELINE_SNAPSHOT.md) | Phase 0: Bundle-, API- und Last-Baseline (Messvorlagen, Vergleich nach Phase 2) |
|
||||
| [KI-Prompt-Zielarchitektur](../../.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md) | Roadmap: Kontext-Arten, Composition, Planung/Rahmen, Phasenplan (verbindliche Zielrichtung) |
|
||||
| [PLANNING_KI_ROADMAP.md](./PLANNING_KI_ROADMAP.md) | **Planungs-KI Produkt-Roadmap** (Phase F Roadmap-first, Abgrenzung Trainingsplanung) |
|
||||
| [Progressions-Roadmap Spec](../../.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md) | Phase F: Artefakte A→B→C, API, Workflow-lite |
|
||||
|
||||
## Tests (E2E / Refaktor-Budget)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@
|
|||
**Ziel:** Nach MVP eine **nachhaltige** Architektur für Wachstum, **Performance** (Server + schwache Clients) und **sichere Feature-Erweiterung**.
|
||||
**Leitdokumente:** [ZIELBILD_ARCHITEKTUR.md](./ZIELBILD_ARCHITEKTUR.md), [SCHULDEN_UND_REMEDIATION.md](./SCHULDEN_UND_REMEDIATION.md), [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md).
|
||||
|
||||
**Planungs-KI (parallel):** [PLANNING_KI_ROADMAP.md](./PLANNING_KI_ROADMAP.md) — Phase **F** Roadmap-first Progressionsgraph (ab 0.8.204), unabhängig von Architektur-Phase 4 API-Split.
|
||||
|
||||
---
|
||||
|
||||
## Leitplanken (vereinbart)
|
||||
|
|
|
|||
188
docs/working/RBAC_ENFORCEMENT_ROADMAP.md
Normal file
188
docs/working/RBAC_ENFORCEMENT_ROADMAP.md
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
# RBAC, Kontingente & Enforcement — Roadmap
|
||||
|
||||
**Stand:** 2026-06-08 · App **0.8.202** · Schema **20260606084**
|
||||
**Bezüge:** `MEMBERSHIP_RBAC_DECISIONS_2026-06.md`, `CAPABILITY_CATALOG.v1.md`, `CLUB_MEMBERSHIP_AND_FEATURES.v1.md`
|
||||
|
||||
Diese Roadmap bündelt **was fertig ist**, **was als Standard gilt** und **was noch fehlt** — ohne Insellösungen pro Feature.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architektur-Standard (verbindlich)
|
||||
|
||||
### Registry-first (korrigiert 2026-06-07)
|
||||
|
||||
**Nicht:** vollständiger Capability-Katalog in Migration 079.
|
||||
**Sondern:** Module registrieren Rechte/Kontingente bei Implementierung → `docs/working/RIGHTS_AND_FEATURES_REGISTRY.md`.
|
||||
|
||||
Admin-Matrix zeigt nur `capabilities.module IS NOT NULL` — keine vorgetäuschte Vollständigkeit.
|
||||
|
||||
### Request-Kette (Ziel)
|
||||
|
||||
```
|
||||
Auth → Account-State → TenantContext
|
||||
→ probe_capability (Recht)
|
||||
→ probe_member_feature_access (Person, v2/M9 — falls Sub-Budget gesetzt)
|
||||
→ probe_club_feature_access (Vereins-Kontingent)
|
||||
→ Governance (Objekt)
|
||||
→ Business-Logik
|
||||
→ consume (Verein + Person) + merge_feature_usage_into_response
|
||||
```
|
||||
|
||||
**v1 (aktuell):** nur Vereins-Ebene. **v2 (Phase 5b):** Prüfung und Zählung zusätzlich gegen `profile_id` aus der Session.
|
||||
|
||||
### Frontend-Standard
|
||||
|
||||
- `GET /api/me/entitlements` = einzige Quelle für Rechte + Kontingente in der UI
|
||||
- `request()` synchronisiert `feature_usage` aus API-Responses automatisch (`featureUsageSync.js`)
|
||||
- Keine parallelen `if (club_admin)` für **Sicherheit** (UX-Fallback nur übergangsweise)
|
||||
|
||||
### Admin
|
||||
|
||||
- **Rollen & Rechte** (`/admin/rights`): Matrix mit Klartext zuerst, technische ID darunter
|
||||
- Umsetzungsstand pro Recht: `capability_enforcement_audit.py` → Feld `enforcement` in der Matrix
|
||||
|
||||
---
|
||||
|
||||
## 2. Ist-Stand nach Meilenstein
|
||||
|
||||
| Meilenstein | Inhalt | Status |
|
||||
|-------------|--------|--------|
|
||||
| **M1** | Feature-Schema, Pläne, Seeds | ✅ |
|
||||
| **M2** | Feature-Probe + JSON-Log | ✅ |
|
||||
| **M3** | Capabilities, Account-Lifecycle, Tenant | ✅ (Legacy parallel) |
|
||||
| **M4** | `/me/entitlements`, Badge (KI) | ✅ teilweise |
|
||||
| **M5** | Hard-Block + vollständiger Consume | ⚠️ `ai_calls` consume + Enforce auf Dev/Prod (0.8.202); Consume andere Features offen |
|
||||
| **M6** | Admin UI Rollen & Rechte | ⚠️ Matrix + Kontingente; kein Plan-/Rollen-CRUD |
|
||||
| **M7** | Vereinsgründung beantragen | ✅ Basis + Capabilities |
|
||||
| **M8** | Stripe | ❌ |
|
||||
| **Sync** | `feature_usage` + `request()` | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 3. Roadmap (empfohlene Reihenfolge)
|
||||
|
||||
### Phase 1 — Durchsetzung sichtbar machen (kurz)
|
||||
|
||||
| # | Paket | Lieferumfang | Aufwand |
|
||||
|---|--------|--------------|---------|
|
||||
| 1.1 | **Admin-Matrix UX** | Alle Rechte, Haken-Matrix, Umsetzungs-Badge | ✅ 2026-06-07 |
|
||||
| 1.2 | **Doku-Sync** | Diese Roadmap, `MEMBERSHIP_RBAC` §2 | ✅ |
|
||||
| 1.3 | **Audit pflegen** | Bei jedem `probe_capability` → `capability_enforcement_audit.py` | laufend |
|
||||
|
||||
### Phase 2 — Kontingente vollständig (M5)
|
||||
|
||||
| # | Paket | Lieferumfang |
|
||||
|---|--------|--------------|
|
||||
| 2.1 | **Consume erweitern** | `exercises`, `exercise_media` nach Standard-Helfer |
|
||||
| 2.2 | **Badges** | `FeatureUsageBadge` an Create/Upload, nicht nur KI |
|
||||
| 2.3 | **Dev: Enforce** | `CLUB_FEATURE_ENFORCE=1` auf Dev, Free `ai_calls=0` testen | ✅ verifiziert |
|
||||
| 2.4 | **Prod-Rollout** | Enforce schrittweise; Kommunikation an Vereine | ✅ Default Compose=1 |
|
||||
|
||||
### Phase 3 — Capabilities an alle Endpoints (C3–C4)
|
||||
|
||||
| # | Paket | Lieferumfang |
|
||||
|---|--------|--------------|
|
||||
| 3.1 | **Endpoint-Audit** | `ACCESS_LAYER_ENDPOINT_AUDIT.md` — jeder Schreib-Pfad |
|
||||
| 3.2 | **probe_capability** | `exercises.update/delete`, `planning.*`, `org.*`, Medien-Bibliothek, … |
|
||||
| 3.3 | **CAPABILITY_ENFORCE=1** | Nach Audit auf Dev, dann Prod |
|
||||
| 3.4 | **Legacy abbauen** | `can_plan_in_club` nur noch als Fallback, dokumentiert |
|
||||
|
||||
### Phase 4 — Frontend auf Entitlements (Phase E)
|
||||
|
||||
| # | Paket | Lieferumfang |
|
||||
|---|--------|--------------|
|
||||
| 4.1 | **Navigation** | Menüpunkte aus `hasCapability()` |
|
||||
| 4.2 | **Buttons** | KI, Anlegen, Löschen, Planung — aus Entitlements |
|
||||
| 4.3 | **Rollen-Labels** | Anzeige `club_roles` statt technischer IDs |
|
||||
|
||||
### Phase 5 — Admin & Produkt (M6 voll)
|
||||
|
||||
| # | Paket | Lieferumfang |
|
||||
|---|--------|--------------|
|
||||
| 5.1 | **Pläne-CRUD** | Neue Vereinspläne anlegen, nicht nur Seed |
|
||||
| 5.2 | **Systemrolle Co-Trainer** | Seed + Matrix |
|
||||
|
||||
### Phase 5b — Kontingent-Verteilung durch Vereinsadmins (M9, Priorität KI-Kosten)
|
||||
|
||||
**Ziel:** Vereins-Kontingent bleibt Plan-Ebene; **Vereinsadmin** verteilt Teilkontingente auf **einzelne Personen** (`profile_id`). Verbrauch und Hard-Block gelten **pro Person** und gegen den Vereins-Pool.
|
||||
|
||||
| # | Paket | Lieferumfang |
|
||||
|---|--------|--------------|
|
||||
| 5b.1 | **Schema** | `club_member_feature_budgets`, `club_member_feature_usage` (Migration); Events mit `profile_id` (bestehend teilweise) |
|
||||
| 5b.2 | **Prüf-Kette** | `probe_capability` → **Mitglieds-Budget** (`profile_id` aus Session) → Vereins-Kontingent → Governance |
|
||||
| 5b.3 | **Consume** | Zählung auf Verein **und** Person; `consume_club_feature_with_usage` erweitern |
|
||||
| 5b.4 | **Entitlements** | `/me/entitlements`: persönliches Budget + Vereins-Rest (z. B. `ai_calls_personal`, `ai_calls_club`) |
|
||||
| 5b.5 | **Vereinsadmin-UI** | Kontingente auf Mitglieder verteilen (Liste/Formular pro Trainer); nur `club_admin` im eigenen Verein |
|
||||
| 5b.6 | **Auswertung** | Admin/Superadmin: Verbrauch **je Person** einsehbar (Fairness, „Kontingent-Fresser“); Filter `profile_id` |
|
||||
| 5b.7 | **Fairness-Modell** | Harte Sub-Budgets (Modell A, Tendenz): Person darf eigenes Limit nicht überschreiten, auch wenn Verein noch Rest hat |
|
||||
|
||||
**Erstes Feature:** `ai_calls` (OpenRouter-Kosten). Später gleiches Muster für andere registrierte Kontingente.
|
||||
|
||||
**Registry:** `register_member_quota_feature()` oder Erweiterung `FeatureRegistration` mit `supports_member_budget: true`.
|
||||
|
||||
Bezug: `MEMBERSHIP_RBAC_DECISIONS_2026-06.md` §1.4.
|
||||
|
||||
### Phase 6 — Abrechnung (M8)
|
||||
|
||||
Stripe / Rechnung — bewusst nach funktionierendem Enforce.
|
||||
|
||||
---
|
||||
|
||||
## 4. Superadmin & Vereinsrechte (Entscheidung)
|
||||
|
||||
**Kurz: Superadmin braucht keine Vereinsrolle `club_admin` für die meiste Arbeit.**
|
||||
|
||||
| Ebene | Verhalten |
|
||||
|-------|-----------|
|
||||
| **Capabilities (neu)** | `admin` und `superadmin` = `platform_admin_bypass` für **alle Vereins-Capabilities** (`capabilities.py`) — unabhängig von `club_member_roles` |
|
||||
| **Legacy-Helfer** | `can_plan_in_club`, `can_manage_club_org` → `True` für Plattform-Admin ohne Mitgliedschaft |
|
||||
| **Mandant** | Aktiver Verein über `X-Active-Club-Id` / `active_club_id` — **keine** Mitgliedschaft nötig für Plattform-Admin |
|
||||
| **Kontingente** | Superadmin: Quota-Bypass (Capability-Grant); zählt nicht gegen Vereins-Kontingent |
|
||||
| **Ausnahmen Legacy** | Einzelne Pfade prüfen noch **nur** `has_club_role(…, 'club_admin')` ohne Plattform-Bypass — z. B. Löschen von `visibility=club`-Übungen. → Phase 3 bereinigen |
|
||||
|
||||
**Empfehlung:** Superadmin **nicht** zwingend als `club_admin` in jeden Verein eintragen. Optional Mitgliedschaft nur für realistische Audit-Tests oder Vereinsorga-Simulation. Produktiv: Mandant per Club-Switcher wählen.
|
||||
|
||||
`admin` (Portal-Admin): gleicher Capability-Bypass für Vereins-Funktionen; Portal-Capabilities nur mit explizitem Grant in der Matrix.
|
||||
|
||||
---
|
||||
|
||||
## 5. Vereinsrollen-Matrix — Semantik (Admin-UI)
|
||||
|
||||
| Zustand | Bedeutung | UI |
|
||||
|---------|-----------|-----|
|
||||
| **Keine Grants** in DB | Alle aktiven Mitglieder (wenn `min_account_state` reicht) | Zellen zeigen „alle“ |
|
||||
| **Mindestens ein Grant** | Nur angehakte Rollen | Checkboxen |
|
||||
| **„Alle Mitglieder“** | Löscht alle Grants der Zeile | Zurück zum offenen Zustand |
|
||||
|
||||
Das ersetzt das frühere Formular „Vereinsrollen-Grant hinzufügen“, das nur bereits eingeschränkte Rechte sichtbar machte.
|
||||
|
||||
---
|
||||
|
||||
## 6. Offene Lücken (Checkliste)
|
||||
|
||||
- [ ] `CAPABILITY_ENFORCE=1` in Produktion
|
||||
- [x] `CLUB_FEATURE_ENFORCE=1` auf Dev (Deploy 0.8.202 verifiziert)
|
||||
- [ ] `CLUB_FEATURE_ENFORCE=1` in Produktion (nach Prod-Deploy bestätigen)
|
||||
- [ ] Consume für alle Features mit Verbrauch (nicht nur `ai_calls`)
|
||||
- [ ] `probe_capability` auf >90 % der Schreib-Endpoints
|
||||
- [ ] Frontend ohne Legacy-Rollen-Guards
|
||||
- [ ] Multipart-Uploads an `featureUsageSync` anbinden
|
||||
- [ ] Legacy-Löschpfade mit Plattform-Bypass harmonisieren
|
||||
- [ ] **M9:** Kontingent-Verteilung Vereinsadmin → Person (`profile_id`), Prüfung + UI
|
||||
- [ ] `HANDOVER.md` / `PROJECT_STATUS` Versionsstand aktualisieren
|
||||
|
||||
---
|
||||
|
||||
## 7. Referenzen
|
||||
|
||||
| Datei | Zweck |
|
||||
|-------|--------|
|
||||
| `backend/capability_enforcement_audit.py` | Matrix-Badges „angebunden / Legacy“ |
|
||||
| `backend/club_features.py` | Consume-Standard |
|
||||
| `frontend/src/utils/featureUsageSync.js` | Entitlements-Sync |
|
||||
| `frontend/src/pages/AdminRightsPage.jsx` | Konfiguration |
|
||||
|
||||
**Changelog**
|
||||
|
||||
- 2026-06-07: Initial nach Session Rollen/Kontingente — Standard, Roadmap Phasen 1–6, Superadmin-Klärung, Matrix-Semantik.
|
||||
- 2026-06-08: Phase 5b / M9 — Kontingent-Verteilung durch Vereinsadmins, personenbezogene Prüfung (`profile_id`); M5 Enforce Dev verifiziert.
|
||||
90
docs/working/RIGHTS_AND_FEATURES_REGISTRY.md
Normal file
90
docs/working/RIGHTS_AND_FEATURES_REGISTRY.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# Rechte & Kontingente — Registry-first (Zielarchitektur)
|
||||
|
||||
**Stand:** 2026-06-07 · **Status:** verbindlich (korrigiert Katalog-first aus Migration 079)
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem mit dem Katalog-first-Ansatz
|
||||
|
||||
Migration `079_capabilities.sql` hat **~70 Rechte vorab** in die DB geschrieben — aus einer Spekulation über die fertige App. Das ist für ein System im Aufbau **verkehrt herum**:
|
||||
|
||||
- Vollständige Liste ist **nicht möglich** und nicht wünschenswert
|
||||
- Die Matrix **suggeriert** Funktionen, die es am Endpoint noch nicht gibt
|
||||
- Module **registrieren sich nicht** — alles war manueller Seed
|
||||
|
||||
**Korrektur:** Registry-first — wie bei anderen Registries im Projekt (z. B. Platzhalter-Pflicht).
|
||||
|
||||
---
|
||||
|
||||
## 2. Zielbild
|
||||
|
||||
```
|
||||
Modul implementiert Feature
|
||||
→ register_capability() / register_feature() in rights_registrations/<modul>.py
|
||||
→ Startup: sync_rights_registry_to_db()
|
||||
→ Admin „Rollen & Rechte“ zeigt nur Einträge mit module IS NOT NULL
|
||||
→ Endpoint: probe_capability + probe/consume Kontingent
|
||||
```
|
||||
|
||||
| Achse | Registrierung | Konfiguration Admin |
|
||||
|-------|---------------|---------------------|
|
||||
| **Recht** | `CapabilityRegistration` | Matrix Vereins-/Portal-Rollen |
|
||||
| **Kontingent** | `FeatureRegistration` | Vereinspläne / Limits |
|
||||
|
||||
Kein neuer Eintrag in `079`-artigen Bulk-Migrations für fachliche Rechte.
|
||||
|
||||
---
|
||||
|
||||
## 3. Implementierung (Code)
|
||||
|
||||
| Pfad | Rolle |
|
||||
|------|--------|
|
||||
| `backend/rights_registry.py` | `register_capability`, `register_feature`, `sync_rights_registry_to_db` |
|
||||
| `backend/rights_registrations/*.py` | Pro Modul nur **tatsächlich verdrahtete** Rechte/Kontingente |
|
||||
| `backend/main.py` | Sync nach Migrationen |
|
||||
| Migration `084_rights_registry_module.sql` | Spalte `module` auf `capabilities` + `features` |
|
||||
| `admin_rights.py` | Matrix-Query: `WHERE module IS NOT NULL` |
|
||||
|
||||
### Neues Modul anbinden (Pflicht)
|
||||
|
||||
1. Datei `rights_registrations/mein_modul.py` anlegen
|
||||
2. `register_capability` / `register_feature` aufrufen
|
||||
3. In `rights_registrations/__init__.py` importieren
|
||||
4. Endpoint: `probe_capability` + ggf. `consume_club_feature_with_usage`
|
||||
5. `capability_enforcement_audit.WIRED_PROBE` ergänzen
|
||||
|
||||
**Kein** Eintrag in `CAPABILITY_CATALOG` als Voraussetzung für DB — der Katalog wird zur **Dokumentation** der Namenskonvention, nicht zur Seed-Quelle.
|
||||
|
||||
---
|
||||
|
||||
## 4. Legacy-Katalog (079)
|
||||
|
||||
- Bleibt in der DB (`module IS NULL`) für Übergang / `check_capability`-Kompatibilität
|
||||
- Erscheint **nicht** mehr in der Admin-Matrix
|
||||
- Wird nicht erweitert — neue Rechte nur über Registry
|
||||
- Langfristig: ungenutzte Seed-Zeilen deaktivieren oder archivieren
|
||||
|
||||
---
|
||||
|
||||
## 5. Aktuell registrierte Module (Start)
|
||||
|
||||
| Modul | Rechte | Kontingente |
|
||||
|-------|--------|-------------|
|
||||
| `exercises` | KI suggest/regenerate, create, media.upload | `ai_calls`, `exercises`, `exercise_media` |
|
||||
| `planning_exercise_suggest` | planning.ai.* | (nutzt `ai_calls`) |
|
||||
| `club_creation_requests` | Gründung + approve | — |
|
||||
| `platform` | admin.access, quota.bypass | — |
|
||||
|
||||
Weitere Module folgen **mit ihrer Implementierung**, nicht vorher.
|
||||
|
||||
---
|
||||
|
||||
## 6. Referenzen
|
||||
|
||||
- `docs/working/RBAC_ENFORCEMENT_ROADMAP.md` — Enforcement nach Verdrahtung
|
||||
- `MEMBERSHIP_RBAC_DECISIONS_2026-06.md` — Produktentscheidungen
|
||||
- `CLUB_MEMBERSHIP_AND_FEATURES.v1.md` — Kontingent-Semantik
|
||||
|
||||
**Changelog**
|
||||
|
||||
- 2026-06-07: Registry-first als verbindliche Korrektur; Migration 084; Pilot-Registrierungen.
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import React, { Suspense, lazy } from 'react'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import { lazyWithRetry } from './utils/lazyWithRetry'
|
||||
import {
|
||||
RouterProvider,
|
||||
createBrowserRouter,
|
||||
|
|
@ -8,18 +10,20 @@ import {
|
|||
Outlet,
|
||||
} from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import { EntitlementsProvider } from './context/EntitlementsContext'
|
||||
import { FormEditorActionsProvider, FormEditorBottomSlot } from './context/FormEditorActionsContext'
|
||||
import { ToastProvider } from './context/ToastContext'
|
||||
import { OrgInboxProvider, useOrgInbox } from './context/OrgInboxContext'
|
||||
import DesktopSidebar from './components/DesktopSidebar'
|
||||
import { getMainNavItems } from './config/appNav'
|
||||
import { isOnboardingAllowedPath, isOnboardingRestricted } from './utils/accountState'
|
||||
import AdminHomeRedirect from './components/AdminHomeRedirect'
|
||||
import PlatformAdminRoute from './components/PlatformAdminRoute'
|
||||
import ActiveClubSwitcher from './components/ActiveClubSwitcher'
|
||||
import InactiveMembershipBanner from './components/InactiveMembershipBanner'
|
||||
import './app.css'
|
||||
|
||||
const LoginPage = lazy(() => import('./pages/LoginPage'))
|
||||
const OnboardingPage = lazyWithRetry(() => import('./pages/OnboardingPage'))
|
||||
const VerifyPage = lazy(() => import('./pages/VerifyPage'))
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'))
|
||||
const AccountSettingsPage = lazy(() => import('./pages/AccountSettingsPage'))
|
||||
|
|
@ -51,6 +55,8 @@ const AdminMaturityModelsPage = lazy(() => import('./pages/AdminMaturityModelsPa
|
|||
const TrainerContextsPage = lazy(() => import('./pages/TrainerContextsPage'))
|
||||
const MediaWikiImportPage = lazy(() => import('./pages/MediaWikiImportPage'))
|
||||
const AdminUsersPage = lazy(() => import('./pages/AdminUsersPage'))
|
||||
const AdminClubCreationRequestsPage = lazy(() => import('./pages/AdminClubCreationRequestsPage'))
|
||||
const AdminRightsPage = lazy(() => import('./pages/AdminRightsPage'))
|
||||
const MediaLibraryPage = lazy(() => import('./pages/MediaLibraryPage'))
|
||||
const LegalPage = lazy(() => import('./pages/LegalPage'))
|
||||
const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPage'))
|
||||
|
|
@ -82,9 +88,12 @@ function AppRouteFallback() {
|
|||
}
|
||||
|
||||
// Bottom Navigation (Mobile)
|
||||
function Nav({ showAdminNav }) {
|
||||
const { canAccessOrgInbox, inboxCount } = useOrgInbox()
|
||||
const items = getMainNavItems(showAdminNav, { showInbox: canAccessOrgInbox })
|
||||
function Nav({ showAdminNav, onboardingOnly }) {
|
||||
const { canShowInboxNav, inboxCount } = useOrgInbox()
|
||||
const items = getMainNavItems(showAdminNav, {
|
||||
showInbox: canShowInboxNav,
|
||||
onboardingOnly,
|
||||
})
|
||||
const loc = useLocation()
|
||||
|
||||
const navItemActive = (pathname, item, routerIsActive) => {
|
||||
|
|
@ -119,11 +128,10 @@ function Nav({ showAdminNav }) {
|
|||
function ProtectedLayout() {
|
||||
const { isAuthenticated, loading, user, logout } = useAuth()
|
||||
|
||||
const handleLogout = () => {
|
||||
if (confirm('Wirklich abmelden?')) {
|
||||
logout()
|
||||
window.location.href = '/'
|
||||
}
|
||||
const handleLogout = async () => {
|
||||
if (!confirm('Wirklich abmelden?')) return
|
||||
await logout()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
|
|
@ -146,26 +154,37 @@ function ProtectedLayout() {
|
|||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
const showAdminNav = computeShowAdminNav(user)
|
||||
const location = useLocation()
|
||||
const onboardingOnly = isOnboardingRestricted(user)
|
||||
if (onboardingOnly && !isOnboardingAllowedPath(location.pathname)) {
|
||||
return <Navigate to="/onboarding" replace />
|
||||
}
|
||||
|
||||
const showAdminNav = computeShowAdminNav(user) && !onboardingOnly
|
||||
|
||||
return (
|
||||
<OrgInboxProvider user={user}>
|
||||
<FormEditorActionsProvider>
|
||||
<DesktopSidebar showAdminNav={showAdminNav} user={user} onLogout={handleLogout} />
|
||||
<DesktopSidebar
|
||||
showAdminNav={showAdminNav}
|
||||
onboardingOnly={onboardingOnly}
|
||||
user={user}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
<div className="app-shell">
|
||||
<div className="app-shell__column">
|
||||
<div className="app-header app-header--mobile app-header--mobile-stack">
|
||||
<div className="app-header-mobile__top">
|
||||
<div className="app-logo">🥋 Shinkan</div>
|
||||
</div>
|
||||
<ActiveClubSwitcher variant="mobile" />
|
||||
{!onboardingOnly ? <ActiveClubSwitcher variant="mobile" /> : null}
|
||||
</div>
|
||||
<div className="app-main">
|
||||
<InactiveMembershipBanner />
|
||||
<Outlet />
|
||||
</div>
|
||||
<FormEditorBottomSlot>
|
||||
<Nav showAdminNav={showAdminNav} />
|
||||
<Nav showAdminNav={showAdminNav} onboardingOnly={onboardingOnly} />
|
||||
</FormEditorBottomSlot>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -219,6 +238,7 @@ const appRouter = createBrowserRouter([
|
|||
element: <ProtectedLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Dashboard /> },
|
||||
{ path: 'onboarding', element: <OnboardingPage /> },
|
||||
{ path: 'profile', element: <Navigate to="/settings" replace /> },
|
||||
{ path: 'settings', element: <AccountSettingsPage /> },
|
||||
{ path: 'settings/system', element: <SettingsSystemInfoPage /> },
|
||||
|
|
@ -264,6 +284,23 @@ const appRouter = createBrowserRouter([
|
|||
</PlatformAdminRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'admin/club-creation-requests',
|
||||
element: (
|
||||
<PlatformAdminRoute>
|
||||
<AdminClubCreationRequestsPage />
|
||||
</PlatformAdminRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'admin/rights',
|
||||
element: (
|
||||
<PlatformAdminRoute>
|
||||
<AdminRightsPage />
|
||||
</PlatformAdminRoute>
|
||||
),
|
||||
},
|
||||
{ path: 'admin/membership', element: <Navigate to="/admin/rights" replace /> },
|
||||
{
|
||||
path: 'admin/hierarchy',
|
||||
element: (
|
||||
|
|
@ -345,11 +382,13 @@ const appRouter = createBrowserRouter([
|
|||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<Suspense fallback={<AppRouteFallback />}>
|
||||
<RouterProvider router={appRouter} />
|
||||
</Suspense>
|
||||
</ToastProvider>
|
||||
<EntitlementsProvider>
|
||||
<ToastProvider>
|
||||
<Suspense fallback={<AppRouteFallback />}>
|
||||
<RouterProvider router={appRouter} />
|
||||
</Suspense>
|
||||
</ToastProvider>
|
||||
</EntitlementsProvider>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
* Alle API-Aufrufe laufen über request() — siehe utils/api.js (Facade) und Domänenmodule (planning.js, exercises.js).
|
||||
*/
|
||||
|
||||
import { syncFeatureUsageFromApiResponse } from '../utils/featureUsageSync.js'
|
||||
|
||||
export const API_URL = import.meta.env.VITE_API_URL || ''
|
||||
|
||||
/** LocalStorage + Request-Header für Mandanten-Kontext */
|
||||
|
|
@ -80,7 +82,9 @@ async function _fetchWithAuth(endpoint, options = {}) {
|
|||
*/
|
||||
export async function request(endpoint, options = {}) {
|
||||
const response = await _fetchWithAuth(endpoint, options)
|
||||
return response.json()
|
||||
const data = await response.json()
|
||||
syncFeatureUsageFromApiResponse(data)
|
||||
return data
|
||||
}
|
||||
|
||||
/** Text-Download (z. B. CSV-Export) mit gleicher Auth wie request(). */
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { NavLink } from 'react-router-dom'
|
||||
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Sparkles, Wand2, Activity } from 'lucide-react'
|
||||
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Sparkles, Wand2, Activity, Building2, Shield } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Admin-Seiten-Navigation (horizontal) — nur für Super-Admins (globaler Portal-Mandant).
|
||||
|
|
@ -8,6 +8,8 @@ export default function AdminPageNav() {
|
|||
const pages = [
|
||||
{ to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine },
|
||||
{ to: '/admin/users', label: 'Nutzer', icon: Users },
|
||||
{ to: '/admin/club-creation-requests', label: 'Vereinsgründungen', icon: Building2 },
|
||||
{ to: '/admin/rights', label: 'Rollen & Rechte', icon: Shield },
|
||||
{ to: '/admin/user-content', label: 'Nutzer-Inhalte', icon: Activity },
|
||||
{ to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
|
||||
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },
|
||||
|
|
|
|||
|
|
@ -6,11 +6,30 @@ import { useOrgInbox } from '../context/OrgInboxContext'
|
|||
* Desktop-Dashboard: Hinweis auf offene Beitrittsanträge (nur ab 1024px sichtbar via CSS).
|
||||
*/
|
||||
export default function DashboardOrgInboxWidget() {
|
||||
const { canAccessOrgInbox, inboxJoinRequests, inboxCount } = useOrgInbox()
|
||||
const {
|
||||
canShowInboxNav,
|
||||
inboxJoinRequests,
|
||||
inboxClubCreationRequests,
|
||||
clubCreationRequestCount,
|
||||
inboxCount,
|
||||
} = useOrgInbox()
|
||||
|
||||
if (!canAccessOrgInbox) return null
|
||||
if (!canShowInboxNav) return null
|
||||
|
||||
const preview = (inboxJoinRequests || []).slice(0, 5)
|
||||
const preview = [
|
||||
...(inboxClubCreationRequests || []).map((req) => ({
|
||||
key: `creation-${req.id}`,
|
||||
club: req.proposed_name || 'Neuer Verein',
|
||||
applicant: req.applicant_name || req.applicant_email || 'Antragsteller/in',
|
||||
kind: 'creation',
|
||||
})),
|
||||
...(inboxJoinRequests || []).map((req) => ({
|
||||
key: `${req.club_id}-${req.id}`,
|
||||
club: req.club_name || 'Verein',
|
||||
applicant: req.applicant_name || req.applicant_email || 'Bewerber/in',
|
||||
kind: 'join',
|
||||
})),
|
||||
].slice(0, 5)
|
||||
|
||||
return (
|
||||
<section
|
||||
|
|
@ -31,17 +50,27 @@ export default function DashboardOrgInboxWidget() {
|
|||
</div>
|
||||
<p className="muted dashboard-org-inbox-widget__lead">
|
||||
{inboxCount === 0
|
||||
? 'Keine offenen Beitrittsanträge.'
|
||||
: `${inboxCount} offene Beitrittsantrag${inboxCount === 1 ? '' : 'e'}.`}
|
||||
? 'Keine offenen Anträge.'
|
||||
: [
|
||||
clubCreationRequestCount > 0
|
||||
? `${clubCreationRequestCount} Gründungsantrag${clubCreationRequestCount === 1 ? '' : 'e'}`
|
||||
: null,
|
||||
(inboxJoinRequests || []).length > 0
|
||||
? `${(inboxJoinRequests || []).length} Beitrittsantrag${(inboxJoinRequests || []).length === 1 ? '' : 'e'}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
{preview.length > 0 ? (
|
||||
<ul className="dashboard-org-inbox-widget__list">
|
||||
{preview.map((req) => (
|
||||
<li key={`${req.club_id}-${req.id}`} className="dashboard-org-inbox-widget__item">
|
||||
<span className="dashboard-org-inbox-widget__club">{req.club_name || 'Verein'}</span>
|
||||
<span className="dashboard-org-inbox-widget__applicant">
|
||||
{req.applicant_name || req.applicant_email || 'Bewerber/in'}
|
||||
<li key={req.key} className="dashboard-org-inbox-widget__item">
|
||||
<span className="dashboard-org-inbox-widget__club">
|
||||
{req.kind === 'creation' ? 'Gründung: ' : ''}
|
||||
{req.club}
|
||||
</span>
|
||||
<span className="dashboard-org-inbox-widget__applicant">{req.applicant}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -14,12 +14,16 @@ function sidebarLinkActive(pathname, item, routerIsActive) {
|
|||
*/
|
||||
export default function DesktopSidebar({
|
||||
showAdminNav,
|
||||
onboardingOnly = false,
|
||||
user,
|
||||
onLogout
|
||||
}) {
|
||||
const loc = useLocation()
|
||||
const { canAccessOrgInbox, inboxCount } = useOrgInbox()
|
||||
const items = getMainNavItems(showAdminNav, { showInbox: canAccessOrgInbox })
|
||||
const { canShowInboxNav, inboxCount } = useOrgInbox()
|
||||
const items = getMainNavItems(showAdminNav, {
|
||||
showInbox: canShowInboxNav,
|
||||
onboardingOnly,
|
||||
})
|
||||
const tier = user?.tier || ''
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export default function ExerciseAiSuggestPreviewModal({
|
|||
applyLabel = 'Übung anlegen',
|
||||
applyDisabled = false,
|
||||
zIndex = 2000,
|
||||
planningContextLines = [],
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!draft) return undefined
|
||||
|
|
@ -86,6 +87,31 @@ export default function ExerciseAiSuggestPreviewModal({
|
|||
<h3 style={{ marginTop: 0, fontSize: '1.1rem', marginBottom: '6px' }}>{dialogTitle}</h3>
|
||||
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0, marginBottom: '16px' }}>{hint}</p>
|
||||
|
||||
{Array.isArray(planningContextLines) && planningContextLines.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '16px',
|
||||
padding: '10px 12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid color-mix(in srgb, var(--accent) 35%, var(--border))',
|
||||
background: 'color-mix(in srgb, var(--accent) 6%, var(--surface))',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
<strong style={{ display: 'block', marginBottom: '6px', fontSize: '13px' }}>
|
||||
Planungskontext (an KI übergeben)
|
||||
</strong>
|
||||
<dl style={{ margin: 0, display: 'grid', gap: '6px' }}>
|
||||
{planningContextLines.map(({ label, value }) => (
|
||||
<div key={label}>
|
||||
<dt style={{ margin: 0, fontSize: '11px', color: 'var(--text3)' }}>{label}</dt>
|
||||
<dd style={{ margin: '2px 0 0', lineHeight: 1.45, color: 'var(--text2)' }}>{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ display: 'grid', gap: '12px', marginBottom: '18px' }}>
|
||||
<div>
|
||||
<label className="form-label" htmlFor="ai-draft-title">
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
aiPreviewToQuickCreateDraft,
|
||||
} from '../utils/exerciseAiQuickCreate'
|
||||
import { resolveExercisePickVariantId } from '../utils/exercisePlanningPick'
|
||||
import { buildPickerPlanningContextForAi } from '../utils/planningContextForExerciseAi'
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
/** Backend POST /api/planning/exercise-suggest erlaubt max. 50 */
|
||||
|
|
@ -707,6 +708,12 @@ export default function ExercisePickerModal({
|
|||
|
||||
setQuickAiError('')
|
||||
setQuickCreateDraft(null)
|
||||
const planningContextPayload = buildPickerPlanningContextForAi({
|
||||
planningContextSummary,
|
||||
planningContext,
|
||||
searchQuery: planningSubmittedQuery || searchInput || aiSearchInput,
|
||||
})
|
||||
|
||||
setQuickSaving(true)
|
||||
try {
|
||||
const aiRes = await api.suggestExerciseAi({
|
||||
|
|
@ -717,6 +724,7 @@ export default function ExercisePickerModal({
|
|||
trainer_notes: '',
|
||||
focus_area_hint: focusHint || undefined,
|
||||
focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
|
||||
planning_context: planningContextPayload || undefined,
|
||||
include_summary: true,
|
||||
include_skills: true,
|
||||
include_instructions: true,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
67
frontend/src/components/FeatureUsageBadge.jsx
Normal file
67
frontend/src/components/FeatureUsageBadge.jsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { useEntitlements } from '../context/EntitlementsContext'
|
||||
|
||||
/**
|
||||
* Zeigt Vereins-Kontingent für ein Feature (M4 UsageBadge).
|
||||
* Unbegrenzt (limit null) → nichts rendern.
|
||||
*/
|
||||
export default function FeatureUsageBadge({ featureId = 'ai_calls', label = 'KI-Kontingent' }) {
|
||||
const { entitlements, loading, error, getFeature } = useEntitlements()
|
||||
const feat = getFeature(featureId)
|
||||
|
||||
if (loading && !feat) {
|
||||
return (
|
||||
<span className="feature-usage-badge muted" style={{ fontSize: '0.8rem' }}>
|
||||
{label}: …
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (!feat) {
|
||||
if (error) {
|
||||
return (
|
||||
<span
|
||||
className="feature-usage-badge muted"
|
||||
style={{ fontSize: '0.8rem', color: 'var(--text3)' }}
|
||||
title={error}
|
||||
>
|
||||
{label}: —
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const { used = 0, limit, remaining, allowed, platform_exempt: platformExempt, reason } = feat
|
||||
|
||||
if (platformExempt || reason === 'platform_exempt' || reason === 'capability_quota_bypass') {
|
||||
return (
|
||||
<span
|
||||
className="feature-usage-badge"
|
||||
style={{ fontSize: '0.8rem', color: 'var(--accent-dark)' }}
|
||||
title="Plattform-Ausnahme: zählt nicht gegen das Vereins-Kontingent"
|
||||
>
|
||||
{label}: Plattform (unbegrenzt)
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// limit === 0 (z. B. Free-Plan ai_calls) anzeigen; nur echtes Unbegrenzt (null) ausblenden
|
||||
if (limit == null) return null
|
||||
|
||||
const tone = !allowed || remaining === 0 ? 'var(--danger)' : 'var(--text2)'
|
||||
|
||||
return (
|
||||
<span
|
||||
className="feature-usage-badge"
|
||||
style={{ fontSize: '0.8rem', color: tone }}
|
||||
title={
|
||||
entitlements?.plan_id
|
||||
? `Plan: ${entitlements.plan_id}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{label}: {used}/{limit}
|
||||
{remaining != null ? ` (${remaining} übrig)` : ''}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import { stripHtmlToText } from '../../utils/htmlUtils'
|
|||
import ExerciseCatalogAssocEditor from './ExerciseCatalogAssocEditor'
|
||||
import ExerciseSkillsEditor from './ExerciseSkillsEditor'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import FeatureUsageBadge from '../FeatureUsageBadge'
|
||||
import { useToast } from '../../context/ToastContext'
|
||||
import {
|
||||
activeClubMemberships,
|
||||
|
|
@ -1653,15 +1654,18 @@ function ExerciseFormPageRoot() {
|
|||
<label className="form-label" style={{ marginBottom: 0 }}>
|
||||
Kurzbeschreibung
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '12px' }}
|
||||
disabled={aiSuggestBusy}
|
||||
onClick={() => runExerciseAiSuggestion('summary')}
|
||||
>
|
||||
KI: Kurzfassung
|
||||
</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<FeatureUsageBadge featureId="ai_calls" />
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '12px' }}
|
||||
disabled={aiSuggestBusy}
|
||||
onClick={() => runExerciseAiSuggestion('summary')}
|
||||
>
|
||||
KI: Kurzfassung
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<RichTextEditor
|
||||
value={formData.summary}
|
||||
|
|
|
|||
|
|
@ -31,8 +31,19 @@ function baseItems(opts = {}) {
|
|||
return items
|
||||
}
|
||||
|
||||
/** @param {boolean} isAdmin @param {{ showInbox?: boolean }} opts */
|
||||
/** Nav für Onboarding (ohne Vereinsmitgliedschaft). */
|
||||
export function getOnboardingNavItems() {
|
||||
return [
|
||||
{ to: '/onboarding', label: 'Verein', shortLabel: 'Verein', end: true, Icon: Building2 },
|
||||
{ to: '/settings', label: 'Einstellungen', shortLabel: 'Einst.', Icon: Settings },
|
||||
]
|
||||
}
|
||||
|
||||
/** @param {boolean} isAdmin @param {{ showInbox?: boolean, onboardingOnly?: boolean }} opts */
|
||||
export function getMainNavItems(isAdmin, opts = {}) {
|
||||
if (opts.onboardingOnly) {
|
||||
return getOnboardingNavItems()
|
||||
}
|
||||
const showInbox = !!opts.showInbox
|
||||
const icons = [
|
||||
LayoutDashboard,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
} from 'react'
|
||||
import api, { ACTIVE_CLUB_STORAGE_KEY } from '../utils/api'
|
||||
import { activeClubMemberships } from '../utils/activeClub'
|
||||
import { clearCoachSessionStorage } from '../utils/trainingPlanUtils'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
|
|
@ -122,15 +123,16 @@ export function AuthProvider({ children }) {
|
|||
setUser(payload)
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(() => {
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await api.logout()
|
||||
} catch {
|
||||
/* Session lokal trotzdem beenden */
|
||||
}
|
||||
setUser(null)
|
||||
localStorage.removeItem('authToken')
|
||||
localStorage.removeItem(ACTIVE_CLUB_STORAGE_KEY)
|
||||
for (const key of Object.keys(sessionStorage)) {
|
||||
if (key.startsWith('sj_coach_')) {
|
||||
sessionStorage.removeItem(key)
|
||||
}
|
||||
}
|
||||
clearCoachSessionStorage()
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
|
|
|
|||
109
frontend/src/context/EntitlementsContext.jsx
Normal file
109
frontend/src/context/EntitlementsContext.jsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { getMeEntitlements } from '../utils/api'
|
||||
import {
|
||||
getDefaultClubIdForGovernanceForms,
|
||||
getResolvedActiveClubIdForUi,
|
||||
} from '../utils/activeClub'
|
||||
import {
|
||||
registerFeatureUsageSyncHandler,
|
||||
unregisterFeatureUsageSyncHandler,
|
||||
} from '../utils/featureUsageSync'
|
||||
import { useAuth } from './AuthContext'
|
||||
|
||||
const EntitlementsContext = createContext(null)
|
||||
|
||||
function mergeFeatureUsage(entitlements, featureUsage) {
|
||||
if (!entitlements || !featureUsage) return entitlements
|
||||
const features = { ...entitlements.features }
|
||||
for (const [fid, row] of Object.entries(featureUsage)) {
|
||||
if (row) features[fid] = { ...features[fid], ...row }
|
||||
}
|
||||
return { ...entitlements, features }
|
||||
}
|
||||
|
||||
export function EntitlementsProvider({ children }) {
|
||||
const { user, isAuthenticated, loading: authLoading } = useAuth()
|
||||
const [entitlements, setEntitlements] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const clubId =
|
||||
getResolvedActiveClubIdForUi(user) ?? getDefaultClubIdForGovernanceForms(user)
|
||||
|
||||
const refreshEntitlements = useCallback(async () => {
|
||||
if (!isAuthenticated) {
|
||||
setEntitlements(null)
|
||||
setError(null)
|
||||
return null
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await getMeEntitlements(clubId)
|
||||
setEntitlements(data)
|
||||
return data
|
||||
} catch (e) {
|
||||
setEntitlements(null)
|
||||
setError(e?.message || String(e))
|
||||
return null
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [isAuthenticated, clubId])
|
||||
|
||||
const refreshEntitlementsQuiet = useCallback(async () => {
|
||||
if (!isAuthenticated) return null
|
||||
try {
|
||||
const data = await getMeEntitlements(clubId)
|
||||
setEntitlements(data)
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [isAuthenticated, clubId])
|
||||
|
||||
const applyFeatureUsageFromResponse = useCallback(
|
||||
async (apiResponse) => {
|
||||
if (apiResponse?.feature_usage) {
|
||||
setEntitlements((prev) => mergeFeatureUsage(prev, apiResponse.feature_usage))
|
||||
}
|
||||
return refreshEntitlementsQuiet()
|
||||
},
|
||||
[refreshEntitlementsQuiet],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
registerFeatureUsageSyncHandler(applyFeatureUsageFromResponse)
|
||||
return () => unregisterFeatureUsageSyncHandler()
|
||||
}, [applyFeatureUsageFromResponse])
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return
|
||||
refreshEntitlements()
|
||||
}, [authLoading, refreshEntitlements])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
entitlements,
|
||||
loading,
|
||||
error,
|
||||
refreshEntitlements,
|
||||
refreshEntitlementsQuiet,
|
||||
hasCapability: (capId) => Boolean(entitlements?.capabilities?.[capId]),
|
||||
getFeature: (featureId) => entitlements?.features?.[featureId] ?? null,
|
||||
}),
|
||||
[entitlements, loading, error, refreshEntitlements, refreshEntitlementsQuiet],
|
||||
)
|
||||
|
||||
return (
|
||||
<EntitlementsContext.Provider value={value}>{children}</EntitlementsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useEntitlements() {
|
||||
const ctx = useContext(EntitlementsContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useEntitlements must be used within EntitlementsProvider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
|
@ -17,6 +17,11 @@ export function canAccessOrgInbox(user) {
|
|||
return activeClubMemberships(user.clubs).some((c) => (c.roles || []).includes('club_admin'))
|
||||
}
|
||||
|
||||
/** Gründungsanträge freigeben — aktuell nur Superadmin (platform.club_creation.approve). */
|
||||
export function canAccessClubCreationInbox(user) {
|
||||
return user?.role === 'superadmin'
|
||||
}
|
||||
|
||||
function canSeeContentReports(user) {
|
||||
if (user?.role === 'admin' || user?.role === 'superadmin') return true
|
||||
return activeClubMemberships(user?.clubs || []).some((c) => (c.roles || []).includes('club_admin'))
|
||||
|
|
@ -28,8 +33,13 @@ export function notifyOrgInboxChanged() {
|
|||
}
|
||||
|
||||
/** Eine konsistente Ladepfad-Logik für Join-Requests + Content-Reports (ein Codepfad für Mount + refresh). */
|
||||
async function fetchOrgInboxSnapshot(canAccess, canAccessReports) {
|
||||
const out = { items: [], contentReports: [], contentReportsError: null }
|
||||
async function fetchOrgInboxSnapshot(canAccess, canAccessReports, canAccessClubCreation) {
|
||||
const out = {
|
||||
items: [],
|
||||
clubCreationRequests: [],
|
||||
contentReports: [],
|
||||
contentReportsError: null,
|
||||
}
|
||||
if (canAccess) {
|
||||
try {
|
||||
const data = await api.getInboxJoinRequests()
|
||||
|
|
@ -38,6 +48,14 @@ async function fetchOrgInboxSnapshot(canAccess, canAccessReports) {
|
|||
out.items = []
|
||||
}
|
||||
}
|
||||
if (canAccessClubCreation) {
|
||||
try {
|
||||
const data = await api.listAdminClubCreationRequests()
|
||||
out.clubCreationRequests = Array.isArray(data) ? data : []
|
||||
} catch {
|
||||
out.clubCreationRequests = []
|
||||
}
|
||||
}
|
||||
if (canAccessReports) {
|
||||
try {
|
||||
const data = await api.getInboxContentReports()
|
||||
|
|
@ -52,27 +70,33 @@ async function fetchOrgInboxSnapshot(canAccess, canAccessReports) {
|
|||
|
||||
export function OrgInboxProvider({ user, children }) {
|
||||
const [items, setItems] = useState([])
|
||||
const [clubCreationRequests, setClubCreationRequests] = useState([])
|
||||
const [contentReports, setContentReports] = useState([])
|
||||
const [contentReportsError, setContentReportsError] = useState(null)
|
||||
const canAccess = useMemo(() => canAccessOrgInbox(user), [user])
|
||||
const canAccessReports = useMemo(() => canSeeContentReports(user), [user])
|
||||
const canAccessClubCreation = useMemo(() => canAccessClubCreationInbox(user), [user])
|
||||
const hasInboxAccess = canAccess || canAccessReports || canAccessClubCreation
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!canAccess && !canAccessReports) {
|
||||
if (!hasInboxAccess) {
|
||||
setItems([])
|
||||
setClubCreationRequests([])
|
||||
setContentReports([])
|
||||
setContentReportsError(null)
|
||||
return
|
||||
}
|
||||
const snap = await fetchOrgInboxSnapshot(canAccess, canAccessReports)
|
||||
const snap = await fetchOrgInboxSnapshot(canAccess, canAccessReports, canAccessClubCreation)
|
||||
setItems(snap.items)
|
||||
setClubCreationRequests(snap.clubCreationRequests)
|
||||
setContentReports(snap.contentReports)
|
||||
setContentReportsError(canAccessReports ? snap.contentReportsError : null)
|
||||
}, [canAccess, canAccessReports])
|
||||
}, [hasInboxAccess, canAccess, canAccessReports, canAccessClubCreation])
|
||||
|
||||
useEffect(() => {
|
||||
if (!canAccess && !canAccessReports) {
|
||||
if (!hasInboxAccess) {
|
||||
setItems([])
|
||||
setClubCreationRequests([])
|
||||
setContentReports([])
|
||||
setContentReportsError(null)
|
||||
return undefined
|
||||
|
|
@ -82,9 +106,10 @@ export function OrgInboxProvider({ user, children }) {
|
|||
let timeoutId = null
|
||||
|
||||
const load = async () => {
|
||||
const snap = await fetchOrgInboxSnapshot(canAccess, canAccessReports)
|
||||
const snap = await fetchOrgInboxSnapshot(canAccess, canAccessReports, canAccessClubCreation)
|
||||
if (cancelled) return
|
||||
setItems(snap.items)
|
||||
setClubCreationRequests(snap.clubCreationRequests)
|
||||
setContentReports(snap.contentReports)
|
||||
setContentReportsError(canAccessReports ? snap.contentReportsError : null)
|
||||
}
|
||||
|
|
@ -116,7 +141,7 @@ export function OrgInboxProvider({ user, children }) {
|
|||
}
|
||||
if (timeoutId != null) window.clearTimeout(timeoutId)
|
||||
}
|
||||
}, [canAccess, canAccessReports, user?.id])
|
||||
}, [hasInboxAccess, canAccess, canAccessReports, canAccessClubCreation, user?.id])
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => { refresh() }
|
||||
|
|
@ -124,21 +149,42 @@ export function OrgInboxProvider({ user, children }) {
|
|||
return () => window.removeEventListener('shinkan:inbox-changed', onChange)
|
||||
}, [refresh])
|
||||
|
||||
const clubCreationCount = clubCreationRequests.length
|
||||
const joinCount = items.length
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
inboxJoinRequests: items,
|
||||
inboxCount: items.length,
|
||||
inboxClubCreationRequests: clubCreationRequests,
|
||||
clubCreationRequestCount: clubCreationCount,
|
||||
inboxCount: joinCount + clubCreationCount,
|
||||
contentReports,
|
||||
contentReportCount: contentReports.filter((r) => r.status === 'submitted').length,
|
||||
contentReportsError,
|
||||
refreshOrgInbox: refresh,
|
||||
canAccessOrgInbox: canAccess,
|
||||
canAccessContentReports: canAccessReports,
|
||||
canAccessClubCreationInbox: canAccessClubCreation,
|
||||
canShowInboxNav: hasInboxAccess,
|
||||
isSuperadmin: user?.role === 'superadmin',
|
||||
isPlatformAdmin: user?.role === 'admin' || user?.role === 'superadmin',
|
||||
isClubAdmin: activeClubMemberships(user?.clubs || []).some((c) => (c.roles || []).includes('club_admin')),
|
||||
}),
|
||||
[items, contentReports, contentReportsError, refresh, canAccess, canAccessReports, user?.role, user?.clubs]
|
||||
[
|
||||
items,
|
||||
clubCreationRequests,
|
||||
clubCreationCount,
|
||||
joinCount,
|
||||
contentReports,
|
||||
contentReportsError,
|
||||
refresh,
|
||||
canAccess,
|
||||
canAccessReports,
|
||||
canAccessClubCreation,
|
||||
hasInboxAccess,
|
||||
user?.role,
|
||||
user?.clubs,
|
||||
]
|
||||
)
|
||||
|
||||
return <OrgInboxContext.Provider value={value}>{children}</OrgInboxContext.Provider>
|
||||
|
|
|
|||
169
frontend/src/pages/AdminClubCreationRequestsPage.jsx
Normal file
169
frontend/src/pages/AdminClubCreationRequestsPage.jsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../utils/api'
|
||||
import AdminPageNav from '../components/AdminPageNav'
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '—'
|
||||
try {
|
||||
return new Date(value).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Superadmin: offene Anträge auf Vereinsgründung freigeben oder ablehnen.
|
||||
*/
|
||||
export default function AdminClubCreationRequestsPage() {
|
||||
const { user } = useAuth()
|
||||
const isSuperadmin = user?.role === 'superadmin'
|
||||
|
||||
const [requests, setRequests] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [busyId, setBusyId] = useState(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const rows = await api.listAdminClubCreationRequests()
|
||||
setRequests(Array.isArray(rows) ? rows : [])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuperadmin) return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await load()
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e.message || String(e))
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isSuperadmin, load])
|
||||
|
||||
if (!isSuperadmin) return <Navigate to="/" replace />
|
||||
|
||||
const handleApprove = async (id) => {
|
||||
if (!confirm('Verein anlegen und Antragsteller als Hauptverwalter eintragen?')) return
|
||||
setBusyId(id)
|
||||
setError('')
|
||||
try {
|
||||
await api.approveClubCreationRequest(id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(e.message || String(e))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReject = async (id) => {
|
||||
if (!confirm('Gründungsantrag wirklich ablehnen?')) return
|
||||
setBusyId(id)
|
||||
setError('')
|
||||
try {
|
||||
await api.rejectClubCreationRequest(id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(e.message || String(e))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-padding app-page">
|
||||
<AdminPageNav />
|
||||
<h1 style={{ marginTop: '1rem', fontSize: '1.35rem' }}>Vereinsgründungen</h1>
|
||||
<p style={{ color: 'var(--text2)', maxWidth: '42rem', lineHeight: 1.5 }}>
|
||||
Offene Anträge von verifizierten Nutzern ohne Vereinsmitgliedschaft. Bei Freigabe wird ein
|
||||
neuer Verein mit Free-Abo angelegt; der Antragsteller wird Vereinsadmin und Trainer.
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
<p role="alert" style={{ color: 'var(--danger)' }}>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="spinner" style={{ marginTop: '1rem' }}>
|
||||
Laden…
|
||||
</p>
|
||||
) : requests.length === 0 ? (
|
||||
<div className="card" style={{ marginTop: '1rem' }}>
|
||||
<p style={{ margin: 0, color: 'var(--text2)' }}>Keine offenen Gründungsanträge.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', marginTop: '1rem' }}>
|
||||
{requests.map((r) => (
|
||||
<div key={r.id} className="card">
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem 1rem', marginBottom: '0.5rem' }}>
|
||||
<strong>{r.proposed_name}</strong>
|
||||
{r.proposed_abbreviation ? (
|
||||
<span style={{ color: 'var(--text2)' }}>({r.proposed_abbreviation})</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p style={{ margin: '0 0 0.35rem', fontSize: '0.9rem', color: 'var(--text2)' }}>
|
||||
Antragsteller: {r.applicant_name || '—'}{' '}
|
||||
{r.applicant_email ? `· ${r.applicant_email}` : ''}
|
||||
</p>
|
||||
<p style={{ margin: '0 0 0.35rem', fontSize: '0.85rem', color: 'var(--text3)' }}>
|
||||
Eingereicht: {formatDate(r.created_at)}
|
||||
</p>
|
||||
{r.proposed_description ? (
|
||||
<p style={{ margin: '0.5rem 0', fontSize: '0.9rem', whiteSpace: 'pre-wrap' }}>
|
||||
{r.proposed_description}
|
||||
</p>
|
||||
) : null}
|
||||
{r.message ? (
|
||||
<p
|
||||
style={{
|
||||
margin: '0.5rem 0 0',
|
||||
fontSize: '0.875rem',
|
||||
color: 'var(--text2)',
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
Nachricht: {r.message}
|
||||
</p>
|
||||
) : null}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.85rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busyId === r.id}
|
||||
onClick={() => handleApprove(r.id)}
|
||||
>
|
||||
{busyId === r.id ? '…' : 'Freigeben'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busyId === r.id}
|
||||
onClick={() => handleReject(r.id)}
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
753
frontend/src/pages/AdminRightsPage.jsx
Normal file
753
frontend/src/pages/AdminRightsPage.jsx
Normal file
|
|
@ -0,0 +1,753 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../utils/api'
|
||||
import AdminPageNav from '../components/AdminPageNav'
|
||||
|
||||
const TABS = [
|
||||
{ id: 'portal', label: 'Portal-Rollen' },
|
||||
{ id: 'club_roles', label: 'Vereinsrollen' },
|
||||
{ id: 'bypass', label: 'Kontingent-Bypass' },
|
||||
{ id: 'quotas', label: 'Vereins-Kontingente' },
|
||||
]
|
||||
|
||||
const PORTAL_ROLE_LABEL = {
|
||||
user: 'Nutzer',
|
||||
trainer: 'Portal-Trainer',
|
||||
admin: 'Portal-Admin',
|
||||
superadmin: 'Superadmin',
|
||||
}
|
||||
|
||||
const CLUB_ROLE_LABEL = {
|
||||
club_admin: 'Vereinsadmin',
|
||||
trainer: 'Trainer',
|
||||
division_lead: 'Spartenleitung',
|
||||
content_editor: 'Inhalte',
|
||||
}
|
||||
|
||||
function limitInputValue(v) {
|
||||
if (v === null || v === undefined) return ''
|
||||
return String(v)
|
||||
}
|
||||
|
||||
function parseLimitInput(raw, limitType) {
|
||||
const s = String(raw ?? '').trim()
|
||||
if (s === '' || s === '∞') return null
|
||||
const n = parseInt(s, 10)
|
||||
if (Number.isNaN(n)) return limitType === 'boolean' ? 0 : null
|
||||
return n
|
||||
}
|
||||
|
||||
function formatLimitHint(feature) {
|
||||
if (feature.limit_type === 'boolean') return '0 = aus, 1 = an'
|
||||
if (feature.reset_period === 'monthly') return 'pro Monat'
|
||||
if (feature.reset_period === 'never') return 'Bestand'
|
||||
return ''
|
||||
}
|
||||
|
||||
function EnforcementBadge({ enforcement, featureConsume }) {
|
||||
if (!enforcement) return null
|
||||
const tone =
|
||||
enforcement.implemented
|
||||
? 'var(--accent-dark)'
|
||||
: enforcement.level === 'legacy'
|
||||
? 'var(--danger)'
|
||||
: 'var(--text3)'
|
||||
return (
|
||||
<div style={{ marginTop: '4px', fontSize: '0.68rem', lineHeight: 1.35 }}>
|
||||
<span style={{ color: tone }} title={enforcement.detail}>
|
||||
{enforcement.implemented ? '● ' : '○ '}
|
||||
{enforcement.label}
|
||||
</span>
|
||||
{featureConsume ? (
|
||||
<div style={{ color: featureConsume.implemented ? 'var(--accent-dark)' : 'var(--text3)' }}>
|
||||
Kontingent: {featureConsume.label}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CapabilityNameCell({ cap }) {
|
||||
return (
|
||||
<td style={{ padding: '6px', verticalAlign: 'top' }}>
|
||||
<div style={{ fontWeight: 500, color: 'var(--text1)' }}>{cap.name || cap.id}</div>
|
||||
<code style={{ fontSize: '0.68rem', color: 'var(--text3)' }}>{cap.id}</code>
|
||||
{cap.linked_feature_id ? (
|
||||
<div style={{ color: 'var(--text3)', fontSize: '0.68rem' }}>
|
||||
Kontingent-ID: {cap.linked_feature_id}
|
||||
</div>
|
||||
) : null}
|
||||
<EnforcementBadge enforcement={cap.enforcement} featureConsume={cap.feature_consume} />
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
function clubGrantsForCapability(capMatrix, capabilityId) {
|
||||
return (capMatrix?.club_role_grants || []).filter((g) => g.capability_id === capabilityId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Superadmin: Rollen → Fähigkeiten (Capabilities) und Vereins-Kontingente konfigurieren.
|
||||
*/
|
||||
export default function AdminRightsPage() {
|
||||
const { user } = useAuth()
|
||||
const isSuperadmin = user?.role === 'superadmin'
|
||||
|
||||
const [tab, setTab] = useState('portal')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const [plansData, setPlansData] = useState({ plans: [], features: [], limits: {} })
|
||||
const [limitDraft, setLimitDraft] = useState({})
|
||||
const [clubSubs, setClubSubs] = useState([])
|
||||
const [capMatrix, setCapMatrix] = useState(null)
|
||||
const [bypassData, setBypassData] = useState(null)
|
||||
|
||||
const [newBypassPortal, setNewBypassPortal] = useState({ portal_role: 'helpdesk', feature_id: '' })
|
||||
const [newBypassProfile, setNewBypassProfile] = useState({
|
||||
profile_id: '',
|
||||
feature_id: '',
|
||||
reason: '',
|
||||
})
|
||||
|
||||
const loadPlans = useCallback(async () => {
|
||||
const data = await api.getAdminRightsClubPlansMatrix()
|
||||
setPlansData(data)
|
||||
const draft = {}
|
||||
for (const plan of data.plans || []) {
|
||||
draft[plan.id] = {}
|
||||
for (const f of data.features || []) {
|
||||
draft[plan.id][f.id] = limitInputValue(data.limits?.[plan.id]?.[f.id])
|
||||
}
|
||||
}
|
||||
setLimitDraft(draft)
|
||||
}, [])
|
||||
|
||||
const loadClubs = useCallback(async () => {
|
||||
const rows = await api.listAdminRightsClubSubscriptions()
|
||||
setClubSubs(Array.isArray(rows) ? rows : [])
|
||||
}, [])
|
||||
|
||||
const loadCapMatrix = useCallback(async () => {
|
||||
setCapMatrix(await api.getAdminRightsCapabilityMatrix())
|
||||
}, [])
|
||||
|
||||
const loadBypass = useCallback(async () => {
|
||||
setBypassData(await api.listAdminRightsQuotaBypass())
|
||||
}, [])
|
||||
|
||||
const reloadTab = useCallback(async () => {
|
||||
setError('')
|
||||
if (tab === 'portal' || tab === 'club_roles') await loadCapMatrix()
|
||||
else if (tab === 'bypass') await loadBypass()
|
||||
else if (tab === 'quotas') await Promise.all([loadPlans(), loadClubs()])
|
||||
}, [tab, loadPlans, loadClubs, loadCapMatrix, loadBypass])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuperadmin) return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await reloadTab()
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e.message || String(e))
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isSuperadmin, reloadTab])
|
||||
|
||||
const portalCapabilities = useMemo(() => {
|
||||
if (!capMatrix?.capabilities) return []
|
||||
return capMatrix.capabilities.filter(
|
||||
(c) => c.domain === 'platform' || String(c.id).startsWith('platform.'),
|
||||
)
|
||||
}, [capMatrix])
|
||||
|
||||
const clubScopedCapabilities = useMemo(() => {
|
||||
if (!capMatrix?.capabilities) return []
|
||||
return capMatrix.capabilities.filter(
|
||||
(c) =>
|
||||
c.domain !== 'platform' &&
|
||||
c.domain !== 'quota_bypass' &&
|
||||
c.domain !== 'account' &&
|
||||
c.domain !== 'club',
|
||||
)
|
||||
}, [capMatrix])
|
||||
|
||||
const portalGrantSet = useMemo(() => {
|
||||
const s = new Set()
|
||||
for (const g of capMatrix?.portal_grants || []) {
|
||||
s.add(`${g.portal_role}::${g.capability_id}`)
|
||||
}
|
||||
return s
|
||||
}, [capMatrix])
|
||||
|
||||
const clubGrantSet = useMemo(() => {
|
||||
const s = new Set()
|
||||
for (const g of capMatrix?.club_role_grants || []) {
|
||||
s.add(`${g.role_code}::${g.capability_id}`)
|
||||
}
|
||||
return s
|
||||
}, [capMatrix])
|
||||
|
||||
if (!isSuperadmin) return <Navigate to="/" replace />
|
||||
|
||||
const savePlanLimits = async (planId) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
const limits = (plansData.features || []).map((f) => ({
|
||||
feature_id: f.id,
|
||||
limit_value: parseLimitInput(limitDraft[planId]?.[f.id], f.limit_type),
|
||||
}))
|
||||
await api.updateAdminRightsClubPlanLimits(planId, limits)
|
||||
await loadPlans()
|
||||
} catch (e) {
|
||||
setError(e.message || String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveClubPlan = async (clubId, planId, status) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.updateAdminRightsClubSubscription(clubId, { plan_id: planId, status })
|
||||
await loadClubs()
|
||||
} catch (e) {
|
||||
setError(e.message || String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const togglePortalGrant = async (portalRole, capabilityId, hasGrant) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
if (hasGrant) {
|
||||
await api.deleteAdminRightsPortalGrant(portalRole, capabilityId)
|
||||
} else {
|
||||
await api.addAdminRightsPortalGrant(portalRole, capabilityId)
|
||||
}
|
||||
await loadCapMatrix()
|
||||
} catch (e) {
|
||||
setError(e.message || String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleClubGrant = async (roleCode, capabilityId, hasGrant) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
if (hasGrant) {
|
||||
await api.deleteAdminRightsClubRoleGrant(roleCode, capabilityId)
|
||||
} else {
|
||||
await api.addAdminRightsClubRoleGrant(roleCode, capabilityId)
|
||||
}
|
||||
await loadCapMatrix()
|
||||
} catch (e) {
|
||||
setError(e.message || String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openClubCapabilityForAllMembers = async (capabilityId) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.clearAdminRightsClubCapabilityGrants(capabilityId)
|
||||
await loadCapMatrix()
|
||||
} catch (e) {
|
||||
setError(e.message || String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const submitBypassPortal = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.addAdminRightsQuotaBypassPortal(
|
||||
newBypassPortal.portal_role.trim(),
|
||||
newBypassPortal.feature_id.trim() || null,
|
||||
)
|
||||
setNewBypassPortal({ portal_role: 'helpdesk', feature_id: '' })
|
||||
await loadBypass()
|
||||
} catch (err) {
|
||||
setError(err.message || String(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const submitBypassProfile = async (e) => {
|
||||
e.preventDefault()
|
||||
const pid = parseInt(newBypassProfile.profile_id, 10)
|
||||
if (!pid) {
|
||||
setError('Profil-ID erforderlich')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.addAdminRightsQuotaBypassProfile(
|
||||
pid,
|
||||
newBypassProfile.feature_id.trim() || null,
|
||||
newBypassProfile.reason.trim() || null,
|
||||
)
|
||||
setNewBypassProfile({ profile_id: '', feature_id: '', reason: '' })
|
||||
await loadBypass()
|
||||
} catch (err) {
|
||||
setError(err.message || String(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-padding app-page">
|
||||
<AdminPageNav />
|
||||
<h1 style={{ marginTop: '1rem', fontSize: '1.35rem' }}>Rollen & Rechte</h1>
|
||||
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55 }}>
|
||||
<strong>Rechte:</strong> Wer darf welche Funktion nutzen? Haken = Grant für diese Rolle.
|
||||
<br />
|
||||
<strong>Kontingente:</strong> Wie viel darf ein Verein verbrauchen (an Rechten gekoppelt).
|
||||
<br />
|
||||
<span style={{ fontSize: '0.85rem' }}>
|
||||
Es erscheinen nur <strong>vom Modul registrierte</strong> Rechte (nicht der alte
|
||||
Vollkatalog). ● = an API angebunden · ○ = registriert, Endpoint fehlt noch.{' '}
|
||||
<code>docs/working/RIGHTS_AND_FEATURES_REGISTRY.md</code>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem', marginTop: '1rem' }}
|
||||
role="tablist"
|
||||
>
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === t.id}
|
||||
className={`btn ${tab === t.id ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setTab(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p role="alert" style={{ color: 'var(--danger)', marginTop: '0.75rem' }}>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="spinner" style={{ marginTop: '1rem' }}>
|
||||
Laden…
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!loading && tab === 'portal' && capMatrix ? (
|
||||
<div className="card" style={{ marginTop: '1rem', overflowX: 'auto' }}>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--text2)', margin: '0 0 12px' }}>
|
||||
Plattform-Funktionen — Anzeige primär nach Klartext. Technische ID und
|
||||
Umsetzungsstand darunter.
|
||||
</p>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: '6px', minWidth: '220px' }}>Recht</th>
|
||||
{(capMatrix.portal_roles || []).map((r) => (
|
||||
<th key={r} style={{ textAlign: 'center', padding: '6px' }}>
|
||||
{PORTAL_ROLE_LABEL[r] || r}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{portalCapabilities.map((cap) => (
|
||||
<tr key={cap.id} style={{ borderTop: '1px solid var(--border)' }}>
|
||||
<CapabilityNameCell cap={cap} />
|
||||
{(capMatrix.portal_roles || []).map((role) => {
|
||||
const on = portalGrantSet.has(`${role}::${cap.id}`)
|
||||
return (
|
||||
<td key={role} style={{ textAlign: 'center', padding: '6px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={on}
|
||||
disabled={busy}
|
||||
aria-label={`${cap.id} für ${role}`}
|
||||
onChange={() => togglePortalGrant(role, cap.id, on)}
|
||||
/>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && tab === 'club_roles' && capMatrix ? (
|
||||
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<div className="card" style={{ overflowX: 'auto' }}>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--text2)', margin: '0 0 12px' }}>
|
||||
Vereinsrollen: Alle Rechte in der Matrix. Haken = diese Rolle hat das Recht.
|
||||
Zeile mit <em>alle</em> = noch nicht rollenbeschränkt (gilt für jedes aktive Mitglied).
|
||||
Erster Klick auf <em>alle</em> schränkt auf die gewählte Rolle ein; „Alle Mitglieder“
|
||||
hebt die Einschränkung wieder auf.
|
||||
</p>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: '6px', minWidth: '220px' }}>Recht</th>
|
||||
{(capMatrix.club_roles || []).map((r) => (
|
||||
<th key={r} style={{ textAlign: 'center', padding: '6px' }}>
|
||||
{CLUB_ROLE_LABEL[r] || r}
|
||||
</th>
|
||||
))}
|
||||
<th style={{ textAlign: 'left', padding: '6px', minWidth: '100px' }}>Freigabe</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{clubScopedCapabilities.map((cap) => {
|
||||
const restricted = clubGrantsForCapability(capMatrix, cap.id).length > 0
|
||||
return (
|
||||
<tr key={cap.id} style={{ borderTop: '1px solid var(--border)' }}>
|
||||
<CapabilityNameCell cap={cap} />
|
||||
{(capMatrix.club_roles || []).map((role) => {
|
||||
const on = clubGrantSet.has(`${role}::${cap.id}`)
|
||||
return (
|
||||
<td key={role} style={{ textAlign: 'center', padding: '6px' }}>
|
||||
{restricted ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={on}
|
||||
disabled={busy}
|
||||
aria-label={`${cap.name} für ${CLUB_ROLE_LABEL[role] || role}`}
|
||||
onChange={() => toggleClubGrant(role, cap.id, on)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busy}
|
||||
title={`Standard: alle Mitglieder. Klick = nur ${CLUB_ROLE_LABEL[role] || role}`}
|
||||
style={{ fontSize: '0.7rem', padding: '2px 6px' }}
|
||||
onClick={() => toggleClubGrant(role, cap.id, false)}
|
||||
>
|
||||
alle
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
<td style={{ padding: '6px', whiteSpace: 'nowrap' }}>
|
||||
{restricted ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busy}
|
||||
style={{ fontSize: '0.68rem', padding: '2px 6px' }}
|
||||
onClick={() => openClubCapabilityForAllMembers(cap.id)}
|
||||
>
|
||||
Alle Mitglieder
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && tab === 'bypass' && bypassData ? (
|
||||
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--text2)', margin: 0 }}>
|
||||
Capability <code>platform.club_quota.bypass</code> — umgeht Vereins-Kontingente (z. B.
|
||||
Superadmin, Helpdesk). Kein separates Rechtemodell.
|
||||
</p>
|
||||
<div className="card">
|
||||
<h2 style={{ fontSize: '1rem', margin: '0 0 0.75rem' }}>Portal-Rollen</h2>
|
||||
<ul style={{ margin: 0, paddingLeft: '1.25rem', fontSize: '0.875rem' }}>
|
||||
{(bypassData.portal_role_grants || []).map((g) => (
|
||||
<li key={`${g.portal_role}-${g.capability_id}`} style={{ marginBottom: '6px' }}>
|
||||
<strong>{g.portal_role}</strong> → {g.capability_id}
|
||||
{g.linked_feature_id ? ` (${g.linked_feature_id})` : ' (alle Features)'}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ marginLeft: '8px', fontSize: '0.75rem', padding: '2px 8px' }}
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.deleteAdminRightsQuotaBypassPortal(
|
||||
g.portal_role,
|
||||
g.linked_feature_id || null,
|
||||
)
|
||||
await loadBypass()
|
||||
} catch (err) {
|
||||
setError(err.message || String(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<form onSubmit={submitBypassPortal} style={{ marginTop: '12px' }}>
|
||||
<div className="form-row" style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||
<label style={{ flex: '1 1 120px' }}>
|
||||
<span className="form-label">Portal-Rolle</span>
|
||||
<input
|
||||
className="form-input"
|
||||
value={newBypassPortal.portal_role}
|
||||
onChange={(e) =>
|
||||
setNewBypassPortal((p) => ({ ...p, portal_role: e.target.value }))
|
||||
}
|
||||
placeholder="helpdesk"
|
||||
/>
|
||||
</label>
|
||||
<label style={{ flex: '1 1 160px' }}>
|
||||
<span className="form-label">Feature (leer = alle)</span>
|
||||
<input
|
||||
className="form-input"
|
||||
value={newBypassPortal.feature_id}
|
||||
onChange={(e) =>
|
||||
setNewBypassPortal((p) => ({ ...p, feature_id: e.target.value }))
|
||||
}
|
||||
placeholder="ai_calls"
|
||||
/>
|
||||
</label>
|
||||
<div style={{ alignSelf: 'flex-end' }}>
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
Grant anlegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 style={{ fontSize: '1rem', margin: '0 0 0.75rem' }}>Einzelprofile</h2>
|
||||
<ul style={{ margin: 0, paddingLeft: '1.25rem', fontSize: '0.875rem' }}>
|
||||
{(bypassData.profile_grants || []).map((g) => (
|
||||
<li key={`${g.profile_id}-${g.capability_id}`} style={{ marginBottom: '6px' }}>
|
||||
Profil #{g.profile_id} {g.profile_name ? `(${g.profile_name})` : ''} →{' '}
|
||||
{g.capability_id}
|
||||
{g.reason ? ` — ${g.reason}` : ''}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ marginLeft: '8px', fontSize: '0.75rem', padding: '2px 8px' }}
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.deleteAdminRightsQuotaBypassProfile(
|
||||
g.profile_id,
|
||||
g.linked_feature_id || null,
|
||||
)
|
||||
await loadBypass()
|
||||
} catch (err) {
|
||||
setError(err.message || String(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<form onSubmit={submitBypassProfile} style={{ marginTop: '12px' }}>
|
||||
<div className="form-row" style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||
<label style={{ flex: '0 1 100px' }}>
|
||||
<span className="form-label">Profil-ID</span>
|
||||
<input
|
||||
className="form-input"
|
||||
value={newBypassProfile.profile_id}
|
||||
onChange={(e) =>
|
||||
setNewBypassProfile((p) => ({ ...p, profile_id: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ flex: '1 1 120px' }}>
|
||||
<span className="form-label">Feature (leer = alle)</span>
|
||||
<input
|
||||
className="form-input"
|
||||
value={newBypassProfile.feature_id}
|
||||
onChange={(e) =>
|
||||
setNewBypassProfile((p) => ({ ...p, feature_id: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ flex: '2 1 200px' }}>
|
||||
<span className="form-label">Grund (optional)</span>
|
||||
<input
|
||||
className="form-input"
|
||||
value={newBypassProfile.reason}
|
||||
onChange={(e) =>
|
||||
setNewBypassProfile((p) => ({ ...p, reason: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div style={{ alignSelf: 'flex-end' }}>
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
Grant anlegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && tab === 'quotas' ? (
|
||||
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<div className="card" style={{ overflowX: 'auto' }}>
|
||||
<h2 style={{ fontSize: '1rem', margin: '0 0 0.75rem' }}>Plan-Limits</h2>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--text2)', margin: '0 0 12px' }}>
|
||||
Kontingent-Bündel pro Plan. Leeres Feld = unbegrenzt. Ersetzt keine Rollen-Grants.
|
||||
</p>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: '8px', minWidth: '140px' }}>Feature</th>
|
||||
{(plansData.plans || []).map((p) => (
|
||||
<th key={p.id} style={{ textAlign: 'center', padding: '8px', minWidth: '100px' }}>
|
||||
<div>{p.name}</div>
|
||||
<div style={{ fontWeight: 400, color: 'var(--text3)', fontSize: '0.75rem' }}>
|
||||
{p.id}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ marginTop: '4px', fontSize: '0.75rem', padding: '4px 8px' }}
|
||||
disabled={busy}
|
||||
onClick={() => savePlanLimits(p.id)}
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(plansData.features || []).map((f) => (
|
||||
<tr key={f.id} style={{ borderTop: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '8px' }}>
|
||||
<strong>{f.name}</strong>
|
||||
<div style={{ color: 'var(--text3)', fontSize: '0.75rem' }}>
|
||||
{f.id} · {formatLimitHint(f)}
|
||||
</div>
|
||||
</td>
|
||||
{(plansData.plans || []).map((p) => (
|
||||
<td key={p.id} style={{ padding: '8px', textAlign: 'center' }}>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ width: '72px', textAlign: 'center' }}
|
||||
placeholder="∞"
|
||||
value={limitDraft[p.id]?.[f.id] ?? ''}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
setLimitDraft((prev) => ({
|
||||
...prev,
|
||||
[p.id]: { ...prev[p.id], [f.id]: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ overflowX: 'auto' }}>
|
||||
<h2 style={{ fontSize: '1rem', margin: '0 0 0.75rem' }}>Verein → Plan</h2>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: '8px' }}>Verein</th>
|
||||
<th style={{ textAlign: 'left', padding: '8px' }}>Plan</th>
|
||||
<th style={{ textAlign: 'left', padding: '8px' }}>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{clubSubs.map((row) => (
|
||||
<tr key={row.club_id} style={{ borderTop: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '8px' }}>
|
||||
{row.club_name || `Verein #${row.club_id}`}
|
||||
</td>
|
||||
<td style={{ padding: '8px' }}>
|
||||
<select
|
||||
className="form-input"
|
||||
value={row.plan_id || 'free'}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
saveClubPlan(row.club_id, e.target.value, row.status || 'active')
|
||||
}
|
||||
>
|
||||
{(plansData.plans || []).map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td style={{ padding: '8px' }}>
|
||||
<select
|
||||
className="form-input"
|
||||
value={row.status || 'active'}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
saveClubPlan(row.club_id, row.plan_id || 'free', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="active">aktiv</option>
|
||||
<option value="trial">Test</option>
|
||||
<option value="past_due">überfällig</option>
|
||||
<option value="cancelled">gekündigt</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{clubSubs.length === 0 ? (
|
||||
<p style={{ padding: '12px', color: 'var(--text2)', margin: 0 }}>Keine Vereine.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -324,11 +324,14 @@ export default function InboxPage() {
|
|||
const {
|
||||
canAccessOrgInbox,
|
||||
canAccessContentReports,
|
||||
canAccessClubCreationInbox,
|
||||
canShowInboxNav,
|
||||
isSuperadmin,
|
||||
isPlatformAdmin,
|
||||
isClubAdmin,
|
||||
refreshOrgInbox,
|
||||
inboxJoinRequests,
|
||||
inboxClubCreationRequests,
|
||||
contentReports,
|
||||
contentReportCount,
|
||||
contentReportsError,
|
||||
|
|
@ -339,7 +342,7 @@ export default function InboxPage() {
|
|||
const [showArchive, setShowArchive] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!canAccessOrgInbox && !canAccessContentReports) {
|
||||
if (!canShowInboxNav) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
|
@ -349,13 +352,13 @@ export default function InboxPage() {
|
|||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [canAccessOrgInbox, canAccessContentReports, refreshOrgInbox])
|
||||
}, [canShowInboxNav, refreshOrgInbox])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
if (!canAccessOrgInbox && !canAccessContentReports) {
|
||||
if (!canShowInboxNav) {
|
||||
return (
|
||||
<div className="app-page">
|
||||
<h1 className="page-title">Posteingang</h1>
|
||||
|
|
@ -375,7 +378,7 @@ export default function InboxPage() {
|
|||
Posteingang
|
||||
</h1>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Beitrittsanträge und Inhaltsmeldungen für deine Zuständigkeitsbereiche.
|
||||
Beitrittsanträge, Vereinsgründungen und Inhaltsmeldungen für deine Zuständigkeitsbereiche.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => load()} disabled={loading}>
|
||||
|
|
@ -389,7 +392,107 @@ export default function InboxPage() {
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Abschnitt 1: Beitrittsanträge */}
|
||||
{/* Abschnitt: Vereinsgründungen (nur Superadmin) */}
|
||||
{canAccessClubCreationInbox && (
|
||||
<section style={{ marginBottom: '2rem' }}>
|
||||
<h2 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text2)' }}>
|
||||
Vereinsgründungen
|
||||
{inboxClubCreationRequests.length > 0 && (
|
||||
<span
|
||||
style={{
|
||||
background: 'var(--accent)',
|
||||
color: '#fff',
|
||||
borderRadius: '12px',
|
||||
padding: '1px 8px',
|
||||
fontSize: '0.75rem',
|
||||
marginLeft: '0.5rem',
|
||||
}}
|
||||
>
|
||||
{inboxClubCreationRequests.length}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
{inboxClubCreationRequests.length === 0 ? (
|
||||
<div className="card" style={{ padding: '1.25rem' }}>
|
||||
<p style={{ margin: 0 }} className="muted">Keine offenen Gründungsanträge.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="inbox-page__list">
|
||||
{inboxClubCreationRequests.map((req) => (
|
||||
<div key={`creation-${req.id}`} className="card inbox-request-card">
|
||||
<div className="inbox-request-card__main">
|
||||
<div className="inbox-request-card__club">
|
||||
{req.proposed_name}
|
||||
{req.proposed_abbreviation ? (
|
||||
<span className="muted" style={{ marginLeft: '0.35rem' }}>
|
||||
({req.proposed_abbreviation})
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<strong className="inbox-request-card__applicant">
|
||||
{req.applicant_name || req.applicant_email || 'Antragsteller/in'}
|
||||
</strong>
|
||||
<div className="muted inbox-request-card__meta">
|
||||
{req.applicant_email} · Profil #{req.profile_id} · {formatWhen(req.created_at)}
|
||||
</div>
|
||||
{req.proposed_description ? (
|
||||
<p className="inbox-request-card__message">{req.proposed_description}</p>
|
||||
) : null}
|
||||
{req.message ? (
|
||||
<p className="inbox-request-card__message" style={{ fontStyle: 'italic' }}>
|
||||
Nachricht: {req.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="inbox-request-card__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={async () => {
|
||||
if (
|
||||
!confirm(
|
||||
'Verein anlegen und Antragsteller als Hauptverwalter eintragen?'
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.approveClubCreationRequest(req.id)
|
||||
notifyOrgInboxChanged()
|
||||
await load()
|
||||
} catch (err) {
|
||||
alert(err.message || String(err))
|
||||
}
|
||||
}}
|
||||
>
|
||||
Freigeben
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={async () => {
|
||||
if (!confirm('Gründungsantrag ablehnen?')) return
|
||||
try {
|
||||
await api.rejectClubCreationRequest(req.id)
|
||||
notifyOrgInboxChanged()
|
||||
await load()
|
||||
} catch (err) {
|
||||
alert(err.message || String(err))
|
||||
}
|
||||
}}
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Abschnitt: Beitrittsanträge */}
|
||||
{canAccessOrgInbox && (
|
||||
<section style={{ marginBottom: '2rem' }}>
|
||||
<h2 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text2)' }}>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'
|
|||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../utils/api'
|
||||
import { clearCoachSessionStorage } from '../utils/trainingPlanUtils'
|
||||
|
||||
function LoginPage() {
|
||||
const [mode, setMode] = useState('login') // 'login' or 'register'
|
||||
|
|
@ -18,6 +19,12 @@ function LoginPage() {
|
|||
const navigate = useNavigate()
|
||||
const { checkAuth } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
if (!localStorage.getItem('authToken')) {
|
||||
clearCoachSessionStorage()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'register') return
|
||||
api.listPublicClubsDirectory().then(setPublicClubs).catch(() => setPublicClubs([]))
|
||||
|
|
|
|||
383
frontend/src/pages/OnboardingPage.jsx
Normal file
383
frontend/src/pages/OnboardingPage.jsx
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import EmailVerificationBanner from '../components/EmailVerificationBanner'
|
||||
import { resolveAccountState } from '../utils/accountState'
|
||||
|
||||
const joinStatusLabel = (s) =>
|
||||
({
|
||||
pending: 'ausstehend',
|
||||
accepted: 'angenommen',
|
||||
rejected: 'abgelehnt',
|
||||
withdrawn: 'zurückgezogen',
|
||||
})[s] || s
|
||||
|
||||
const creationStatusLabel = (s) =>
|
||||
({
|
||||
pending: 'ausstehend',
|
||||
approved: 'freigegeben',
|
||||
rejected: 'abgelehnt',
|
||||
withdrawn: 'zurückgezogen',
|
||||
superseded: 'Verein entfernt',
|
||||
})[s] || s
|
||||
|
||||
/** Freigabe noch gültig (Verein existiert). */
|
||||
function isActiveApprovedCreation(req) {
|
||||
return req.status === 'approved' && req.created_club_id
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding für Nutzer ohne aktive Vereinsmitgliedschaft (Phase A).
|
||||
*/
|
||||
export default function OnboardingPage() {
|
||||
const { user, checkAuth } = useAuth()
|
||||
const [publicClubs, setPublicClubs] = useState([])
|
||||
const [myJoinRequests, setMyJoinRequests] = useState([])
|
||||
const [joinClubId, setJoinClubId] = useState('')
|
||||
const [joinMessage, setJoinMessage] = useState('')
|
||||
const [joinBusy, setJoinBusy] = useState(false)
|
||||
const [myCreationRequests, setMyCreationRequests] = useState([])
|
||||
const [createName, setCreateName] = useState('')
|
||||
const [createAbbr, setCreateAbbr] = useState('')
|
||||
const [createDesc, setCreateDesc] = useState('')
|
||||
const [createMessage, setCreateMessage] = useState('')
|
||||
const [createBusy, setCreateBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [ok, setOk] = useState('')
|
||||
|
||||
const accountState = resolveAccountState(user)
|
||||
const emailOk = accountState !== 'unverified'
|
||||
|
||||
const refreshJoinRequests = () => {
|
||||
if (!emailOk) return
|
||||
api.getMyClubJoinRequests().then(setMyJoinRequests).catch(() => {})
|
||||
}
|
||||
|
||||
const refreshCreationRequests = () => {
|
||||
if (!emailOk) return
|
||||
api.getMyClubCreationRequests().then(setMyCreationRequests).catch(() => {})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
api.listPublicClubsDirectory().then(setPublicClubs).catch(() => {})
|
||||
refreshJoinRequests()
|
||||
refreshCreationRequests()
|
||||
}, [user?.id, emailOk])
|
||||
|
||||
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 = publicClubs.filter(
|
||||
(c) => !memberClubIds.has(c.id) && !pendingClubIds.has(c.id)
|
||||
)
|
||||
|
||||
const hasPendingCreation = myCreationRequests.some((r) => r.status === 'pending')
|
||||
|
||||
const handleCreateClub = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setOk('')
|
||||
const name = (createName || '').trim()
|
||||
if (!name) {
|
||||
setError('Bitte einen Vereinsnamen angeben.')
|
||||
return
|
||||
}
|
||||
setCreateBusy(true)
|
||||
try {
|
||||
await api.createClubCreationRequest({
|
||||
proposed_name: name,
|
||||
proposed_abbreviation: (createAbbr || '').trim() || undefined,
|
||||
proposed_description: (createDesc || '').trim() || undefined,
|
||||
message: (createMessage || '').trim() || undefined,
|
||||
})
|
||||
setCreateName('')
|
||||
setCreateAbbr('')
|
||||
setCreateDesc('')
|
||||
setCreateMessage('')
|
||||
refreshCreationRequests()
|
||||
setOk(
|
||||
'Gründungsantrag gesendet. Nach Freigabe durch den Plattform-Administrator wird dein Verein angelegt.'
|
||||
)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Antrag fehlgeschlagen.')
|
||||
} finally {
|
||||
setCreateBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleJoin = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setOk('')
|
||||
if (!joinClubId) {
|
||||
setError('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()
|
||||
setOk('Antrag gesendet. Der Vereinsadmin kann ihn unter Vereinsverwaltung annehmen.')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Antrag fehlgeschlagen.')
|
||||
} finally {
|
||||
setJoinBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-padding app-page" style={{ padding: '1rem', maxWidth: '40rem' }}>
|
||||
<h1 style={{ marginTop: 0, fontSize: '1.5rem' }}>Willkommen bei Shinkan</h1>
|
||||
<p style={{ color: 'var(--text2)', lineHeight: 1.5, marginBottom: '1.25rem' }}>
|
||||
Shinkan ist die Trainingsplanungs-Plattform für Vereine. Um Übungen, Planung und Medien zu nutzen,
|
||||
brauchst du eine Mitgliedschaft in einem Verein — oder du beantragst die Gründung eines neuen Vereins.
|
||||
</p>
|
||||
|
||||
<EmailVerificationBanner profile={user} />
|
||||
|
||||
{!emailOk ? (
|
||||
<div className="card">
|
||||
<p style={{ margin: 0, color: 'var(--text2)', lineHeight: 1.5 }}>
|
||||
Bitte bestätige zuerst deine E-Mail-Adresse. Danach kannst du einen Beitrittsantrag stellen.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{ok ? (
|
||||
<p role="status" style={{ color: 'var(--accent-dark)', marginBottom: '1rem' }}>
|
||||
{ok}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p role="alert" style={{ color: 'var(--danger)', marginBottom: '1rem' }}>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="card" style={{ marginBottom: '1rem' }}>
|
||||
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Bestehendem Verein beitreten</h2>
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||
Wähle einen Verein und sende einen Beitrittsantrag. Nach Freigabe durch den Vereinsadmin
|
||||
stehen dir alle Funktionen zur Verfügung.
|
||||
</p>
|
||||
|
||||
{myJoinRequests.length > 0 ? (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<strong style={{ fontSize: '0.9rem' }}>Meine Anträge</strong>
|
||||
<ul
|
||||
style={{
|
||||
margin: '0.5rem 0 0',
|
||||
paddingLeft: '1.25rem',
|
||||
color: 'var(--text2)',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
{myJoinRequests.map((r) => (
|
||||
<li key={r.id} style={{ marginBottom: '0.35rem' }}>
|
||||
{r.club_name || `Verein #${r.club_id}`} — {joinStatusLabel(r.status)}
|
||||
{r.status === 'pending' ? (
|
||||
<>
|
||||
{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '0.75rem', padding: '0.15rem 0.45rem' }}
|
||||
onClick={async () => {
|
||||
if (!confirm('Antrag wirklich zurückziehen?')) return
|
||||
try {
|
||||
await api.withdrawClubJoinRequest(r.id)
|
||||
refreshJoinRequests()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Zurückziehen fehlgeschlagen.')
|
||||
}
|
||||
}}
|
||||
>
|
||||
zurückziehen
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form onSubmit={handleJoin}>
|
||||
<label className="form-label" htmlFor="onb-join-club">
|
||||
Verein
|
||||
</label>
|
||||
<select
|
||||
id="onb-join-club"
|
||||
className="form-input"
|
||||
value={joinClubId}
|
||||
onChange={(e) => setJoinClubId(e.target.value)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{joinClubChoices.map((c) => (
|
||||
<option key={c.id} value={String(c.id)}>
|
||||
{c.name}
|
||||
{c.abbreviation ? ` (${c.abbreviation})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="form-label" htmlFor="onb-join-msg" style={{ marginTop: '0.75rem' }}>
|
||||
Nachricht (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="onb-join-msg"
|
||||
className="form-input"
|
||||
rows={2}
|
||||
value={joinMessage}
|
||||
onChange={(e) => setJoinMessage(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={joinBusy}
|
||||
style={{ marginTop: '0.85rem' }}
|
||||
>
|
||||
{joinBusy ? 'Senden…' : 'Beitritt beantragen'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Neuen Verein gründen</h2>
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||
Stelle einen Antrag auf Vereinsgründung. Nach Freigabe durch den Plattform-Administrator
|
||||
wird der Verein mit Free-Abo angelegt und du wirst Hauptverwalter.
|
||||
</p>
|
||||
|
||||
{myCreationRequests.length > 0 ? (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<strong style={{ fontSize: '0.9rem' }}>Meine Gründungsanträge</strong>
|
||||
<ul
|
||||
style={{
|
||||
margin: '0.5rem 0 0',
|
||||
paddingLeft: '1.25rem',
|
||||
color: 'var(--text2)',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
{myCreationRequests.map((r) => (
|
||||
<li key={r.id} style={{ marginBottom: '0.35rem' }}>
|
||||
{r.proposed_name} — {creationStatusLabel(r.status)}
|
||||
{isActiveApprovedCreation(r) && r.created_club_name
|
||||
? ` (${r.created_club_name})`
|
||||
: null}
|
||||
{r.status === 'pending' ? (
|
||||
<>
|
||||
{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '0.75rem', padding: '0.15rem 0.45rem' }}
|
||||
onClick={async () => {
|
||||
if (!confirm('Antrag wirklich zurückziehen?')) return
|
||||
try {
|
||||
await api.withdrawClubCreationRequest(r.id)
|
||||
refreshCreationRequests()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Zurückziehen fehlgeschlagen.')
|
||||
}
|
||||
}}
|
||||
>
|
||||
zurückziehen
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{isActiveApprovedCreation(r) ? (
|
||||
<>
|
||||
{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: '0.75rem', padding: '0.15rem 0.45rem' }}
|
||||
onClick={() => checkAuth()}
|
||||
>
|
||||
App aktualisieren
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasPendingCreation ? (
|
||||
<p style={{ margin: 0, color: 'var(--text2)', fontSize: '0.875rem' }}>
|
||||
Du hast bereits einen offenen Gründungsantrag. Bitte warte auf die Freigabe oder ziehe
|
||||
den Antrag zurück.
|
||||
</p>
|
||||
) : (
|
||||
<form onSubmit={handleCreateClub}>
|
||||
<label className="form-label" htmlFor="onb-create-name">
|
||||
Vereinsname
|
||||
</label>
|
||||
<input
|
||||
id="onb-create-name"
|
||||
className="form-input"
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
maxLength={200}
|
||||
required
|
||||
/>
|
||||
<label className="form-label" htmlFor="onb-create-abbr" style={{ marginTop: '0.75rem' }}>
|
||||
Kürzel (optional)
|
||||
</label>
|
||||
<input
|
||||
id="onb-create-abbr"
|
||||
className="form-input"
|
||||
value={createAbbr}
|
||||
onChange={(e) => setCreateAbbr(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
<label className="form-label" htmlFor="onb-create-desc" style={{ marginTop: '0.75rem' }}>
|
||||
Beschreibung (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="onb-create-desc"
|
||||
className="form-input"
|
||||
rows={2}
|
||||
value={createDesc}
|
||||
onChange={(e) => setCreateDesc(e.target.value)}
|
||||
/>
|
||||
<label className="form-label" htmlFor="onb-create-msg" style={{ marginTop: '0.75rem' }}>
|
||||
Nachricht an den Administrator (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="onb-create-msg"
|
||||
className="form-input"
|
||||
rows={2}
|
||||
value={createMessage}
|
||||
onChange={(e) => setCreateMessage(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={createBusy}
|
||||
style={{ marginTop: '0.85rem' }}
|
||||
>
|
||||
{createBusy ? 'Senden…' : 'Gründung beantragen'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p style={{ marginTop: '1.25rem', fontSize: '0.875rem' }}>
|
||||
<Link to="/settings">Einstellungen</Link> (Passwort, Profil)
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user