diff --git a/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md b/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md index 51b392e..d8d4e67 100644 --- a/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md +++ b/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md @@ -1,7 +1,7 @@ # Einheitliche Zugriffsschicht & Governance – Umsetzungsplan **Status:** verbindliche Umsetzungsreihenfolge (nachgelagert zum Zielbild in `MULTI_TENANCY_RBAC_ARCHITECTURE.md`) -**Stand:** 2026-05-05 +**Stand:** 2026-05-06 **Zweck:** Drift vermeiden – eine nachvollziehbare Schicht für Mandanten-Kontext, Sichtbarkeit und Berechtigungen, auf die alle inhaltsbezogenen Module konsistent aufbauen. **Explizit zurückgestellt (wie vereinbart):** kostenpflichtiges Vereins-Membership / Tier-Limits pro Verein (`club_subscriptions` o. Ä.) – kommt nach stabiler Zugriffs- und Datenisolationsbasis. @@ -101,7 +101,7 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`). | **PR-Checkliste** | Neuer/changed Endpoint: TenantContext verwendet? Governance für Listen + Detail? Tests für zweiten Verein? | | **Single Source of Truth** | Sichtbarkeitsregeln nur in Zugriffsmodul(en), nicht in Routers dupliziert. | | **Änderungen am Enum** | Nur zusammen mit Migration + Kurzbeschreibung in diesem Dokument (Datum/Changelog-Zeile). | -| **Beziehung zu MULTI_TENANCY-Doc** | Phasen 1–4 dort größtenteils umgesetzt; **Gap-Analyse §3** im alten Dokument historisch lesen – fachlicher Zielabgleich bleibt dort, **operative Reihenfolge** hier. | +| **Beziehung zu MULTI_TENANCY-Doc** | Zielbild und Gap-Analyse §3 dort pflegen (**§3.0** = aktueller Umsetzungsstand); **operative Reihenfolge** hier. | --- @@ -109,7 +109,7 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`). 1. **TenantContext-Spezifikation** (ein Abschnitt in diesem Dokument oder Kurz-ADR): Request-Lebenszyklus, Fehlerbilder, Superadmin. 2. **Endpoint-Audit-Tabelle** (Working-Dokument, bei jedem Merge pflegen bis Stufe C abgeschlossen). -3. **Testplan „Cross-Tenant“** (manuell oder pytest): Minimalsetup zweier Vereine — erste rein-funktionale Tests unter `backend/tests/test_access_layer.py` (ohne DB); Integration folgt. +3. **Testplan „Cross-Tenant“** (manuell oder pytest): Minimalsetup zweier Vereine — Unit-Tests `backend/tests/test_access_layer.py`; Integration `backend/tests/test_access_layer_integration.py` bei `ACCESS_LAYER_INTEGRATION=1` / CI im Backend-Container. **Audit-Tabelle (fortlaufend):** `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md` @@ -122,4 +122,4 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`). --- -**Letzte Aktualisierung:** 2026-05-05 +**Letzte Aktualisierung:** 2026-05-06 diff --git a/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md b/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md index 3f26dbf..6ad6284 100644 --- a/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md +++ b/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md @@ -1,7 +1,7 @@ # Multi-Tenancy, Vereins-Membership und Rollenmodell – Zielarchitektur & Umsetzungsplan **Status:** verbindliche Zielrichtung (Architekturpapier) -**Stand:** 2026-05-05 +**Stand:** 2026-05-06 **Bezug:** `functional/shinkan_anforderungsdokument_entwurf.md` §5, §17–18 · `functional/DOMAIN_MODEL.md` (Sichtbarkeit §5.5) · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR-004–008) **Operative Reihenfolge & einheitliche Zugriffsschicht:** [`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`](./ACCESS_LAYER_AND_GOVERNANCE_PLAN.md) – dort sind Stufen A–F, Drift-Prävention und die Zurückstellung von Vereinsabo/Limits festgehalten; dieses Dokument bleibt das übergeordnete **Zielbild**. @@ -20,60 +20,68 @@ Dieses Dokument fasst den **Soll-Zustand** für Mandantenfähigkeit (Verein = Ma |--------|-----------------------------------|-------------------------| | `shinkan_anforderungsdokument_entwurf.md` §5.4 | Rollen: Superadmin, Vereinsadmin, Trainer, Co-Trainer, Redakteur | Deckt sich; „Superadmin“ entspricht fachlich **Systemadmin** | | §17.1 | Erweiterung: Systemadmin, Spartenadmin | Entspricht den gewünschten **Spartenverantwortlichen** | -| §5.5 / §17 | Sichtbarkeit: privat, Verein, Sparte, global, offiziell | DOMAIN_MODEL listet ähnliche Stufen; **technische Durchsetzung** ist noch lückenhaft | +| §5.5 / §17 | Sichtbarkeit: privat, Verein, Sparte, global, offiziell | DOMAIN_MODEL listet ähnliche Stufen; Bibliothek **`private` \| `club` \| `official`** technisch über Zugriffsschicht durchgesetzt; **Sparte/community** folgt | | §18.5 | MVP: Datenmodell mandantenfähig, Rechte zunächst einfach | Bestätigt schrittweise Verschärfung | | `DOMAIN_MODEL.md` §5.5 | Freigabeebenen inkl. Sparte | Zielbild; DB/API nutzen derzeit überwiegend `private` \| `club` \| `official` | | `TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` | CURR-004–008: Governance-Kern, spätere Policy | Datenfelder vorbereitet; **Policy/Erzwingung** folgt | | `working/SHINKAN_PROJECT_SETUP.md` §6 | „Multi-Tenant-Administration“ ausgeschlossen (MVP-Liste) | Historisch; **technische Mandanten** sind dennoch Ziel – UI-Komplexität kontrolliert einführen | -**Fazit:** Die fachlichen Rollen und Sichtbarkeitsebenen sind **in den funktionalen Docs bereits skizziert**. Es fehlt die **stringente technische Schicht**: Vereinszugehörigkeit, aktiver Vereinskontext, effektive Berechtigungen pro Anfrage und konsequente Filterung bei `club`-sichtbaren Objekten. +**Fazit:** Die fachlichen Rollen und Sichtbarkeitsebenen sind **in den funktionalen Docs skizziert**. Für die **Kernteile Bibliothek und Vereinskontext** ist eine **technische Zugriffsschicht** (`TenantContext`, `club_members`, einheitliche Sichtbarkeits-SQL/-Prüfungen) umgesetzt — Details und Restarbeit (**Sparte**, Konsolidierung der Hilfen, Planungs-/Admin-Flows) siehe §3 und `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`. --- ## 3. Ist-Stand im Code (Gap-Analyse) -> **Hinweis:** Dieser Abschnitt beschreibt den Ausgangspunkt vor Ausbauschritten (**Mitgliedschaften, gefilterte Vereinsliste, Teilen von Governance für Übungen/Rahmen/Planung** sind bereits angegangen). Verbindliche **offene Arbeit und Reihenfolge** sind im Dokument [`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`](./ACCESS_LAYER_AND_GOVERNANCE_PLAN.md) festgehalten. +> **Hinweis:** Die Unterabschnitte **3.1–3.6** enthalten weiterhin **historische Problemstellungen** (Ausgangsbild). Ergänzend beschreibt **3.0** den **aktualisierten Umsetzungsstand** nach Mitgliedschafts-, Tenant- und Bibliotheksarbeit. Verbindliche **offene Arbeit und Reihenfolge:** [`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`](./ACCESS_LAYER_AND_GOVERNANCE_PLAN.md). + +### 3.0 Aktualisierung des Umsetzungsstands (kurz) + +- **Mitgliedschaft:** Tabellen `club_members` und `club_member_roles`; aktiver Verein über Profilfeld und Header `X-Active-Club-Id`; Auflösung in **`TenantContext`** (`tenant_context.py`). +- **Bibliothek** (Übungen, Trainingsplan-Vorlagen, Rahmenprogramme u. a.): gemeinsame Leselogik **`library_content_visibility_sql`** / **`library_content_visible_to_profile`** — Vereinsinhalte **`club`** nur bei passendem **`club_id`** und **aktiver Mitgliedschaft** im Objekt-Verein (normale Nutzer ohne gültigen Vereinskontext: kein „beliebiges club“). +- **`GET /api/clubs`:** Nicht-Admins sehen nur Vereine mit Mitgliedschaft; **`POST /api/clubs`:** nur **Plattform-Admin**, mit Pflicht **`primary_admin_profile_id`**. +- **Organisation** (Sparten/Gruppen): Schreibzugriff über **`can_manage_club_org`** / **`can_plan_in_club`** auf Basis von **`club_member_roles`** (nicht mehr nur globales `admin`). +- **Profil-API:** eingeschränktes **`GET /profiles/{id}`**, **`DELETE`**, **`POST /profiles`** (Plattform-Admin / Selbstzugriff) — Details `backend/routers/profiles.py`. +- **Tests:** pytest inkl. optionaler Mandanten-Integration (`ACCESS_LAYER_INTEGRATION`); CI-Anbindung siehe `.gitea/workflows/test.yml` (Ausführung im Backend-Container wie Schwesterprojekt). ### 3.1 Identität und Rollen - `profiles.role` ist eine **globale** Kennzeichnung (`admin`, `superadmin`, `trainer`, `user`, …). -- **Keine** Tabelle für Vereinsmitgliedschaft mit **Mehrfachrollen pro Verein**. -- Sessions liefern nur `profile_id` + globale `role` (`auth.py` → `get_session`). +- *(Historisch)* Fehlende Abbildung von Vereinsrollen **ohne** eigene Tabellen. +- **Ist:** Zusätzlich **`club_member_roles`** pro Verein (z. B. `club_admin`, `trainer`, …); Sessions liefern weiter **`profile_id`** + globale **`role`** (`auth.py` → `get_session`), Vereinsrechte werden aus Mitgliedschaft abgeleitet. -**Konsequenz:** Mehrere Vereine mit unterschiedlichen Rollen pro User sind **nicht modelliert**; ein „Vereinsadmin“ kann nicht sauber von einem reinen Trainer unterschieden werden, sobald beides nur über `profiles.role` laufen soll. +**Konsequenz:** Globale Rolle und Vereinsrollen **koexistieren**; Produkt und Code sollten langfristig klar trennen, was nur global vs. nur über Mitgliedschaft gilt (vgl. Zielarchitektur §4). ### 3.2 Organisation & APIs -- `clubs`, `divisions`, `training_groups` existieren (`002_organization.sql`). -- `GET /api/clubs` listiert **alle** Vereine für jeden eingeloggten Nutzer. -- `POST /api/clubs` erlaubt Anlage für `trainer` und `user` – **nicht** nur Systemadmin. -- Sparten/Gruppen: Schreibzugriff über globale `admin`/`superadmin`, nicht über **Vereinsadmin** im Kontext „sein Verein“. +- *(Historisch)* Zu offene Vereinsliste und Club-Anlage für jeden Trainer/User. +- **Ist:** siehe **3.0** — gefilterte Liste, eingeschränktes Anlegen, kontextbezogene Organisationsrechte. -**Konsequenz:** Weder **Datenisolation** noch **Produktdifferenzierung** „nur Systemadmin legt Verein an“ sind umgesetzt. +**Konsequenz:** Offene Punkte verlagern sich in **feine Produktregeln** und **Sparten-/Community-Stufen** (ACCESS_LAYER Stufe D bzw. spätere Epics). ### 3.3 Trainingsplanung -- Zugriff auf Einheiten gruppenbasiert: Trainer/Co-Trainer der `training_groups`, plus `lead_trainer_profile_id` (Migration/Pfad `training_planning`). -- `_assert_club_visible_for_trainer` bindet Vereinssicht für Teile der Planung an „aktive Gruppe als Trainer/Co im Verein“ – **kein** generelles Mitgliedschaftsmodell. +- Zugriff auf Einheiten weiterhin stark **gruppenbezogen** (`training_groups`, optional **`lead_trainer_profile_id`** auf Einheiten). +- Mitgliedschaft/`TenantContext` unterstützen andere Endpoints; **`GET /training-units`** hat **keinen** impliziten Filter nur auf **`effective_club_id`** (Multi-Verein-Kalender; bei Bedarf Query **`club_id`**). -**Konsequenz:** Planung ist **gruppenzentriert**, nicht **mitgliedschaftszentriert**; Vereinsweite Aufgaben des Vereinsadmins fehlen als konsistentes Recht. +**Konsequenz:** Vereinsweite oder „Administrations“-Planungsaufgaben können weiter ausgebaut werden (eigenes Produkt-Thema; nicht identisch mit Bibliotheks-Governance). -### 3.4 Governance / Sichtbarkeit (kritisch) +### 3.4 Governance / Sichtbarkeit (Bibliothek) -- Übungen (`list_exercises`): Bedingung sinngemäß „official OR club OR created_by = ich“ – **`club` gilt für alle Mandanten**, ohne Prüfung `exercise.club_id` ∈ Vereine des Nutzers. -- Detailzugriff `private`: nur Owner – **ok**. -- Rahmenprogramme (`training_framework_programs`): Lesen fremder Rahmen über `visibility=club` ist in `_framework_access` **nicht** gelöst (faktisch stark creator-basiert für Nicht-Admins). +- *(Historisch)* Risiko: **`club`**-Objekte ohne Bindung an **`club_id`** / Mitgliedschaft → mögliche Cross-Tenant-Sicht. +- **Ist:** Listen und Detail für die genannten Bibliotheksmodule nutzen die **einheitliche** Logik in **`club_tenancy`** / **`tenant_context`** (siehe **3.0**). -**Konsequenz:** **Cross-Tenant-Leaks** bei als `club` markierten Bibliotheksobjekten sind möglich bzw. Leselogik ist inkonsistent zwischen Modulen. +**Konsequenz:** Die historische „Leak“-Diagnose für **Übungen und Rahmenprogramme** in dieser Form ist **überholt**. Verbleibend: **Konsolidierung auf wenige Hilfsfunktionen** (ACCESS_LAYER Stufe C), **Sparte** als eigene Stufe, ggf. **community**. ### 3.5 Frontend -- **Stand 2026-05:** `GET /api/profiles/me` liefert `clubs[]`, `active_club_id`; Frontend setzt `X-Active-Club-Id`. Details und Pflicht zur serverseitigen **TenantContext**-Validierung siehe `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`. +- `GET /api/profiles/me` liefert u. a. **`clubs[]`**, **`active_club_id`**; Client setzt **`X-Active-Club-Id`**. Geschützte Backend-Routen nutzen **`Depends(get_tenant_context)`** wo im Audit festgehalten. ### 3.6 Membership (kommerziell/limits) - Mitai-artige Tabellen (`tiers`, `subscriptions`, `tier_limits`, …) sind **nutzerbezogen**, nicht **vereinsbezogen**. -- Kein konzipiertes **`club_subscription` / `club_plan`** im Schema. +- Kein konzipiertes **`club_subscription` / `club_plan`** im Schema — bewusst nach ACCESS_LAYER-Plan zurückgestellt. + +**Letzte Überarbeitung dieses Abschnitts (3.x):** 2026-05-06. --- diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md index 87ac058..9168685 100644 --- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -5,6 +5,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. | Router / Bereich | Beispiel-Endpunkt | tenant-relevant | `Depends(get_tenant_context)` / Kontext | Governance geprüft (Liste+Detail) | Notizen | |------------------|-------------------|-----------------|----------------------------------------|-------------------------------------|---------| | profiles | `GET /api/profiles/me` | ja | `resolve_tenant_context` inline (`invalid_header_policy=ignore`) | teils | + `effective_club_id`; veralteter Header bricht Refresh nicht | +| profiles | `GET /api/profiles`, `GET /profiles/{pid}`, `POST /profiles`, `DELETE /profiles/{pid}` | ja/teils | `require_auth` | ja | Liste nur Plattform-Admin; GET nach ID eigenes Profil oder Admin; POST/DELETE nur Admin | | profiles | `PUT /api/profiles/{id}`, `PUT /api/profile` | ja | `get_tenant_context` | `active_club_id` Mitgliedschaft | Validiert `X-Active-Club-Id` konsistent zu Mitgliedschaft | | clubs | geschützte `/api/clubs*`, `/divisions*`, `/groups*` | ja | `get_tenant_context` | Mitgliedschaft / `can_manage_*` | Öffentlich: `/clubs/public-directory` ohne Auth | | club_memberships | `/clubs/{id}/members*` | ja | `get_tenant_context` | ja | | @@ -24,7 +25,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. **Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen. -Letzte Änderung: 2026-05-05 — Cursor-Regel + Architektur-/Coding-Pflicht + Script `backend/scripts/check_access_layer_hints.py`; Katalog-Router im Audit als global dokumentiert. +Letzte Änderung: 2026-05-06 — MULTI_TENANCY §3 Gap-Analyse aktualisiert; Audit um geschützte Profil-Endpunkte ergänzt. --- diff --git a/backend/club_tenancy.py b/backend/club_tenancy.py index e81e374..4da0747 100644 --- a/backend/club_tenancy.py +++ b/backend/club_tenancy.py @@ -52,6 +52,33 @@ def has_club_role(cur, profile_id: int, club_id: int, *role_codes: str) -> bool: return cur.fetchone() is not None +def club_admin_shares_club_with_creator( + cur, club_admin_profile_id: int, creator_profile_id: int +) -> bool: + """ + True, wenn club_admin_profile_id in mindestens einem Verein die Rolle club_admin hat und + creator_profile_id dort ebenfalls aktives Mitglied ist (z. B. Löschen fremder privater Übungen). + """ + if club_admin_profile_id == creator_profile_id: + return False + cur.execute( + """ + SELECT 1 + FROM club_members cm_admin + INNER JOIN club_member_roles r + ON r.club_member_id = cm_admin.id AND r.role_code = 'club_admin' + INNER JOIN club_members cm_creator + ON cm_creator.club_id = cm_admin.club_id + AND cm_creator.profile_id = %s + AND cm_creator.status = 'active' + WHERE cm_admin.profile_id = %s AND cm_admin.status = 'active' + LIMIT 1 + """, + (creator_profile_id, club_admin_profile_id), + ) + return cur.fetchone() is not None + + def can_manage_club_org(cur, profile_id: int, club_id: int, global_role: Optional[str]) -> bool: """Sparten/Gruppen/Struktur: Vereinsadmin oder Plattform-Admin.""" if is_platform_admin(global_role): diff --git a/backend/migrations/042_training_unit_session_assignments.sql b/backend/migrations/042_training_unit_session_assignments.sql new file mode 100644 index 0000000..816368e --- /dev/null +++ b/backend/migrations/042_training_unit_session_assignments.sql @@ -0,0 +1,9 @@ +-- Session-spezifische Co-Trainer: NULL = wie training_groups.co_trainer_ids; [] = explizit keine Co-Trainer +ALTER TABLE training_units +ADD COLUMN IF NOT EXISTS assistant_trainer_profile_ids JSONB; + +COMMENT ON COLUMN training_units.assistant_trainer_profile_ids IS + 'Co-Trainer nur für diese Einheit; NULL vererbt training_groups.co_trainer_ids; leeres Array = keine Co-Trainer'; + +CREATE INDEX IF NOT EXISTS idx_training_units_assistant_trainers + ON training_units USING GIN (assistant_trainer_profile_ids); diff --git a/backend/migrations/043_profiles_exercise_list_prefs.sql b/backend/migrations/043_profiles_exercise_list_prefs.sql new file mode 100644 index 0000000..02180b7 --- /dev/null +++ b/backend/migrations/043_profiles_exercise_list_prefs.sql @@ -0,0 +1,3 @@ +-- Gespeicherte Standard-Filter für die Übungsliste (pro Nutzer) +ALTER TABLE profiles + ADD COLUMN IF NOT EXISTS exercise_list_prefs JSONB NOT NULL DEFAULT '{}'::jsonb; diff --git a/backend/models.py b/backend/models.py index 791d48c..68f0ac5 100644 --- a/backend/models.py +++ b/backend/models.py @@ -4,7 +4,7 @@ Pydantic Models for Shinkan Jinkendo API Request/Response schemas for all endpoints """ from pydantic import BaseModel, EmailStr, Field -from typing import Optional, List +from typing import Optional, List, Dict, Any from datetime import date, time, datetime # ============================================================================ @@ -43,6 +43,10 @@ class ProfileUpdate(BaseModel): description="Portal-Rolle: user, trainer, admin, superadmin (nur Plattform-Admin)", ) tier: Optional[str] = Field(default=None, max_length=50) + exercise_list_prefs: Optional[Dict[str, Any]] = Field( + default=None, + description="JSON: gespeicherte Standardfilter für die Übungsliste", + ) class ProfileResponse(BaseModel): id: int diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index e5b0e2b..8ef0bef 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -17,6 +17,8 @@ from pydantic import BaseModel, Field, model_validator from db import get_db, get_cursor, r2d from club_tenancy import ( assert_valid_governance_visibility, + club_admin_shares_club_with_creator, + has_club_role, is_platform_admin, library_content_visible_to_profile, ) @@ -26,6 +28,24 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api", tags=["exercises"]) + +def _coerce_json_str_list(val: Any) -> List[str]: + """JSON-Aggregat oder JSON-String aus PG in eine saubere str-Liste für die Listen-API.""" + if val is None: + return [] + if isinstance(val, list): + return [str(x) for x in val if x is not None and str(x).strip()] + if isinstance(val, str): + try: + parsed = json.loads(val) + if isinstance(parsed, list): + return [str(x) for x in parsed if x is not None and str(x).strip()] + except Exception: + return [] + return [] + return [] + + # Kanonische Fähigkeitsstufen 1–5 (Übung ↔ Skill-Zeile), siehe Migration 029 _CANONICAL_SKILL_LEVELS = frozenset( {"basis", "grundlagen", "aufbau", "fortgeschritten", "optimierung"} @@ -214,21 +234,38 @@ class ExerciseVariantsReorder(BaseModel): _VALID_EXERCISE_STATUS_BULK = frozenset({"draft", "in_review", "approved", "archived"}) +_LIST_FILTER_VISIBILITY = frozenset({"private", "club", "official"}) +_LIST_FILTER_STATUS = frozenset({"draft", "in_review", "approved", "archived"}) _MAX_BULK_METADATA_IDS = 500 +_MAX_BULK_RELATION_IDS_PER_KIND = 80 class ExerciseBulkMetadataPatch(BaseModel): - """Massenänderung von Sichtbarkeit und/oder Status (z. B. Private → Verein).""" + """Massenänderung: Sichtbarkeit/Status und/oder Zuordnungen (Kataloge).""" exercise_ids: list[int] = Field(..., min_length=1, max_length=_MAX_BULK_METADATA_IDS) visibility: Optional[str] = Field(None, pattern="^(private|club|official)$") status: Optional[str] = None club_id: Optional[int] = Field(default=None, ge=1) + focus_area_ids: Optional[list[int]] = Field(default=None, max_length=_MAX_BULK_RELATION_IDS_PER_KIND) + style_direction_ids: Optional[list[int]] = Field(default=None, max_length=_MAX_BULK_RELATION_IDS_PER_KIND) + training_type_ids: Optional[list[int]] = Field(default=None, max_length=_MAX_BULK_RELATION_IDS_PER_KIND) + target_group_ids: Optional[list[int]] = Field(default=None, max_length=_MAX_BULK_RELATION_IDS_PER_KIND) @model_validator(mode="after") - def at_least_visibility_or_status(self): - if self.visibility is None and self.status is None: - raise ValueError("Mindestens eines der Felder visibility oder status angeben") + def at_least_one_patch_field(self): + if ( + self.visibility is None + and self.status is None + and self.focus_area_ids is None + and self.style_direction_ids is None + and self.training_type_ids is None + and self.target_group_ids is None + ): + raise ValueError( + "Mindestens eines der Felder visibility, status, focus_area_ids, style_direction_ids, " + "training_type_ids oder target_group_ids angeben" + ) return self @@ -456,7 +493,14 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict: return exercise -def assign_exercise_relations(cur, conn, exercise_id: int, data: dict): +def assign_exercise_relations( + cur, + conn, + exercise_id: int, + data: dict, + *, + do_commit: bool = True, +): """ Weist M:N Relations für eine Übung zu. Löscht alte Zuordnungen und legt neue an (REPLACE-Logik). @@ -532,13 +576,59 @@ def assign_exercise_relations(cur, conn, exercise_id: int, data: dict): ) ) - conn.commit() + if do_commit: + conn.commit() # ============================================================================ # Endpoints # ============================================================================ + +def _normalize_bulk_id_list(raw: Optional[list]) -> list[int]: + """Positive IDs, Reihenfolge beibehalten, Duplikate entfernen.""" + if not raw: + return [] + seen: set[int] = set() + out: list[int] = [] + for x in raw: + try: + xi = int(x) + except (TypeError, ValueError): + continue + if xi < 1 or xi in seen: + continue + seen.add(xi) + out.append(xi) + return out + + +def _assert_catalog_ids_exist(cur, kind: str, ids: list[int]) -> None: + if not ids: + return + table_by_kind = { + "focus_areas": "focus_areas", + "style_directions": "style_directions", + "training_types": "training_types", + "target_groups": "target_groups", + } + table = table_by_kind.get(kind) + if not table: + raise HTTPException(status_code=500, detail="Interner Fehler: unbekannter Katalog") + ph = ",".join(["%s"] * len(ids)) + cur.execute(f"SELECT id FROM {table} WHERE id IN ({ph})", tuple(ids)) + found = { + int(r["id"]) if isinstance(r, dict) else int(r[0]) + for r in cur.fetchall() + } + missing = [i for i in ids if i not in found] + if missing: + raise HTTPException( + status_code=400, + detail=f"Unbekannte {kind}-IDs (Beispiele): {missing[:12]}", + ) + + def _merge_ids(multi: list[int], single: Optional[int]) -> list[int]: """Liste aus wiederholten Query-Parametern plus optional einem Legacy-Einzelfilter (ohne Duplikate).""" seen: set[int] = set() @@ -555,6 +645,21 @@ def _merge_ids(multi: list[int], single: Optional[int]) -> list[int]: return out +def _dedupe_positive_ids(ids: list[int]) -> list[int]: + seen: set[int] = set() + out: list[int] = [] + for raw in ids or []: + try: + xi = int(raw) + except (TypeError, ValueError): + continue + if xi < 1 or xi in seen: + continue + seen.add(xi) + out.append(xi) + return out + + def _merge_str_any(multi: list[str], single: Optional[str]) -> list[str]: seen = set() out = [] @@ -571,13 +676,107 @@ def _merge_str_any(multi: list[str], single: Optional[str]) -> list[str]: return out +def _normalize_choice_list(raw: list[str], allowed: frozenset, label: str) -> list[str]: + out = [] + seen = set() + for x in raw or []: + s = str(x).strip().lower() + if not s or s in seen: + continue + if s not in allowed: + raise HTTPException(status_code=400, detail=f"Ungültiger Wert in {label}") + seen.add(s) + out.append(s) + return out + + +def _exercise_delete_usage_counts(cur, exercise_id: int) -> dict: + cur.execute( + """ + SELECT + (SELECT COUNT(*)::int FROM exercise_block_items WHERE exercise_id = %s) AS block_items, + (SELECT COUNT(*)::int FROM training_unit_section_items WHERE exercise_id = %s) AS section_items, + (SELECT COUNT(*)::int FROM exercise_progression_edges + WHERE from_exercise_id = %s OR to_exercise_id = %s) AS prog_edges + """, + (exercise_id, exercise_id, exercise_id, exercise_id), + ) + row = cur.fetchone() + return dict(row) if row else {"block_items": 0, "section_items": 0, "prog_edges": 0} + + +def _exercise_delete_usage_message(counts: dict) -> str: + bi = int(counts.get("block_items") or 0) + si = int(counts.get("section_items") or 0) + pe = int(counts.get("prog_edges") or 0) + parts = [] + if bi: + parts.append(f"{bi}× in Übungsblöcken") + if si: + parts.append(f"{si}× in Trainingsplänen oder Rahmenabläufen") + if pe: + parts.append(f"{pe}× in Progressionsgraphen (Kanten)") + if not parts: + return "" + return ( + "Die Übung wird noch verwendet und kann nicht gelöscht werden. Bitte auf „archiviert“ setzen. " + "Verwendung: " + ", ".join(parts) + "." + ) + + +def _assert_can_delete_exercise(cur, tenant: TenantContext, row: dict) -> None: + pid = tenant.profile_id + role = tenant.global_role + if is_platform_admin(role): + return + vis = str(row.get("visibility") or "private").strip().lower() + cid = row.get("club_id") + creator = row.get("created_by") + try: + creator_int = int(creator) if creator is not None else None + except (TypeError, ValueError): + creator_int = None + + if vis == "official": + raise HTTPException( + status_code=403, + detail="Globale Übungen dürfen nur von Plattform-Admins gelöscht werden.", + ) + if vis == "club": + try: + ex_club = int(cid) if cid is not None else None + except (TypeError, ValueError): + ex_club = None + if ex_club is None: + raise HTTPException(status_code=400, detail="Vereins-Übung ohne gültige Vereinszuordnung") + if not has_club_role(cur, pid, ex_club, "club_admin"): + raise HTTPException( + status_code=403, + detail="Nur Vereins-Admins dürfen Vereins-Übungen löschen.", + ) + return + + if creator_int is not None and creator_int == pid: + return + if creator_int is not None and club_admin_shares_club_with_creator(cur, pid, creator_int): + return + raise HTTPException( + status_code=403, + detail="Keine Berechtigung zum Löschen dieser Übung.", + ) + + @router.patch("/exercises/bulk-metadata") def bulk_patch_exercises_metadata( body: ExerciseBulkMetadataPatch, tenant: TenantContext = Depends(get_tenant_context), ): """ - Ändert Sichtbarkeit und/oder Status für viele Übungen auf einmal. + Ändert Sichtbarkeit, Status und/oder Katalog-Zuordnungen für viele Übungen auf einmal (REPLACE je Kategorie). + + Zuordnung: Sind z. B. focus_area_ids im Body gesetzt, werden die Fokusbereiche bei den bearbeiteten + Übungen vollständig durch diese Liste ersetzt (leeres Array entfernt alle). + Berechtigt: Ersteller der jeweiligen Übung oder Plattform-Admin (admin/superadmin). Governance wie bei Einzel-PUT (official nur Plattform-Admin; club mit Mitgliedschaft bzw. Admin). """ @@ -603,6 +802,33 @@ def bulk_patch_exercises_metadata( patch_visibility = body.visibility is not None patch_status = status_val is not None + patch_focus_areas = body.focus_area_ids is not None + fa_ids = _normalize_bulk_id_list(body.focus_area_ids or []) if patch_focus_areas else [] + patch_style_dirs = body.style_direction_ids is not None + sd_ids = _normalize_bulk_id_list(body.style_direction_ids or []) if patch_style_dirs else [] + patch_training_types = body.training_type_ids is not None + tt_ids = _normalize_bulk_id_list(body.training_type_ids or []) if patch_training_types else [] + patch_target_groups = body.target_group_ids is not None + tg_ids = _normalize_bulk_id_list(body.target_group_ids or []) if patch_target_groups else [] + + relation_data: Dict[str, Any] = {} + if patch_focus_areas: + relation_data["focus_areas_multi"] = [ + {"focus_area_id": i, "is_primary": idx == 0} for idx, i in enumerate(fa_ids) + ] + if patch_style_dirs: + relation_data["training_styles_multi"] = [ + {"training_style_id": i, "is_primary": idx == 0} for idx, i in enumerate(sd_ids) + ] + if patch_training_types: + relation_data["training_types_multi"] = [ + {"training_type_id": i, "is_primary": idx == 0} for idx, i in enumerate(tt_ids) + ] + if patch_target_groups: + relation_data["target_groups_multi"] = [ + {"target_group_id": i, "is_primary": idx == 0} for idx, i in enumerate(tg_ids) + ] + updated: List[int] = [] failed: List[Dict[str, Any]] = [] @@ -612,6 +838,16 @@ def bulk_patch_exercises_metadata( with get_db() as conn: cur = get_cursor(conn) + + if patch_focus_areas: + _assert_catalog_ids_exist(cur, "focus_areas", fa_ids) + if patch_style_dirs: + _assert_catalog_ids_exist(cur, "style_directions", sd_ids) + if patch_training_types: + _assert_catalog_ids_exist(cur, "training_types", tt_ids) + if patch_target_groups: + _assert_catalog_ids_exist(cur, "target_groups", tg_ids) + for ex_id in unique_ids: cur.execute( "SELECT id, created_by, visibility, club_id FROM exercises WHERE id = %s", @@ -681,6 +917,8 @@ def bulk_patch_exercises_metadata( f"UPDATE exercises SET {', '.join(sets)} WHERE id = %s", tuple(vals), ) + if relation_data: + assign_exercise_relations(cur, conn, ex_id, relation_data, do_commit=False) updated.append(ex_id) conn.commit() @@ -721,6 +959,56 @@ def list_exercises( default=False, description="Wenn true: Feld variants[] pro Übung (id, variant_name, sequence_order) für Planung/UI", ), + visibility_exclude_any: list[str] = Query( + default=[], description="Keine dieser Sichtbarkeiten (Negativliste)" + ), + status_exclude_any: list[str] = Query( + default=[], description="Keiner dieser Statuswerte (Negativliste)" + ), + exclude_without_focus: bool = Query( + default=False, + description="Wenn true: nur Übungen mit mindestens einem Fokusbereich", + ), + focus_only_without_focus_areas: bool = Query( + default=False, + description="Nur Übungen ohne einen einzigen Fokusbereich (M:N exercise_focus_areas leer)", + ), + focus_area_must_include_ids: list[int] = Query( + default=[], + description="Alle genannten Fokusbereiche müssen gesetzt sein (UND / „+“)", + ), + focus_area_must_exclude_ids: list[int] = Query( + default=[], + description="Keiner dieser Fokusbereiche darf gesetzt sein („−“)", + ), + style_direction_must_include_ids: list[int] = Query( + default=[], + description="Alle genannten Stilrichtungen müssen der Übung zugeordnet sein (UND)", + ), + style_direction_must_exclude_ids: list[int] = Query( + default=[], + description="Keine dieser Stilrichtungen darf zugeordnet sein", + ), + training_type_must_include_ids: list[int] = Query( + default=[], + description="Alle genannten Trainingsstile müssen zugeordnet sein (UND)", + ), + training_type_must_exclude_ids: list[int] = Query( + default=[], + description="Keiner dieser Trainingsstile darf zugeordnet sein", + ), + target_group_must_include_ids: list[int] = Query( + default=[], + description="Alle genannten Zielgruppen müssen zugeordnet sein (UND)", + ), + target_group_must_exclude_ids: list[int] = Query( + default=[], + description="Keine dieser Zielgruppen darf zugeordnet sein", + ), + include_archived: bool = Query( + default=False, + description="Archivierte einbeziehen; Standard false (außer Statusfilter enthält archived)", + ), tenant: TenantContext = Depends(get_tenant_context), ): """ @@ -760,13 +1048,83 @@ def list_exercises( where.append(f"e.status IN ({ph})") params.extend(st_list) - fa_ids = _merge_ids(focus_area_ids, focus_area) - if fa_ids: - ph = ",".join(["%s"] * len(fa_ids)) + includes_archived = any(str(x).strip().lower() == "archived" for x in st_list) + if not include_archived and not includes_archived: + where.append("COALESCE(e.status, '') <> %s") + params.append("archived") + + vis_excl = _normalize_choice_list( + list(visibility_exclude_any), + _LIST_FILTER_VISIBILITY, + "visibility_exclude_any", + ) + if vis_excl: + ph = ",".join(["%s"] * len(vis_excl)) + where.append(f"(e.visibility IS NULL OR LOWER(TRIM(e.visibility::text)) NOT IN ({ph}))") + params.extend(vis_excl) + + st_excl = _normalize_choice_list( + list(status_exclude_any), + _LIST_FILTER_STATUS, + "status_exclude_any", + ) + if st_excl: + ph = ",".join(["%s"] * len(st_excl)) + where.append(f"(e.status IS NULL OR LOWER(TRIM(e.status::text)) NOT IN ({ph}))") + params.extend(st_excl) + + focus_only = focus_only_without_focus_areas + must_inc = _dedupe_positive_ids(list(focus_area_must_include_ids)) + must_exc = _dedupe_positive_ids(list(focus_area_must_exclude_ids)) + fa_or = _merge_ids(focus_area_ids, focus_area) + + if focus_only: + if exclude_without_focus: + raise HTTPException( + status_code=400, + detail="focus_only_without_focus_areas schließt exclude_without_focus aus.", + ) + if fa_or: + raise HTTPException( + status_code=400, + detail="focus_only_without_focus_areas darf nicht zusammen mit focus_area_ids (ODER-Liste) verwendet werden.", + ) + if must_inc: + raise HTTPException( + status_code=400, + detail="focus_only_without_focus_areas darf nicht zusammen mit focus_area_must_include_ids verwendet werden.", + ) + if must_exc: + raise HTTPException( + status_code=400, + detail="focus_only_without_focus_areas darf nicht zusammen mit focus_area_must_exclude_ids verwendet werden.", + ) where.append( - f"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id IN ({ph}))" + "NOT EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id)" ) - params.extend(fa_ids) + else: + if exclude_without_focus: + where.append( + "EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id)" + ) + if fa_or: + ph = ",".join(["%s"] * len(fa_or)) + where.append( + f"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id IN ({ph}))" + ) + params.extend(fa_or) + for fid in must_inc: + where.append( + "EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id = %s)" + ) + params.append(fid) + if must_exc: + ph = ",".join(["%s"] * len(must_exc)) + where.append( + f"NOT EXISTS (SELECT 1 FROM exercise_focus_areas efa " + f"WHERE efa.exercise_id = e.id AND efa.focus_area_id IN ({ph}))" + ) + params.extend(must_exc) sk_ids = _merge_ids(skill_ids, skill_id) if sk_ids: @@ -776,32 +1134,77 @@ def list_exercises( ) params.extend(sk_ids) - sd_ids = _merge_ids(style_direction_ids, style_direction_id) - if sd_ids: - ph = ",".join(["%s"] * len(sd_ids)) + sd_or = _merge_ids(style_direction_ids, style_direction_id) + sd_inc = _dedupe_positive_ids(list(style_direction_must_include_ids)) + sd_exc = _dedupe_positive_ids(list(style_direction_must_exclude_ids)) + if sd_or: + ph = ",".join(["%s"] * len(sd_or)) where.append( "EXISTS (SELECT 1 FROM exercise_style_directions esd " f"WHERE esd.exercise_id = e.id AND esd.style_direction_id IN ({ph}))" ) - params.extend(sd_ids) + params.extend(sd_or) + for sid in sd_inc: + where.append( + "EXISTS (SELECT 1 FROM exercise_style_directions esd " + "WHERE esd.exercise_id = e.id AND esd.style_direction_id = %s)" + ) + params.append(sid) + if sd_exc: + ph = ",".join(["%s"] * len(sd_exc)) + where.append( + "NOT EXISTS (SELECT 1 FROM exercise_style_directions esd " + f"WHERE esd.exercise_id = e.id AND esd.style_direction_id IN ({ph}))" + ) + params.extend(sd_exc) - tt_ids = _merge_ids(training_type_ids, training_type_id) - if tt_ids: - ph = ",".join(["%s"] * len(tt_ids)) + tt_or = _merge_ids(training_type_ids, training_type_id) + tt_inc = _dedupe_positive_ids(list(training_type_must_include_ids)) + tt_exc = _dedupe_positive_ids(list(training_type_must_exclude_ids)) + if tt_or: + ph = ",".join(["%s"] * len(tt_or)) where.append( "EXISTS (SELECT 1 FROM exercise_training_types ett " f"WHERE ett.exercise_id = e.id AND ett.training_type_id IN ({ph}))" ) - params.extend(tt_ids) + params.extend(tt_or) + for tid in tt_inc: + where.append( + "EXISTS (SELECT 1 FROM exercise_training_types ett " + "WHERE ett.exercise_id = e.id AND ett.training_type_id = %s)" + ) + params.append(tid) + if tt_exc: + ph = ",".join(["%s"] * len(tt_exc)) + where.append( + "NOT EXISTS (SELECT 1 FROM exercise_training_types ett " + f"WHERE ett.exercise_id = e.id AND ett.training_type_id IN ({ph}))" + ) + params.extend(tt_exc) - tg_ids = _merge_ids(target_group_ids, target_group_id) - if tg_ids: - ph = ",".join(["%s"] * len(tg_ids)) + tg_or = _merge_ids(target_group_ids, target_group_id) + tg_inc = _dedupe_positive_ids(list(target_group_must_include_ids)) + tg_exc = _dedupe_positive_ids(list(target_group_must_exclude_ids)) + if tg_or: + ph = ",".join(["%s"] * len(tg_or)) where.append( "EXISTS (SELECT 1 FROM exercise_target_groups etg " f"WHERE etg.exercise_id = e.id AND etg.target_group_id IN ({ph}))" ) - params.extend(tg_ids) + params.extend(tg_or) + for gid in tg_inc: + where.append( + "EXISTS (SELECT 1 FROM exercise_target_groups etg " + "WHERE etg.exercise_id = e.id AND etg.target_group_id = %s)" + ) + params.append(gid) + if tg_exc: + ph = ",".join(["%s"] * len(tg_exc)) + where.append( + "NOT EXISTS (SELECT 1 FROM exercise_target_groups etg " + f"WHERE etg.exercise_id = e.id AND etg.target_group_id IN ({ph}))" + ) + params.extend(tg_exc) if skill_min_level is not None or skill_max_level is not None: lo = skill_min_level if skill_min_level is not None else 1 @@ -860,7 +1263,34 @@ def list_exercises( WHERE efa.exercise_id = e.id ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC LIMIT 1 - ) AS primary_focus_name + ) AS primary_focus_name, + ( + SELECT COALESCE( + json_agg(fa.name ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC), + '[]'::json + ) + FROM exercise_focus_areas efa + JOIN focus_areas fa ON fa.id = efa.focus_area_id + WHERE efa.exercise_id = e.id + ) AS focus_area_names, + ( + SELECT COALESCE( + json_agg(sd.name ORDER BY esd.is_primary DESC NULLS LAST, sd.name ASC), + '[]'::json + ) + FROM exercise_style_directions esd + JOIN style_directions sd ON sd.id = esd.style_direction_id + WHERE esd.exercise_id = e.id + ) AS style_direction_names, + ( + SELECT COALESCE( + json_agg(tt.name ORDER BY ett.is_primary DESC NULLS LAST, tt.sort_order NULLS LAST, tt.name ASC), + '[]'::json + ) + FROM exercise_training_types ett + JOIN training_types tt ON tt.id = ett.training_type_id + WHERE ett.exercise_id = e.id + ) AS training_type_names {variants_sql} FROM exercises e LEFT JOIN profiles p ON e.created_by = p.id @@ -879,6 +1309,9 @@ def list_exercises( d = r2d(r) pfn = d.get("primary_focus_name") d["focus_area"] = pfn + d["focus_area_names"] = _coerce_json_str_list(d.get("focus_area_names")) + d["style_direction_names"] = _coerce_json_str_list(d.get("style_direction_names")) + d["training_type_names"] = _coerce_json_str_list(d.get("training_type_names")) if include_variants: v = d.get("variants") if isinstance(v, str): @@ -1082,38 +1515,32 @@ def delete_exercise( ): """ Löscht eine Übung. - Nur Owner oder Admin darf löschen. - """ - profile_id = tenant.profile_id - role = tenant.global_role + Berechtigung: Plattform-Admin (alle); Vereins-Admin Vereins-Übungen seines Vereins; + Ersteller nur eigene private Übungen; Vereins-Admin zusätzlich private Übungen von Mitgliedern, + mit denen er einen Verein teilt. + + Bei Verwendung in Blöcken, Trainingsplänen oder Progressionsgraphen: 409 — bitte archivieren. + """ with get_db() as conn: cur = get_cursor(conn) - # Existiert die Übung? - cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,)) + cur.execute( + "SELECT id, created_by, visibility, club_id FROM exercises WHERE id = %s", + (exercise_id,), + ) row = cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Übung nicht gefunden") + ex = r2d(row) - # Permission Check - if _row_created_by(row) != profile_id and not is_platform_admin(role): - raise HTTPException(status_code=403, detail="Nur Ersteller oder Admin darf löschen") + _assert_can_delete_exercise(cur, tenant, ex) - # Prüfen ob Übung in Block-Items verwendet wird - cur.execute( - "SELECT COUNT(*) AS cnt FROM exercise_block_items WHERE exercise_id = %s", - (exercise_id,) - ) - crow = cur.fetchone() - count = crow["cnt"] if isinstance(crow, dict) else crow[0] - if count > 0: - raise HTTPException( - status_code=409, - detail=f"Übung wird in {count} Block-Item(s) verwendet und kann nicht gelöscht werden" - ) + counts = _exercise_delete_usage_counts(cur, exercise_id) + usage_msg = _exercise_delete_usage_message(counts) + if usage_msg: + raise HTTPException(status_code=409, detail=usage_msg) - # DELETE (Cascade löscht M:N Zuordnungen automatisch) cur.execute("DELETE FROM exercises WHERE id = %s", (exercise_id,)) conn.commit() diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py index 9825854..e6cefec 100644 --- a/backend/routers/profiles.py +++ b/backend/routers/profiles.py @@ -9,6 +9,8 @@ from datetime import datetime from fastapi import APIRouter, HTTPException, Header, Depends +from psycopg2.extras import Json + from db import get_db, get_cursor, r2d from auth import require_auth, hash_pin from club_tenancy import assert_club_member, memberships_with_roles, is_platform_admin @@ -258,6 +260,15 @@ def _run_profile_update(pid: str, p: ProfileUpdate, tenant: TenantContext) -> di assert_club_member(cur, int(pid), cid) data["active_club_id"] = cid + if "exercise_list_prefs" in patch: + ep = patch.pop("exercise_list_prefs") + if ep is None: + data["exercise_list_prefs"] = Json({}) + elif isinstance(ep, dict): + data["exercise_list_prefs"] = Json(ep) + else: + raise HTTPException(400, "exercise_list_prefs muss ein JSON-Objekt sein") + nullable_keys = {"goal_weight", "goal_bf_pct", "dob"} for k, v in patch.items(): if k == "email": diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index e664209..1eddfea 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -12,6 +12,7 @@ from db import get_db, get_cursor, r2d from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql from club_tenancy import ( assert_valid_governance_visibility, + can_manage_club_org, is_platform_admin, library_content_visible_to_profile, ) @@ -53,7 +54,7 @@ def _validate_variant_for_exercise(cur, exercise_id: Optional[int], variant_id: def _can_access_group_for_create(cur, group_id: int, profile_id: int, role: str) -> None: cur.execute( - "SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s", + "SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s", (group_id,), ) group = cur.fetchone() @@ -64,9 +65,83 @@ def _can_access_group_for_create(cur, group_id: int, profile_id: int, role: str) raise HTTPException(status_code=403, detail="Nur Trainer dürfen Trainingseinheiten erstellen") if role not in ["admin", "superadmin"]: if group["trainer_id"] != profile_id and profile_id not in co_trainers: - raise HTTPException( - status_code=403, detail="Nur der zuständige Trainer darf für diese Gruppe planen" - ) + if not can_manage_club_org(cur, profile_id, int(group["club_id"]), role): + raise HTTPException( + status_code=403, detail="Nur der zuständige Trainer darf für diese Gruppe planen" + ) + + +def _profile_active_in_club(cur, club_id: int, profile_id: int) -> bool: + cur.execute( + """ + SELECT 1 FROM club_members + WHERE club_id = %s AND profile_id = %s AND status = 'active' + LIMIT 1 + """, + (club_id, profile_id), + ) + return cur.fetchone() is not None + + +def _caller_may_assign_session_trainers( + cur, + group_row: Dict[str, Any], + profile_id: int, + role: str, + unit_created_by: Optional[int], +) -> bool: + if is_platform_admin(role): + return True + cid = group_row.get("club_id") + if cid is not None and can_manage_club_org(cur, profile_id, int(cid), role): + return True + if unit_created_by is not None and unit_created_by == profile_id: + return True + if group_row.get("trainer_id") == profile_id: + return True + co = group_row.get("co_trainer_ids") or [] + return profile_id in co + + +def _effective_co_trainer_ids_for_row(unit_row: Dict[str, Any]) -> List[int]: + """Leseregel: Session-Co-Trainer überschreiben die Gruppe; NULL auf der Einheit = Gruppen-Standard.""" + unit_asst = unit_row.get("assistant_trainer_profile_ids") + if unit_asst is not None: + src = unit_asst + else: + src = unit_row.get("co_trainer_ids") or [] + seen: set = set() + out: List[int] = [] + for x in src: + try: + i = int(x) + except (TypeError, ValueError): + continue + if i not in seen: + seen.add(i) + out.append(i) + return sorted(out) + + +def effective_co_trainer_profile_ids_for_merge( + unit_assistant: Any, group_co: Any +) -> List[int]: + """Reine Hilfsfunktion (pytest): gleiche Semantik wie _effective_co_trainer_ids_for_row.""" + if unit_assistant is not None: + src = unit_assistant + else: + src = group_co or [] + seen: set = set() + out: List[int] = [] + for x in src: + try: + i = int(x) + except (TypeError, ValueError): + continue + if i not in seen: + seen.add(i) + out.append(i) + return sorted(out) def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]: @@ -74,7 +149,8 @@ def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]: """ SELECT tu.id, tu.created_by, tu.group_id, tu.plan_template_id, tu.framework_slot_id, tu.lead_trainer_profile_id, - tg.trainer_id, tg.co_trainer_ids, + tu.assistant_trainer_profile_ids, + tg.trainer_id, tg.co_trainer_ids, tg.club_id AS group_club_id, fwp.created_by AS framework_created_by FROM training_units tu LEFT JOIN training_groups tg ON tu.group_id = tg.id @@ -103,26 +179,53 @@ def _assert_training_unit_permission( return raise HTTPException(status_code=403, detail="Keine Berechtigung") - co_trainers = unit_row["co_trainer_ids"] or [] - if role not in ["admin", "superadmin"]: - if ( - unit_row["created_by"] != profile_id - and unit_row["trainer_id"] != profile_id - and profile_id not in co_trainers - and unit_row.get("lead_trainer_profile_id") != profile_id - ): - raise HTTPException(status_code=403, detail="Keine Berechtigung") - - -def _assert_delete_training_unit(role: str, created_by: int, profile_id: int) -> None: - if role not in ["admin", "superadmin"] and created_by != profile_id: + co_eff = _effective_co_trainer_ids_for_row(unit_row) + if role in ["admin", "superadmin"]: + return + gcid = unit_row.get("group_club_id") + if gcid is not None and can_manage_club_org(cur, profile_id, int(gcid), role): + return + if ( + unit_row["created_by"] != profile_id + and unit_row["trainer_id"] != profile_id + and profile_id not in co_eff + and unit_row.get("lead_trainer_profile_id") != profile_id + ): raise HTTPException(status_code=403, detail="Keine Berechtigung") +def _assert_delete_training_unit( + cur, + role: str, + created_by: int, + profile_id: int, + group_club_id: Optional[int], +) -> None: + if role in ["admin", "superadmin"]: + return + if created_by == profile_id: + return + if group_club_id is not None and can_manage_club_org(cur, profile_id, int(group_club_id), role): + return + raise HTTPException(status_code=403, detail="Keine Berechtigung") + + def _assert_club_visible_for_trainer(cur, club_id: int, profile_id: int, role: str) -> None: - """Nicht-Admin: mindestens eine aktive Gruppe im Verein als Trainer/Co-Trainer.""" + """Nicht-Admin: Vereinsbezug für Listen mit club_id (Mitglied genügt; Details filtert WHERE).""" if role in ("admin", "superadmin"): return + if can_manage_club_org(cur, profile_id, club_id, role): + return + cur.execute( + """ + SELECT 1 FROM club_members + WHERE club_id = %s AND profile_id = %s AND status = 'active' + LIMIT 1 + """, + (club_id, profile_id), + ) + if cur.fetchone(): + return cur.execute( """ SELECT 1 FROM training_groups g @@ -145,8 +248,9 @@ def _normalize_lead_trainer_profile_id( raw_lead: Any, profile_id: int, role: str, + unit_created_by: Optional[int], ) -> Optional[int]: - """NULL = Vertretung aufheben; sonst Profil-ID mit Profil-Check und Gruppenkontext.""" + """NULL = Standard (Gruppen-Haupttrainer); sonst gültiges Profil i. d. R. mit Vereinsbezug.""" if raw_lead is None: return None if raw_lead in ("", []): @@ -160,27 +264,130 @@ def _normalize_lead_trainer_profile_id( cur.execute("SELECT 1 FROM profiles WHERE id = %s", (nid,)) if not cur.fetchone(): raise HTTPException(status_code=400, detail="Profil für Leitung nicht gefunden") - if role in ("admin", "superadmin"): - return nid - if nid == profile_id: - return nid + cur.execute( - "SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s", + "SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s", (group_id,), ) gr = cur.fetchone() if not gr: raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden") - eligible = {gr["trainer_id"]} if gr.get("trainer_id") else set() - for x in gr.get("co_trainer_ids") or []: - eligible.add(x) - if nid in eligible: - return nid - raise HTTPException( - status_code=403, - detail="Lead-Trainer kann nur eigene Person, Haupttrainer oder Co-Trainer der Gruppe sein", - ) + grd = dict(gr) + cid = grd.get("club_id") + if cid is None: + raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden") + club_i = int(cid) + if is_platform_admin(role): + return nid + + eligible = {grd["trainer_id"]} if grd.get("trainer_id") else set() + for x in grd.get("co_trainer_ids") or []: + try: + eligible.add(int(x)) + except (TypeError, ValueError): + continue + + if nid == profile_id: + if not _profile_active_in_club(cur, club_i, profile_id): + raise HTTPException( + status_code=403, + detail="Nur aktive Vereinsmitglieder können die Leitung dieser Einheit übernehmen", + ) + return nid + + if nid not in eligible: + if not _profile_active_in_club(cur, club_i, nid): + raise HTTPException( + status_code=400, + detail="Leitung nur für Profile mit aktiver Mitgliedschaft im Verein der Gruppe", + ) + if not _caller_may_assign_session_trainers(cur, grd, profile_id, role, unit_created_by): + raise HTTPException( + status_code=403, + detail="Keine Berechtigung, die Leitung zuzuweisen", + ) + return nid + + if nid != profile_id and not _caller_may_assign_session_trainers( + cur, grd, profile_id, role, unit_created_by + ): + raise HTTPException(status_code=403, detail="Keine Berechtigung, die Leitung anderen zuzuweisen") + return nid + + +def _normalize_assistant_trainer_profile_ids( + cur, + group_id: int, + raw_val: Any, + profile_id: int, + role: str, + unit_created_by: Optional[int], + lead_nid: Optional[int], +) -> Any: + """ + None = Vererbung aus training_groups.co_trainer_ids (SQL NULL); + Liste = Session-Co-Trainer (JSONB Array; leeres Array ausdrücklich ohne Co.) + """ + if raw_val is None: + return None + if not isinstance(raw_val, list): + raise HTTPException(status_code=400, detail="assistant_trainer_profile_ids muss Liste oder null sein") + + ids_in: List[int] = [] + for x in raw_val: + try: + i = int(x) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="assistant_trainer_profile_ids ungültig") + if i < 1: + raise HTTPException(status_code=400, detail="assistant_trainer_profile_ids ungültig") + ids_in.append(i) + uniq = sorted(set(ids_in)) + + cur.execute( + "SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s", + (group_id,), + ) + gr = cur.fetchone() + if not gr: + raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden") + grd = dict(gr) + cid = grd.get("club_id") + if cid is None: + raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden") + club_i = int(cid) + + if not is_platform_admin(role) and not _caller_may_assign_session_trainers( + cur, grd, profile_id, role, unit_created_by + ): + raise HTTPException(status_code=403, detail="Keine Berechtigung, Co-Trainer zuzuweisen") + + eligible = {grd["trainer_id"]} if grd.get("trainer_id") else set() + for x in grd.get("co_trainer_ids") or []: + try: + eligible.add(int(x)) + except (TypeError, ValueError): + continue + + eff_lead = lead_nid if lead_nid is not None else (grd.get("trainer_id") or None) + + for nid in uniq: + cur.execute("SELECT 1 FROM profiles WHERE id = %s", (nid,)) + if not cur.fetchone(): + raise HTTPException(status_code=400, detail="Profil für Co-Trainer nicht gefunden") + if eff_lead is not None and nid == eff_lead: + raise HTTPException(status_code=400, detail="Leitung und Co-Trainer dürfen sich nicht überschneiden") + if is_platform_admin(role): + continue + if nid in eligible: + continue + if not _profile_active_in_club(cur, club_i, nid): + raise HTTPException( + status_code=400, + detail="Co-Trainer nur mit aktiver Mitgliedschaft im Verein dieser Gruppe", + ) + return uniq # Nachverfolgbarkeit: Übernahmen aus Rahmenprogramm über origin_framework_slot_id _ORIGIN_LINEAGE_JOIN = """ @@ -775,14 +982,18 @@ def list_training_units( if gid and role not in ["admin", "superadmin"]: cur.execute( - "SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s AND status = 'active'", + "SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s AND status = 'active'", (gid,), ) gr = cur.fetchone() if not gr: raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden") - cob = gr["co_trainer_ids"] or [] - if gr["trainer_id"] != profile_id and profile_id not in cob: + gd = dict(gr) + cob = gd.get("co_trainer_ids") or [] + ok_staff = gd.get("trainer_id") == profile_id or profile_id in cob + ok_org = can_manage_club_org(cur, profile_id, int(gd["club_id"]), role) + ok_member = _profile_active_in_club(cur, int(gd["club_id"]), profile_id) + if not (ok_staff or ok_org or ok_member): raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Gruppe") order_dir = "ASC" if (sort or "").strip().lower() == "asc" else "DESC" @@ -805,6 +1016,8 @@ def list_training_units( p.name as trainer_name, p.name as creator_name, COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id, + COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb) + AS effective_assistant_trainer_profile_ids, leadp.name AS lead_trainer_name """ query += "," + _ORIGIN_LINEAGE_FIELDS @@ -820,12 +1033,27 @@ def list_training_units( where = [] params = [] - if role not in ["admin", "superadmin"]: - where.append( - "(tu.created_by = %s OR tg.trainer_id = %s OR " - "(tg.co_trainer_ids IS NOT NULL AND tg.co_trainer_ids @> jsonb_build_array(%s::int)))" + skip_involvement_filter = role in ("admin", "superadmin") + if not skip_involvement_filter and cid is not None: + if can_manage_club_org(cur, profile_id, cid, role): + skip_involvement_filter = True + if not skip_involvement_filter and gid is not None: + cur.execute( + "SELECT club_id FROM training_groups WHERE id = %s AND status = 'active'", + (gid,), ) - params.extend([profile_id, profile_id, profile_id]) + gcx = cur.fetchone() + if gcx and gcx.get("club_id") is not None: + if can_manage_club_org(cur, profile_id, int(gcx["club_id"]), role): + skip_involvement_filter = True + + if not skip_involvement_filter: + where.append( + "(tu.created_by = %s OR tg.trainer_id = %s OR tu.lead_trainer_profile_id = %s OR " + "COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb) " + "@> jsonb_build_array(%s::int))" + ) + params.extend([profile_id, profile_id, profile_id, profile_id]) where.append("tu.framework_slot_id IS NULL") @@ -840,7 +1068,8 @@ def list_training_units( if assigned_to_me: where.append( "(COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) = %s OR " - "(tg.co_trainer_ids IS NOT NULL AND tg.co_trainer_ids @> jsonb_build_array(%s::int)))" + "COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb) " + "@> jsonb_build_array(%s::int))" ) params.extend([profile_id, profile_id]) @@ -890,7 +1119,10 @@ def get_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_c p.name as creator_name, tg.trainer_id AS trainer_id, tg.co_trainer_ids AS co_trainer_ids, + tg.club_id AS group_club_id, COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id, + COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb) + AS effective_assistant_trainer_profile_ids, leadp.name AS lead_trainer_name, """ + _ORIGIN_LINEAGE_FIELDS.strip() + """ FROM training_units tu @@ -957,27 +1189,77 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_ tpl_id_safe = plan_template_id cur.execute( - """ - INSERT INTO training_units ( - group_id, planned_date, planned_time_start, planned_time_end, - planned_focus, status, notes, trainer_notes, created_by, - plan_template_id - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - RETURNING id - """, - ( - group_id, - planned_date, - data.get("planned_time_start"), - data.get("planned_time_end"), - data.get("planned_focus"), - data.get("status", "planned"), - data.get("notes"), - data.get("trainer_notes"), - profile_id, - tpl_id_safe, - ), + "SELECT trainer_id FROM training_groups WHERE id = %s", + (int(group_id),), ) + g0 = cur.fetchone() + default_group_trainer = g0["trainer_id"] if g0 else None + + lead_ins: Optional[int] = None + if "lead_trainer_profile_id" in data: + lead_ins = _normalize_lead_trainer_profile_id( + cur, + int(group_id), + data.get("lead_trainer_profile_id"), + profile_id, + role, + profile_id, + ) + assistant_val: Any = None + assistant_set = False + if "assistant_trainer_profile_ids" in data: + assistant_set = True + eff_lead_for_co = lead_ins if lead_ins is not None else default_group_trainer + assistant_val = _normalize_assistant_trainer_profile_ids( + cur, + int(group_id), + data.get("assistant_trainer_profile_ids"), + profile_id, + role, + profile_id, + eff_lead_for_co, + ) + + base_params = ( + group_id, + planned_date, + data.get("planned_time_start"), + data.get("planned_time_end"), + data.get("planned_focus"), + data.get("status", "planned"), + data.get("notes"), + data.get("trainer_notes"), + profile_id, + tpl_id_safe, + lead_ins, + ) + if assistant_set: + cur.execute( + """ + INSERT INTO training_units ( + group_id, planned_date, planned_time_start, planned_time_end, + planned_focus, status, notes, trainer_notes, created_by, + plan_template_id, + lead_trainer_profile_id, + assistant_trainer_profile_ids + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, + base_params + (assistant_val,), + ) + else: + cur.execute( + """ + INSERT INTO training_units ( + group_id, planned_date, planned_time_start, planned_time_end, + planned_focus, status, notes, trainer_notes, created_by, + plan_template_id, + lead_trainer_profile_id + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, + base_params, + ) unit_id = cur.fetchone()["id"] @@ -1066,8 +1348,13 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen tuple(blueprint_params), ) else: + cur_lead = unit_row.get("lead_trainer_profile_id") + base_tr = unit_row.get("trainer_id") lead_sql = "" lead_params: List[Any] = [] + assist_sql = "" + assist_params: List[Any] = [] + nl: Optional[int] if "lead_trainer_profile_id" in data: nl = _normalize_lead_trainer_profile_id( cur, @@ -1075,9 +1362,27 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen data.get("lead_trainer_profile_id"), profile_id, role, + unit_row.get("created_by"), ) lead_sql = ", lead_trainer_profile_id = %s" lead_params.append(nl) + eff_lead_for_co = nl if nl is not None else base_tr + else: + nl = cur_lead if cur_lead is not None else base_tr + eff_lead_for_co = nl + + if "assistant_trainer_profile_ids" in data: + na = _normalize_assistant_trainer_profile_ids( + cur, + unit_row["group_id"], + data.get("assistant_trainer_profile_ids"), + profile_id, + role, + unit_row.get("created_by"), + eff_lead_for_co, + ) + assist_sql = ", assistant_trainer_profile_ids = %s" + assist_params.append(na) cur.execute( f""" @@ -1096,6 +1401,7 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen plan_template_id = COALESCE(%s, plan_template_id), updated_at = NOW() {lead_sql} + {assist_sql} WHERE id = %s """, ( @@ -1113,6 +1419,7 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen tpl_id_val, ) + tuple(lead_params) + + tuple(assist_params) + (unit_id,), ) @@ -1152,7 +1459,12 @@ def delete_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenan cur = get_cursor(conn) cur.execute( - "SELECT created_by, framework_slot_id FROM training_units WHERE id = %s", + """ + SELECT tu.created_by, tu.framework_slot_id, tg.club_id AS group_club_id + FROM training_units tu + LEFT JOIN training_groups tg ON tu.group_id = tg.id + WHERE tu.id = %s + """, (unit_id,), ) @@ -1167,7 +1479,13 @@ def delete_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenan detail="Blueprint-Einheiten werden über das Rahmenprogramm verwaltet, nicht hier gelöscht.", ) - _assert_delete_training_unit(role, unit["created_by"], profile_id) + _assert_delete_training_unit( + cur, + role, + unit["created_by"], + profile_id, + unit.get("group_club_id"), + ) cur.execute("DELETE FROM training_units WHERE id = %s", (unit_id,)) conn.commit() diff --git a/backend/tests/test_exercises_delete_policy.py b/backend/tests/test_exercises_delete_policy.py new file mode 100644 index 0000000..a3296fb --- /dev/null +++ b/backend/tests/test_exercises_delete_policy.py @@ -0,0 +1,131 @@ +""" +DELETE /api/exercises/{id}: Mandanten-/Rollenlogik und Verwendungsblock (409). + +TestClient mit Overrides für Auth und TenantContext; DB via get_db/get_cursor gemockt. +""" +from __future__ import annotations + +import os +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +os.environ.setdefault("SKIP_DB_MIGRATE", "1") + +from auth import require_auth +from main import app +from tenant_context import TenantContext, get_tenant_context + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +@pytest.fixture(autouse=True) +def _clear_overrides() -> None: + yield + app.dependency_overrides.pop(require_auth, None) + app.dependency_overrides.pop(get_tenant_context, None) + + +def _mock_db_cm(mock_cur: MagicMock): + mock_conn = MagicMock() + mock_cm = MagicMock() + mock_cm.__enter__.return_value = mock_conn + mock_cm.__exit__.return_value = False + return mock_cm + + +def test_delete_trainer_private_own_ok(client: TestClient) -> None: + mock_cur = MagicMock() + mock_cur.fetchone.side_effect = [ + {"id": 7, "created_by": 42, "visibility": "private", "club_id": None}, + {"block_items": 0, "section_items": 0, "prog_edges": 0}, + ] + mock_cm = _mock_db_cm(mock_cur) + + app.dependency_overrides[require_auth] = lambda: {"profile_id": 42, "role": "trainer"} + app.dependency_overrides[get_tenant_context] = lambda: TenantContext( + profile_id=42, + global_role="trainer", + effective_club_id=5, + club_ids=frozenset({5}), + memberships=[], + ) + with patch("routers.exercises.get_db", return_value=mock_cm), patch( + "routers.exercises.get_cursor", return_value=mock_cur + ): + r = client.delete("/api/exercises/7", headers={"X-Auth-Token": "dummy"}) + assert r.status_code == 200 + assert r.json().get("ok") is True + + +def test_delete_trainer_club_exercise_forbidden_without_club_admin(client: TestClient) -> None: + mock_cur = MagicMock() + mock_cur.fetchone.side_effect = [ + {"id": 7, "created_by": 42, "visibility": "club", "club_id": 5}, + ] + mock_cm = _mock_db_cm(mock_cur) + + app.dependency_overrides[require_auth] = lambda: {"profile_id": 42, "role": "trainer"} + app.dependency_overrides[get_tenant_context] = lambda: TenantContext( + profile_id=42, + global_role="trainer", + effective_club_id=5, + club_ids=frozenset({5}), + memberships=[], + ) + with patch("routers.exercises.get_db", return_value=mock_cm), patch( + "routers.exercises.get_cursor", return_value=mock_cur + ), patch("routers.exercises.has_club_role", return_value=False): + r = client.delete("/api/exercises/7", headers={"X-Auth-Token": "dummy"}) + assert r.status_code == 403 + + +def test_delete_usage_returns_409(client: TestClient) -> None: + mock_cur = MagicMock() + mock_cur.fetchone.side_effect = [ + {"id": 7, "created_by": 42, "visibility": "private", "club_id": None}, + {"block_items": 1, "section_items": 2, "prog_edges": 3}, + ] + mock_cm = _mock_db_cm(mock_cur) + + app.dependency_overrides[require_auth] = lambda: {"profile_id": 42, "role": "trainer"} + app.dependency_overrides[get_tenant_context] = lambda: TenantContext( + profile_id=42, + global_role="trainer", + effective_club_id=None, + club_ids=frozenset(), + memberships=[], + ) + with patch("routers.exercises.get_db", return_value=mock_cm), patch( + "routers.exercises.get_cursor", return_value=mock_cur + ): + r = client.delete("/api/exercises/7", headers={"X-Auth-Token": "dummy"}) + assert r.status_code == 409 + detail = r.json().get("detail", "") + assert "Übungsblöcken" in detail or "Trainingsplänen" in detail + + +def test_delete_official_forbidden_non_platform_admin(client: TestClient) -> None: + mock_cur = MagicMock() + mock_cur.fetchone.side_effect = [ + {"id": 99, "created_by": 1, "visibility": "official", "club_id": None}, + ] + mock_cm = _mock_db_cm(mock_cur) + + app.dependency_overrides[require_auth] = lambda: {"profile_id": 42, "role": "trainer"} + app.dependency_overrides[get_tenant_context] = lambda: TenantContext( + profile_id=42, + global_role="trainer", + effective_club_id=None, + club_ids=frozenset(), + memberships=[], + ) + with patch("routers.exercises.get_db", return_value=mock_cm), patch( + "routers.exercises.get_cursor", return_value=mock_cur + ): + r = client.delete("/api/exercises/99", headers={"X-Auth-Token": "dummy"}) + assert r.status_code == 403 diff --git a/backend/tests/test_training_unit_assignments.py b/backend/tests/test_training_unit_assignments.py new file mode 100644 index 0000000..4a41b6f --- /dev/null +++ b/backend/tests/test_training_unit_assignments.py @@ -0,0 +1,17 @@ +"""Unit-Tests ohne DB: Zusammenführung Session-Co vs. Gruppe.""" +import pytest + +from routers.training_planning import effective_co_trainer_profile_ids_for_merge + + +@pytest.mark.parametrize( + "unit_side,group_side,expected", + [ + (None, [10, 22], [10, 22]), + (None, None, []), + ([], [10, 22], []), + ([7, "8", 7], None, [7, 8]), + ], +) +def test_effective_co_trainer_profile_ids_for_merge(unit_side, group_side, expected): + assert effective_co_trainer_profile_ids_for_merge(unit_side, group_side) == expected diff --git a/backend/version.py b/backend/version.py index b4065d0..6e41b32 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,12 +1,12 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.36" -BUILD_DATE = "2026-05-05" -DB_SCHEMA_VERSION = "20260505041" +APP_VERSION = "0.8.40" +BUILD_DATE = "2026-05-06" +DB_SCHEMA_VERSION = "20260506043" MODULE_VERSIONS = { "auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute) - "profiles": "1.6.0", # POST /profiles nur Plattform-Admin; Insert SERIAL + E-Mail wie Auth; Tests + "profiles": "1.7.0", # exercise_list_prefs JSONB (Standard Übungsfilter); Patch via ProfileUpdate + Json() "tenant_context": "1.0.4", # pytest: Unit test_access_layer.py + optional Integration test_access_layer_integration (PostgreSQL) "clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext "club_memberships": "1.0.1", # Depends(get_tenant_context) @@ -15,8 +15,8 @@ MODULE_VERSIONS = { "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", - "exercises": "2.7.0", # PATCH /exercises/bulk-metadata — Massenänderung Sichtbarkeit/Status - "training_units": "0.1.0", + "exercises": "2.10.0", # GET /exercises: focus_area_must_include/exclude_ids, focus_only_without_focus_areas; UI +/- Fokusregeln + "training_units": "0.2.0", "training_programs": "0.1.0", "planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile "import_wiki": "1.0.0", @@ -27,6 +27,42 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.40", + "date": "2026-05-06", + "changes": [ + "Übungen Liste: Fokusfilter mit UND-+ (must_include) und UND-− (must_exclude), nur ohne Fokusbereich (focus_only_without); Frontend Dropdown + Mit / − Ohne", + ], + }, + { + "version": "0.8.39", + "date": "2026-05-06", + "changes": [ + "Übungen DELETE: Nur eigene private / Vereinsadmin für Vereins-Übungen / Plattform für globale; keine harte Löschung bei Verwendung in Blöcken, Plan-Abschnitten oder Progressionskanten (409 → archivieren)", + "GET /api/exercises: Negativfilter (visibility_exclude_any, status_exclude_any), exclude_without_focus, include_archived; archivierte standardmäßig ausgeblendet", + "Profile exercise_list_prefs (JSONB, Migration 043): gespeicherte Standardfilter; Frontend Übungsliste Filterdialog + „Als Standard speichern“", + "Übungspicker: gleiche Negativfilter; Planung lädt archivierte Übungen immer mit (bestehende Zuordnungen)", + "pytest: tests/test_exercises_delete_policy.py", + ], + }, + { + "version": "0.8.38", + "date": "2026-05-06", + "changes": [ + "Trainingsplanung: Vereinsadmins sehen alle Einheiten bei club_id-/Gruppenliste; GET/PUT Einheit & Löschen mit can_manage_club_org", + "Planung UI: „Trainer zuweisen“ in Vereins-Ansicht (Liste + Kalender) + eigener Modal; Mitgliederverzeichnis für Vereinsorganisation", + ], + }, + { + "version": "0.8.37", + "date": "2026-05-05", + "changes": [ + "DB 042: training_units.assistant_trainer_profile_ids (Co-Trainer-Zuweisung je Termin; NULL = Gruppen-Standard)", + "Trainingseinheiten: POST/PUT lead_trainer_profile_id & assistant_trainer_profile_ids; Leitung für Vereinsmitglieder (Vertretung); GET-Listen inkl. Zuweisung für Sichtbarkeit/assigned_to_me", + "Frontend Trainingsplanung: Leitung/Co-Trainer pro Einheit; Dashboard-Text", + "pytest: tests/test_training_unit_assignments.py", + ], + }, { "version": "0.8.36", "date": "2026-05-05", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b545339..95c5db3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -55,7 +55,7 @@ function Nav({ isAdmin }) { 'nav-item' + (navItemActive(loc.pathname, item, isActive) ? ' active' : '') } > - + {item.shortLabel || item.label} ))} diff --git a/frontend/src/app.css b/frontend/src/app.css index 4ebd9a0..b9f372a 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1,7 +1,7 @@ :root { - --bg: #f4f3ef; + --bg: #f6f5f0; --surface: #ffffff; - --surface2: #f9f8f5; + --surface2: #fafaf6; --border: rgba(0,0,0,0.09); --border2: rgba(0,0,0,0.16); --text1: #1c1b18; @@ -12,9 +12,9 @@ --accent-dark: #0a5c43; --danger: #D85A30; --warn: #EF9F27; - /* Höhe der eigentlichen Tab-Zeile (ohne Abstand/Home-Indicator) */ - --nav-h: 56px; - --nav-pad-top: 8px; + /* Höhe der Tab-Zeile (Icon + Beschriftung, ohne Home-Indicator) — an Mitai/iOS-Tabbar angelehnt */ + --nav-h: 58px; + --nav-pad-top: 10px; --header-h: 52px; --font: system-ui, -apple-system, 'Segoe UI', sans-serif; --capture-content-max: 800px; @@ -27,6 +27,9 @@ --text1: #eeecea; --text2: #aaa9a4; --text3: #686762; --accent-light: #04342C; --accent-dark: #5DCAA5; } + .bottom-nav { + box-shadow: 0 -6px 28px rgba(0, 0, 0, 0.55); + } } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } html, body, #root { height: 100%; } @@ -168,18 +171,21 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we width: auto; max-width: none; display: flex; - align-items: center; + align-items: stretch; background: var(--surface); border-top: 1px solid var(--border); + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06); z-index: 20; overflow-x: auto; overflow-y: visible; -webkit-overflow-scrolling: touch; scrollbar-width: none; -ms-overflow-style: none; + scroll-snap-type: x proximity; + overscroll-behavior-x: contain; justify-content: flex-start; - gap: 2px; - padding: var(--nav-pad-top) max(6px, env(safe-area-inset-right, 0px)) env(safe-area-inset-bottom, 0px) max(6px, env(safe-area-inset-left, 0px)); + gap: 4px; + padding: var(--nav-pad-top) max(10px, env(safe-area-inset-right, 0px)) env(safe-area-inset-bottom, 0px) max(10px, env(safe-area-inset-left, 0px)); min-height: calc(var(--nav-h) + var(--nav-pad-top) + env(safe-area-inset-bottom, 0px)); height: auto; box-sizing: border-box; @@ -189,32 +195,55 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we } .nav-item { flex: 0 0 auto; - min-width: 56px; - max-width: 96px; + min-width: 68px; + max-width: 108px; + min-height: 48px; display: flex; flex-direction: column; align-items: center; justify-content: center; - gap: 3px; + gap: 4px; color: var(--text3); text-decoration: none; - font-weight: 500; + font-weight: 600; transition: color 0.15s; - padding: 2px 4px 4px; + padding: 6px 10px 8px; box-sizing: border-box; + scroll-snap-align: center; + -webkit-tap-highlight-color: transparent; } .nav-item span { - font-size: 10px; - line-height: 1.15; + font-size: 11px; + line-height: 1.2; text-align: center; max-width: 100%; + letter-spacing: 0.01em; } .nav-item.active { color: var(--accent); } .nav-item svg { flex-shrink: 0; } /* Cards */ .card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px; } +/* Vertikaler Rhythmus nur im normalen Blockfluss — in Grids/Flex mit gap stört margin-top zwischen Geschwistern */ .card + .card { margin-top: 12px; } +ul > li.card + li.card, +.exercises-list-grid > .card + .card, +.ref-value-tiles-grid > .card + .card, +.skills-page__card-grid > .card + .card, +.dashboard-training-grid > .card + .card, +.framework-slots-board > .card + .card, +[class*="slots-board"] > .card + .card, +.card-grid > .card + .card, +.clubs-groups-card-grid > .card + .card { + margin-top: 0; +} +/* Optional: Raster für Karten (Abstände nur über gap); Spalten per Modifier oder inline grid-template-columns */ +.card-grid { + display: grid; + gap: 14px; + align-items: stretch; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); +} .card-title { font-size: 13px; font-weight: 600; color: var(--text3); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 10px; } /* Stats grid */ @@ -961,120 +990,17 @@ a.analysis-split__nav-item { padding: 8px 10px; } -/* Erfassung: Sub-Navigation (Mobil = Chips, Desktop = linke Spalte) */ -.capture-shell { +/* Legacy .capture-shell entfällt — Sektionsnav: PageSectionNav → .admin-page-subtabs */ +.app-subnav-shell { width: 100%; -} - -.capture-shell__layout { display: flex; flex-direction: column; gap: 16px; } - -.capture-shell__nav { - display: flex; - flex-direction: row; - gap: 6px; - overflow-x: auto; - padding-bottom: 6px; - -ms-overflow-style: none; - scrollbar-width: none; -} - -.capture-shell__nav::-webkit-scrollbar { - display: none; -} - -.capture-shell__nav-item { - flex-shrink: 0; - display: inline-flex; - align-items: center; - gap: 6px; - padding: 7px 12px; - border-radius: 20px; - border: 1.5px solid var(--border2); - background: var(--surface); - color: var(--text2); - font-family: var(--font); - font-size: 13px; - font-weight: 500; - text-decoration: none; - white-space: nowrap; - cursor: pointer; - box-sizing: border-box; -} - -.capture-shell__nav-item:hover { - border-color: var(--accent); - color: var(--text1); -} - -.capture-shell__nav-item--active { - border-color: var(--accent); - background: var(--accent); - color: white; -} - -.capture-shell__nav-item--active:hover { - color: white; -} - -.capture-shell__nav-item--highlight:not(.capture-shell__nav-item--active) { - border-color: #7f77dd88; - background: #7f77dd14; -} - -.capture-shell__nav-icon { - font-size: 15px; - line-height: 1; -} - -.capture-shell__nav-label { - line-height: 1.2; -} - -.capture-shell__main { +.app-subnav-shell__main { min-width: 0; } -@media (min-width: 1024px) { - .capture-shell__layout { - flex-direction: row; - align-items: flex-start; - gap: 24px; - } - - .capture-shell__nav-wrap { - flex: 0 0 260px; - max-width: 280px; - position: sticky; - top: 16px; - align-self: flex-start; - } - - .capture-shell__nav { - flex-direction: column; - overflow-x: visible; - overflow-y: auto; - max-height: calc(100vh - 140px); - padding-bottom: 0; - gap: 8px; - } - - .capture-shell__nav-item { - width: 100%; - justify-content: flex-start; - border-radius: 10px; - white-space: normal; - padding: 9px 12px; - } - - .capture-shell__main { - flex: 1; - } -} - /* Einstellungen: gleiche Split-Struktur wie Analyse/Admin */ .settings-shell { width: 100%; @@ -1135,6 +1061,654 @@ a.analysis-split__nav-item { background: var(--surface2); } +/* Admin: horizontale Seiten-Weiche (Hierarchie · Nutzer · …) */ +.admin-top-nav { + display: flex; + gap: 8px; + border-bottom: 2px solid var(--border); + margin-bottom: 24px; + flex-wrap: wrap; +} +.admin-top-nav__link { + padding: 12px 18px; + background: transparent; + border: none; + border-bottom: 3px solid transparent; + margin-bottom: -2px; + cursor: pointer; + font-size: 15px; + font-weight: 500; + color: var(--text2); + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 8px; + transition: color 0.15s, background 0.15s, border-color 0.15s; + font-family: inherit; + border-radius: 8px 8px 0 0; + box-sizing: border-box; +} +.admin-top-nav__link:hover { + color: var(--text1); + background: var(--surface2); +} +.admin-top-nav__link--active { + color: var(--accent); + border-bottom-color: var(--accent); + background: transparent; +} + +/* + * Mobile (≤1023px): geringerer Platzbedarf bei Schaltern und Menüs. + * Haupt- + Sub-Navigation: horizontale, scrollbare Chip-Leisten (kein Umbruch). + */ +@media (max-width: 1023px) { + .app-main { + padding-top: 12px; + padding-left: max(12px, env(safe-area-inset-left, 0px)); + padding-right: max(12px, env(safe-area-inset-right, 0px)); + } + + .page-title { + font-size: 1.125rem; + margin-bottom: 10px; + letter-spacing: -0.02em; + } + + .btn { + padding: 8px 14px; + font-size: 13px; + border-radius: 9px; + } + + .card { + padding: 12px 14px; + } + + .card + .card { + margin-top: 10px; + } + + /* globale Tab-Zeile: bei Bedarf horizontal scrollbar */ + .tabs { + display: flex; + flex-wrap: nowrap; + overflow-x: auto; + overflow-y: hidden; + gap: 2px; + padding: 2px; + margin-bottom: 12px; + border-radius: 8px; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + + .tabs::-webkit-scrollbar { + display: none; + } + + .tab { + flex: 0 0 auto; + min-height: 38px; + padding: 6px 12px; + font-size: 12px; + border-radius: 7px; + } + + .admin-top-nav { + flex-wrap: nowrap; + overflow-x: auto; + overflow-y: hidden; + gap: 6px; + border-bottom: none; + margin-bottom: 14px; + padding-bottom: 4px; + margin-left: calc(-1 * max(12px, env(safe-area-inset-left, 0px))); + margin-right: calc(-1 * max(12px, env(safe-area-inset-right, 0px))); + padding-left: max(12px, env(safe-area-inset-left, 0px)); + padding-right: max(12px, env(safe-area-inset-right, 0px)); + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + scroll-snap-type: x proximity; + } + + .admin-top-nav::-webkit-scrollbar { + display: none; + } + + .admin-top-nav__link { + flex: 0 0 auto; + scroll-snap-align: start; + min-height: 36px; + padding: 6px 11px; + font-size: 12px; + font-weight: 600; + border-bottom: none; + margin-bottom: 0; + border-radius: 999px; + background: var(--surface2); + border: 1px solid var(--border2); + gap: 5px; + box-sizing: border-box; + align-items: center; + } + + .admin-top-nav__link svg { + width: 15px; + height: 15px; + flex-shrink: 0; + } + + .admin-top-nav__link--active { + background: var(--accent); + color: #fff !important; + border-color: var(--accent); + } + + .admin-top-nav__link:hover { + background: var(--surface); + color: var(--text1); + } + + .admin-top-nav__link--active:hover { + color: #fff !important; + background: color-mix(in srgb, var(--accent) 90%, #000); + } + + /* Segment-Schalter (Liste/Kalender, Gruppe/Verein, Rahmen-Tabs) */ + .planning-segment-group { + border-radius: 8px; + } + + .planning-segment-group__btn { + padding: 6px 10px; + font-size: 0.78rem; + min-height: 34px; + box-sizing: border-box; + } + + .planning-segment-group--comfort .planning-segment-group__btn { + padding: 7px 11px; + font-size: 0.8125rem; + min-height: 36px; + } + + .planning-segment-group--equal .planning-segment-group__btn { + min-width: 0; + flex: 1 1 0; + } + + .framework-edit__tabbar { + padding: 4px 0 8px; + margin-bottom: 10px; + gap: 6px; + } + + .framework-edit__tabbar .admin-page-subtabs__btn { + padding: 6px 8px; + font-size: 11px; + line-height: 1.25; + min-height: 34px; + } + + .training-planning-create__cta, + .training-planning-create__secondary { + min-height: 42px; + padding-left: 1rem; + padding-right: 1rem; + } + + .admin-assignments-wrap { + padding: 14px; + } + + .admin-assignments-wrap__title { + font-size: 1.05rem; + } + + /* Framework-Slot-Chips (falls sehr breit): leicht kompakter */ + .framework-slot-chip { + padding: 7px 12px; + font-size: 0.8rem; + } + + .admin-page-subtabs { + margin-left: calc(-1 * max(12px, env(safe-area-inset-left, 0px))); + margin-right: calc(-1 * max(12px, env(safe-area-inset-right, 0px))); + padding-left: max(12px, env(safe-area-inset-left, 0px)); + padding-right: max(12px, env(safe-area-inset-right, 0px)); + margin-bottom: 14px; + border-bottom: none; + padding-bottom: 6px; + } + + .admin-catalog-section { + padding: 14px; + } + + .exercises-page-toolbar-tabs, + .skills-page__tabs-scroll { + margin-left: calc(-1 * max(12px, env(safe-area-inset-left, 0px))); + margin-right: calc(-1 * max(12px, env(safe-area-inset-right, 0px))); + padding-left: max(12px, env(safe-area-inset-left, 0px)); + padding-right: max(12px, env(safe-area-inset-right, 0px)); + padding-bottom: 4px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + + .exercises-page-toolbar-tabs::-webkit-scrollbar, + .skills-page__tabs-scroll::-webkit-scrollbar { + display: none; + } +} + +/* Trainingsplanung: kompakte Segmente (Gruppe / Verein) */ +.planning-segment-group { + display: inline-flex; + border-radius: 10px; + border: 1.5px solid var(--border2); + overflow: hidden; + background: var(--surface2); +} +.planning-segment-group__btn { + border: none; + padding: 8px 14px; + font-weight: 600; + font-size: 0.85rem; + cursor: pointer; + font-family: inherit; + background: transparent; + color: var(--text1); + white-space: nowrap; + transition: background 0.12s, color 0.12s; +} +.planning-segment-group__btn:disabled { + cursor: not-allowed; + opacity: 0.55; +} +.planning-segment-group__btn--active { + background: var(--accent); + color: #fff; +} +.planning-segment-group__btn:not(:first-child) { + border-left: 1.5px solid var(--border2); +} + +/* Gleich breite Segment-Buttons (z. B. mobile Rahmenprogramm-Tabs) */ +.planning-segment-group--equal { + flex: 1; + min-width: 0; +} +.planning-segment-group--equal .planning-segment-group__btn { + flex: 1; + min-width: 0; +} + +/* Etwas größere Segmente (Planung: Liste / Kalender) */ +.planning-segment-group--comfort .planning-segment-group__btn { + padding: 10px 18px; + font-size: 0.92rem; +} + +/* ---------- Navigations-Ebenen (Kurzreferenz) ---------- + * Hauptbereiche: Bottom-Nav / App-Header. + * Sektionsumschalter auf einer Seite: PageSectionNav → .admin-page-subtabs (Chips, mobil Edge-Scroll). + * AppSubnavShell = PageSectionNav + Inhalt (z. B. Hierarchie-Admin). + * Wechsel zwischen Admin-Seiten → .admin-top-nav + * „Sub-Sub“ (dritte Ebene): Feature-Layouts (Rahmen-Editor, Slots). + * Karten-Raster: .card-grid / *list-grid* / *slots-board* — nur gap, kein .card+.card. + * ---------- */ + +/* Admin-Kataloge: Seite „Stammdaten“ — viele Unter-Tabs, Chip-Scroll */ +.admin-page-subtabs { + display: flex; + flex-wrap: nowrap; + gap: 6px; + overflow-x: auto; + overflow-y: hidden; + margin-bottom: 18px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + scroll-snap-type: x proximity; +} +.admin-page-subtabs::-webkit-scrollbar { + display: none; +} +.admin-page-subtabs__btn { + flex: 0 0 auto; + scroll-snap-align: start; + margin: 0; + font-family: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + padding: 8px 13px; + border-radius: 999px; + border: 1px solid var(--border2); + background: var(--surface2); + color: var(--text2); + transition: background 0.12s, color 0.12s, border-color 0.12s; + -webkit-tap-highlight-color: transparent; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; +} +.admin-page-subtabs__btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.admin-page-subtabs__btn:disabled:hover { + border-color: var(--border2); + color: var(--text2); + background: var(--surface2); +} +.admin-page-subtabs__btn:hover { + border-color: var(--accent); + color: var(--text1); +} +.admin-page-subtabs__btn--active { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +.admin-page-subtabs__btn--active:hover { + color: #fff; + background: color-mix(in srgb, var(--accent) 92%, #000); +} +@media (min-width: 1024px) { + .admin-page-subtabs__btn { + font-size: 13px; + padding: 9px 15px; + } + .page-section-nav--wrap.admin-page-subtabs { + flex-wrap: wrap; + } +} + +.page-section-nav__icon { + flex-shrink: 0; +} +/* Eingebettet in z. B. framework-edit__tabbar — keine zweite Unterlinie */ +.page-section-nav--embedded.admin-page-subtabs { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; + flex: 1; + min-width: 0; +} +/* Inline neben Labeln (Planung: Ansicht / Einblenden) */ +.page-section-nav--inline.admin-page-subtabs { + display: inline-flex; + width: auto; + max-width: 100%; + margin-bottom: 0; + flex: 0 1 auto; + border-bottom: none; + padding-bottom: 0; +} + +/* Admin Hierarchy & Catalog Section (Komponenten) */ +.admin-hierarchy-layout { + display: flex; + flex-direction: column; + gap: 16px; +} +.admin-hierarchy-pane { + border: 1px solid var(--border); + border-radius: 12px; + padding: 14px 16px; + background: var(--surface); +} +.admin-hierarchy-pane__title { + margin-top: 0; + margin-bottom: 12px; + font-size: 1.15rem; + font-weight: 700; +} +.admin-hierarchy-back { + margin-bottom: 14px; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.admin-catalog-stack { + display: grid; + grid-template-columns: 1fr; + gap: 1rem; +} +@media (min-width: 1024px) { + .admin-catalog-stack { + gap: 1.25rem; + } +} +.admin-catalog-section { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 16px; +} +@media (min-width: 1024px) { + .admin-catalog-section { + padding: 20px; + } +} +.admin-catalog-section__head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 16px; +} +.admin-catalog-section__title { + margin: 0; + font-size: 1.05rem; + font-weight: 700; + display: inline-flex; + align-items: center; + gap: 8px; +} +.admin-catalog-section__icon { + flex-shrink: 0; + color: var(--accent-dark); +} +@media (prefers-color-scheme: dark) { + .admin-catalog-section__icon { + color: var(--accent); + } +} +.admin-catalog-inline-form { + margin-bottom: 16px; + padding: 14px; + background: var(--surface2); + border-radius: 10px; + border: 1px solid var(--border); +} +.admin-catalog-inline-form h4 { + margin-top: 0; + margin-bottom: 12px; + font-size: 0.95rem; + font-weight: 700; +} +.admin-catalog-list { + display: grid; + gap: 10px; +} +.admin-catalog-item { + padding: 12px; + background: var(--surface2); + border-radius: 10px; + border: 1px solid var(--border); +} +.admin-catalog-item__name-row { + margin-bottom: 8px; +} +.admin-catalog-meta { + margin-left: 10px; + color: var(--text3); + font-size: 0.875rem; +} +.admin-catalog-desc { + color: var(--text2); + font-size: 0.875rem; + margin: 8px 0; +} +.admin-catalog-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} +.admin-catalog-empty { + text-align: center; + color: var(--text3); + padding: 1.25rem; + font-size: 0.9rem; +} + +/* Ausklappbare Kontext-Hilfe (Filterzeile Planung) */ +.planning-filter-help { + flex: 1 1 100%; + margin-top: 4px; + max-width: 100%; +} +.planning-filter-help__summary { + cursor: pointer; + font-size: 0.8rem; + font-weight: 600; + color: var(--accent-dark); + list-style: none; + user-select: none; +} +.planning-filter-help__summary::-webkit-details-marker { + display: none; +} +.planning-filter-help__body { + margin-top: 10px; + padding: 12px 14px; + font-size: 0.82rem; + line-height: 1.5; + color: var(--text2); + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 10px; +} +@media (prefers-color-scheme: dark) { + .planning-filter-help__summary { + color: var(--accent); + } +} + +/* Rahmenprogramm-Editor: Kurz-Einstieg ausklappbar */ +.framework-edit-intro { + margin-bottom: 1rem; +} +.framework-edit-intro__summary { + cursor: pointer; + font-size: 0.88rem; + font-weight: 600; + color: var(--accent-dark); + list-style: none; + user-select: none; + padding: 10px 12px; + border-radius: 10px; + border: 1px dashed var(--border2); + background: var(--surface2); +} +.framework-edit-intro__summary::-webkit-details-marker { + display: none; +} +.framework-edit-intro__body { + margin-top: 10px; + padding: 12px 14px; + font-size: 0.88rem; + line-height: 1.55; + color: var(--text2); + border-radius: 10px; + border: 1px solid var(--border); + background: var(--surface); +} +@media (prefers-color-scheme: dark) { + .framework-edit-intro__summary { + color: var(--accent); + } +} + +/* Admin: Zuordnungsmatrix (Stilrichtungen ↔ Zielgruppen) */ +.admin-assignments-wrap { + background: var(--surface); + border-radius: 12px; + padding: 20px; +} +.admin-assignments-wrap__title { + margin-top: 0; + font-size: 1.15rem; + font-weight: 700; +} +.admin-assignments-matrix-container { + overflow-x: auto; + margin-top: 20px; + -webkit-overflow-scrolling: touch; +} +.admin-assignments-matrix { + width: 100%; + border-collapse: collapse; + min-width: 600px; +} +.admin-assignments-matrix th, +.admin-assignments-matrix td { + border: 1px solid var(--border); + padding: 12px; +} +.admin-assignments-matrix th { + background: var(--surface2); + font-weight: 600; + color: var(--text1); +} +.admin-assignments-matrix__corner { + position: sticky; + left: 0; + background: var(--surface); + z-index: 2; +} +.admin-assignments-matrix__row-label { + position: sticky; + left: 0; + background: var(--surface); + z-index: 1; + padding: 12px; + font-weight: 500; +} +.admin-assignments-matrix tbody tr:hover { + background: var(--surface2); +} +.admin-assignments-matrix__focus-header td { + background: var(--surface2); + padding: 8px 12px; + font-weight: 600; + color: var(--text2); +} +.admin-assignments-matrix__th-narrow { + text-align: center; + padding: 12px; +} +@media (max-width: 768px) { + .admin-assignments-matrix { + font-size: 14px; + } + .admin-assignments-matrix th, + .admin-assignments-matrix td { + padding: 8px; + } +} + /* Admin: Split-Layout wie .analysis-split (nur Gruppen in der Nav) */ .admin-shell { width: 100%; @@ -1652,6 +2226,460 @@ a.analysis-split__nav-item { overscroll-behavior: contain; } +.skills-page-modal.admin-modal-sheet { + max-width: min(600px, 100vw - 32px); +} + +/* Admin Hierarchie: Detail-Panel */ +.detail-panel__title { + margin-top: 0; +} +.detail-panel__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 20px; +} +.detail-panel__context { + padding: 12px; + margin-bottom: 20px; + border-radius: 8px; + background: var(--surface2); + color: var(--text2); +} +.detail-panel__unknown { + padding: 20px; + color: var(--text3); +} + +/* Seite Fähigkeiten & Methoden */ +.skills-page__loading { + padding: 2rem; + text-align: center; +} +.skills-page__tabs-scroll { + margin-bottom: 1.5rem; +} +.skills-page__intro-row { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 1rem; +} +.skills-page__intro-row p { + margin: 0; + flex: 1 1 12rem; + color: var(--text2); +} +.skills-page__empty { + margin: 0; + text-align: center; + color: var(--text2); +} +.skills-page__category { + margin-bottom: 2rem; +} +.skills-page__category-title { + margin: 0 0 1rem; + text-transform: capitalize; +} +.skills-page__card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; +} +.skills-page__card-grid--methods { + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); +} +.skills-page-card { + display: flex; + flex-direction: column; + height: 100%; +} +.skills-page-card__head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 8px; + margin-bottom: 0.5rem; +} +.skills-page-card__meta-block { + margin-bottom: 0.5rem; +} +.skills-page-card__title { + margin: 0; + font-size: 1rem; +} +.skills-page-card__title--method { + margin: 0 0 0.25rem; +} +.skills-page-card__abbr { + color: var(--text2); + font-size: 0.875rem; + margin-left: 0.5rem; + font-weight: 400; +} +.skills-page-card__badge { + flex-shrink: 0; + font-size: 0.875rem; + padding: 0.25rem 0.5rem; + border-radius: 4px; + background: var(--accent); + color: #fff; +} +.skills-page-card__meta-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} +.skills-page-card__chip { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + border-radius: 4px; + background: var(--surface2); + color: var(--text2); +} +.skills-page-card__desc { + margin: 0 0 1rem; + color: var(--text2); + font-size: 0.875rem; +} +.skills-page-card__actions { + display: flex; + gap: 0.5rem; + margin-top: auto; +} +.skills-page-card__grow { + flex: 1; +} +.skills-page-modal__footer { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1.5rem; +} +.skills-page-modal__submit { + flex: 1; +} + +.clubs-page__intro { + margin: 0 0 1.25rem; + max-width: 46rem; + line-height: 1.55; +} + +/* Übungsliste: Kopf, Modus-Segmente, Hinweise */ +.exercises-page__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + flex-wrap: wrap; + gap: 8px; +} +.exercises-page__title { + margin: 0; +} +.exercises-page-toolbar-tabs { + margin-bottom: 14px; +} +.exercise-search-hint { + font-size: 12px; + color: var(--text3); + margin-top: 10px; + margin-bottom: 0; + line-height: 1.45; +} +.exercise-search-hint .btn { + margin-left: 6px; + vertical-align: middle; +} +.exercise-search-bar { + margin-bottom: 12px; +} +.exercise-search-bar__primary { + margin-bottom: 10px; +} +.exercise-bulk-toolbar { + margin-bottom: 12px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} +.exercise-bulk-toolbar__meta { + font-size: 12px; + color: var(--text3); + line-height: 1.4; + flex: 1 1 200px; +} +.exercises-list-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); + gap: 14px; + align-items: stretch; +} +.exercises-list-grid > .exercise-card { + height: 100%; + min-height: 0; +} +.exercise-card-layout { + display: flex; + gap: 10px; + align-items: flex-start; +} +.exercise-card-layout--grow { + flex: 1 1 auto; + min-height: 0; + width: 100%; +} +.exercise-card-layout__check { + margin-top: 4px; + flex-shrink: 0; + accent-color: var(--accent); +} +.exercise-card-body-flex { + flex: 1; + min-width: 0; +} +.exercise-card-title { + margin: 0 0 8px; + font-size: 1.05rem; + line-height: 1.3; + font-weight: 700; +} +.exercise-card-title a { + color: inherit; + text-decoration: none; +} +.exercise-card-title a:hover { + color: var(--accent-dark); +} +@media (prefers-color-scheme: dark) { + .exercise-card-title a:hover { + color: var(--accent); + } +} +.exercise-card-tags { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 8px; +} +.exercise-card-summary { + color: var(--text2); + font-size: 13px; + line-height: 1.4; + margin: 0; +} +.exercise-card-summary--rich { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; + overflow: hidden; + word-break: break-word; +} +.exercise-card-summary--rich b, +.exercise-card-summary--rich strong { + font-weight: 700; + color: var(--text1); +} +.exercise-card-summary--rich i, +.exercise-card-summary--rich em { + font-style: italic; +} +.exercise-card-summary--rich p { + margin: 0 0 0.35em; +} +.exercise-card-summary--rich p:last-child { + margin-bottom: 0; +} +.exercises-meta-line { + font-size: 13px; + color: var(--text2); + margin: 0 0 10px; +} +.exercises-meta-line--muted { + color: var(--text3); + margin-bottom: 8px; +} +.exercises-load-more { + text-align: center; + margin-top: 16px; +} +.exercises-empty-text { + margin: 0; + color: var(--text2); + text-align: center; +} + +/* Admin Hierarchie-Baum (Fokusbereich) */ +.focus-tree-root { + margin-bottom: 12px; +} +.focus-tree-header { + display: flex; + align-items: stretch; + gap: 2px; + padding: 4px 4px 4px 6px; + border-radius: 10px; + border: 1px solid transparent; + background: transparent; + transition: background 0.12s, border-color 0.12s; +} +.focus-tree-header:hover { + background: var(--surface2); + border-color: var(--border); +} +.focus-tree-header--selected { + background: var(--accent); + border-color: var(--accent); +} +.focus-tree-header--selected:hover { + background: color-mix(in srgb, var(--accent) 94%, #000); + border-color: var(--accent); +} +.focus-tree-toggle { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + min-height: 36px; + margin: 0; + padding: 0; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text2); + cursor: pointer; + align-self: center; + -webkit-tap-highlight-color: transparent; +} +.focus-tree-toggle:hover { + background: rgba(0, 0, 0, 0.05); +} +.focus-tree-header--selected .focus-tree-toggle { + color: #fff; +} +.focus-tree-header--selected .focus-tree-toggle:hover { + background: rgba(255, 255, 255, 0.12); +} +.focus-tree-header__label { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px 6px 2px; + margin: 0; + border: none; + background: transparent; + color: inherit; + font: inherit; + font-weight: 600; + text-align: left; + cursor: pointer; + border-radius: 8px; + -webkit-tap-highlight-color: transparent; +} +.focus-tree-emoji { + flex-shrink: 0; + line-height: 1; +} +.focus-tree-children { + margin-top: 8px; + margin-left: 8px; + padding-left: 12px; + border-left: 2px solid var(--border); +} +.focus-tree-group { + margin-bottom: 12px; +} +.focus-tree-group:last-child { + margin-bottom: 0; +} +.focus-tree-group__head { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--text3); + margin-bottom: 6px; + text-transform: uppercase; + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} +.focus-tree-add-btn { + flex-shrink: 0; +} +.focus-tree-item { + padding: 8px 12px; + margin-bottom: 4px; + border-radius: 8px; + cursor: pointer; + background: var(--surface2); + color: var(--text1); + font-size: 14px; + border: 1px solid var(--border); + line-height: 1.35; + transition: background 0.12s, border-color 0.12s, color 0.12s; +} +.focus-tree-item:last-child { + margin-bottom: 0; +} +.focus-tree-item:hover { + border-color: var(--accent); +} +.focus-tree-item--selected { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} +.focus-tree-item--selected:hover { + border-color: var(--accent); +} +.focus-tree-item__abbr { + margin-left: 8px; + font-size: 12px; + opacity: 0.85; +} +.focus-tree-item__meta { + font-size: 11px; + margin-top: 4px; + line-height: 1.35; + opacity: 0.88; +} +.focus-tree-item--selected .focus-tree-item__abbr, +.focus-tree-item--selected .focus-tree-item__meta { + opacity: 0.95; + color: #fff; +} + +@media (max-width: 1023px) { + .exercise-filter-chips-row { + flex-wrap: nowrap; + overflow-x: auto; + padding-bottom: 4px; + margin-left: -2px; + margin-right: -2px; + padding-left: 2px; + padding-right: 2px; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + .exercise-filter-chips-row::-webkit-scrollbar { + display: none; + } + .focus-tree-children { + margin-left: 4px; + padding-left: 8px; + } +} + .exercise-filter-modal.admin-modal-sheet { max-width: min(920px, calc(100dvw - 16px)); } @@ -1704,6 +2732,10 @@ a.analysis-split__nav-item { .exercise-filters-modal-grid--two { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); } +.exercise-filters-modal-grid--catalog { + grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); + gap: 10px 12px; +} .exercise-filter-chips-row { display: flex; flex-wrap: wrap; @@ -1795,6 +2827,93 @@ a.analysis-split__nav-item { font-weight: 600; } +/* Übungsfilter: kompakte +/- Katalogregeln */ +.catalog-rule-picker { + margin-bottom: 10px; +} +.catalog-rule-picker--disabled { + opacity: 0.55; + pointer-events: none; +} +.catalog-rule-picker__label { + margin-bottom: 0; + font-size: 13px; +} +.catalog-rule-picker__chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + min-height: 0; + margin-bottom: 6px; +} +.catalog-rule-chip { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 100%; + padding: 2px 6px 2px 8px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface2); + font-size: 12px; + line-height: 1.35; +} +.catalog-rule-chip__sign { + font-weight: 700; + font-size: 12px; + flex-shrink: 0; + opacity: 0.85; +} +.catalog-rule-chip__sign--require { + color: var(--accent-dark); +} +.catalog-rule-chip__sign--forbid { + color: var(--danger); +} +.catalog-rule-chip__text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 180px; +} +.catalog-rule-chip__x { + flex-shrink: 0; + margin: 0; + padding: 0 4px; + border: none; + background: transparent; + color: var(--text2); + font-size: 15px; + line-height: 1; + cursor: pointer; + border-radius: 4px; +} +.catalog-rule-chip__x:hover { + color: var(--text1); + background: var(--border); +} +.catalog-rule-picker__row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} +.catalog-rule-picker__select { + flex: 0 1 132px; + max-width: 160px; + min-width: 96px; + padding: 6px 8px; + font-size: 13px; +} +.catalog-rule-picker__sign-btn { + min-width: 32px; + padding-left: 10px; + padding-right: 10px; + font-weight: 700; + font-size: 14px; +} + /* Reifegradmodell-Admin: klare Schritte, responsives Raster */ .admin-matrix-alert { border: 1px solid var(--danger); @@ -2571,7 +3690,31 @@ a.analysis-split__nav-item { .exercise-card { display: flex; flex-direction: column; - min-height: 200px; + min-height: 0; + border-left: 4px solid var(--border2); + transition: border-color 0.15s, box-shadow 0.15s; +} +.exercise-card--scope-official { + border-left-color: var(--accent); + background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 7%, var(--surface)) 0%, var(--surface) 64%); +} +.exercise-card--scope-club { + border-left-color: var(--warn); + background: linear-gradient(180deg, color-mix(in srgb, var(--warn) 10%, var(--surface)) 0%, var(--surface) 64%); +} +.exercise-card--scope-private { + border-left-color: var(--text3); +} +.exercise-card--mine { + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 28%, var(--border)); +} +@media (prefers-color-scheme: dark) { + .exercise-card--scope-official { + background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 12%, var(--surface)) 0%, var(--surface) 64%); + } + .exercise-card--scope-club { + background: linear-gradient(180deg, color-mix(in srgb, var(--warn) 12%, var(--surface)) 0%, var(--surface) 64%); + } } .exercise-card__body { flex: 1 1 auto; @@ -2615,14 +3758,89 @@ a.analysis-split__nav-item { line-height: 1.45; } +.exercise-card__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: auto; + padding-top: 10px; + border-top: 1px solid var(--border); + flex-wrap: nowrap; + min-height: 44px; + box-sizing: border-box; +} +.exercise-card__meta-compact { + display: inline-flex; + align-items: center; + gap: 5px; + flex-shrink: 0; + color: var(--text3); + font-size: 0; + line-height: 0; +} +.exercise-card__meta-glyph { + display: inline-flex; + color: var(--text3); + opacity: 0.9; +} +.exercise-card__meta-sep { + font-size: 11px; + line-height: 1; + opacity: 0.45; + user-select: none; + padding: 0 1px; +} .exercise-card__actions { flex-shrink: 0; display: flex; gap: 6px; flex-wrap: wrap; - margin-top: 12px; - padding-top: 10px; - border-top: 1px solid var(--border); + margin-top: 0; + padding-top: 0; + border-top: none; +} +.exercise-card__actions--icons { + justify-content: flex-end; + gap: 8px; + flex-wrap: nowrap; + margin-left: auto; +} +.exercise-card__icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + border-radius: 8px; + border: 1px solid var(--border2); + background: var(--surface2); + color: var(--text1); + text-decoration: none; + cursor: pointer; + flex-shrink: 0; + transition: background 0.12s, border-color 0.12s, color 0.12s; + -webkit-tap-highlight-color: transparent; +} +.exercise-card__icon-btn:hover { + background: var(--surface); + border-color: var(--accent); + color: var(--accent-dark); +} +@media (prefers-color-scheme: dark) { + .exercise-card__icon-btn:hover { + color: var(--accent); + } +} +.exercise-card__icon-btn--danger { + color: var(--danger); + border-color: color-mix(in srgb, var(--danger) 35%, var(--border2)); +} +.exercise-card__icon-btn--danger:hover { + background: color-mix(in srgb, var(--danger) 10%, var(--surface2)); + border-color: var(--danger); + color: var(--danger); } .exercise-card__actions .btn, .exercise-card__actions a.btn { @@ -2653,6 +3871,29 @@ a.analysis-split__nav-item { color: var(--accent-dark); border-color: transparent; } +.exercise-tag--style { + background: color-mix(in srgb, var(--accent) 12%, var(--surface2)); + color: var(--accent-dark); + border-color: color-mix(in srgb, var(--accent) 22%, var(--border)); +} +.exercise-tag--training { + background: var(--surface2); + color: var(--text1); + border-color: var(--border2); +} + +/* Liste Rahmenprogramme: Abstand nur über gap (kein .card+.card zwischen li) */ +.framework-programs-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 12px; +} +.framework-programs-list > li.card { + margin-bottom: 0; +} .exercise-detail-shell { max-width: none; @@ -2851,36 +4092,27 @@ a.analysis-split__nav-item { } .framework-edit__tabbar { display: flex; - gap: 6px; + align-items: stretch; + gap: 8px; margin-bottom: 14px; - padding: 2px 0 12px; + padding: 6px 0 12px; border-bottom: 1px solid var(--border); flex-wrap: nowrap; overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; + position: sticky; + top: 0; + z-index: 6; + background: var(--bg); } .framework-edit__tabbar::-webkit-scrollbar { display: none; } -.framework-edit__tab { - flex: 1 1 0; +.framework-edit__tabbar .admin-page-subtabs, +.framework-edit__tabbar .page-section-nav { + flex: 1; min-width: 0; - padding: 10px 8px; - border: 1px solid var(--border2); - border-radius: 10px; - background: var(--surface2); - color: var(--text2); - font-size: 12px; - font-weight: 600; - cursor: pointer; - white-space: nowrap; - font-family: var(--font); -} -.framework-edit__tab--active { - background: var(--accent-light); - color: var(--accent-dark); - border-color: var(--accent); } .framework-edit__plan-stack { display: flex; @@ -3445,22 +4677,60 @@ a.analysis-split__nav-item { white-space: nowrap; } -.tu-ex-run-block { - display: block; +.tu-ex-debrief { + display: grid; + grid-template-columns: minmax(0, 1fr) 4.75rem; + gap: 10px 14px; + align-items: start; width: 100%; margin-top: 10px; - font-size: 0.78rem; + padding-top: 10px; + border-top: 1px solid var(--border2, rgba(0, 0, 0, 0.08)); + box-sizing: border-box; } -.tu-ex-run-block__controls { +.tu-ex-debrief__grow { + min-width: 0; display: flex; flex-direction: column; - gap: 6px; - margin-top: 5px; + gap: 5px; } -.tu-ex-run-block__controls .form-input:first-of-type { - max-width: 120px; +.tu-ex-debrief__textarea { + width: 100%; + max-width: 100%; + box-sizing: border-box; + min-height: 4.75rem; + resize: vertical; + font-size: 0.88rem; + line-height: 1.45; +} + +.tu-ex-debrief__ist { + display: flex; + flex-direction: column; + gap: 5px; + align-items: stretch; + justify-self: end; + width: 4.75rem; + flex-shrink: 0; +} + +.tu-ex-debrief__ist .tu-ex-duration { + width: 100%; + margin: 0; +} + +@media (max-width: 520px) { + .tu-ex-debrief { + grid-template-columns: 1fr; + } + + .tu-ex-debrief__ist { + width: 100%; + max-width: 10rem; + justify-self: start; + } } .tu-textedit-backdrop { @@ -3786,6 +5056,98 @@ a.analysis-split__nav-item { } } +/* ── Trainingsplanung: Abschnitt „Neue Trainingseinheit“ + Vorlage im Modal ───────── */ +.training-planning-create--in-card { + margin-top: 1.25rem; + padding-top: 1.25rem; + border-top: 1px solid var(--border, rgba(0, 0, 0, 0.08)); +} + +.training-planning-create__intro { + margin-bottom: 1rem; +} + +.training-planning-create__title { + margin: 0 0 0.45rem; + font-size: 1.06rem; + font-weight: 700; + color: var(--text1); + letter-spacing: -0.01em; +} + +.training-planning-create__lede { + margin: 0; + font-size: 0.92rem; + line-height: 1.55; + color: var(--text2); + max-width: 52rem; +} + +.training-planning-create__actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; +} + +.training-planning-create__cta { + min-height: 44px; + padding-left: 1.35rem; + padding-right: 1.35rem; + font-weight: 600; +} + +.training-planning-create__secondary { + min-height: 44px; + padding-left: 1rem; + padding-right: 1rem; +} + +.training-planning-create__hint { + margin: 0.85rem 0 0; + font-size: 0.82rem; + line-height: 1.45; + color: var(--text3); + max-width: 48rem; +} + +.training-planning-create__hint--warn { + color: var(--text2); + margin-top: 0.65rem; + padding: 0.55rem 0.7rem; + border-radius: 8px; + background: var(--surface2); + border: 1px solid var(--border2); +} + +.training-planning-template-panel { + padding: 1rem 1.1rem; + border-radius: 12px; + border: 1px solid var(--border2); + background: linear-gradient(165deg, var(--surface2) 0%, var(--surface) 100%); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +.training-planning-template-panel__label { + font-weight: 600; + margin-bottom: 0.4rem; + display: block; +} + +.training-planning-template-panel__select { + font-size: 0.94rem; + padding: 0.55rem 0.65rem; + width: 100%; + max-width: 100%; +} + +.training-planning-template-panel__help { + margin: 0.65rem 0 0; + font-size: 0.82rem; + color: var(--text2); + line-height: 1.48; +} + @media print { .desktop-sidebar, .bottom-nav, diff --git a/frontend/src/components/AdminPageNav.jsx b/frontend/src/components/AdminPageNav.jsx index 4ad7a50..2e6aa4c 100644 --- a/frontend/src/components/AdminPageNav.jsx +++ b/frontend/src/components/AdminPageNav.jsx @@ -1,4 +1,4 @@ -import { NavLink, useLocation } from 'react-router-dom' +import { NavLink } from 'react-router-dom' import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react' /** @@ -6,8 +6,6 @@ import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react' * Wechselt zwischen verschiedenen Admin-Seiten */ export default function AdminPageNav() { - const location = useLocation() - const pages = [ { to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine }, { to: '/admin/users', label: 'Nutzer', icon: Users }, @@ -17,51 +15,18 @@ export default function AdminPageNav() { ] return ( -