From 585ee8c90ded8694e7c6eb015135d79802465ff1 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 6 May 2026 13:52:24 +0200 Subject: [PATCH] feat: enhance exercise management features and UI - Introduced new function `club_admin_shares_club_with_creator` to check club admin permissions for shared clubs. - Updated `can_manage_club_org` to incorporate new role checks. - Enhanced exercise deletion logic to include checks for club admin roles and shared club memberships. - Added new filters for exercise visibility and status in the ExercisesListPage, allowing users to exclude specific criteria. - Implemented functionality to save user-specific exercise list preferences, improving user experience. - Updated API interactions to support new filtering options and preferences for exercise management. --- backend/club_tenancy.py | 27 +++ .../043_profiles_exercise_list_prefs.sql | 3 + backend/models.py | 6 +- backend/routers/exercises.py | 176 +++++++++++++++--- backend/routers/profiles.py | 11 ++ backend/tests/test_exercises_delete_policy.py | 131 +++++++++++++ backend/version.py | 19 +- .../src/components/ExercisePickerModal.jsx | 64 +++++-- frontend/src/constants/exerciseListFilters.js | 54 ++++++ frontend/src/pages/ExercisesListPage.jsx | 154 +++++++++++++-- frontend/src/utils/api.js | 2 +- frontend/src/utils/exercisePermissions.js | 27 +++ frontend/src/version.js | 4 +- 13 files changed, 619 insertions(+), 59 deletions(-) create mode 100644 backend/migrations/043_profiles_exercise_list_prefs.sql create mode 100644 backend/tests/test_exercises_delete_policy.py create mode 100644 frontend/src/constants/exerciseListFilters.js create mode 100644 frontend/src/utils/exercisePermissions.js 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/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 6ac2b89..86fa2df 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, ) @@ -232,6 +234,8 @@ 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 @@ -657,6 +661,96 @@ 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, @@ -850,6 +944,20 @@ 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", + ), + include_archived: bool = Query( + default=False, + description="Archivierte einbeziehen; Standard false (außer Statusfilter enthält archived)", + ), tenant: TenantContext = Depends(get_tenant_context), ): """ @@ -889,6 +997,36 @@ def list_exercises( where.append(f"e.status IN ({ph})") params.extend(st_list) + 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) + + if exclude_without_focus: + where.append( + "EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id)" + ) + fa_ids = _merge_ids(focus_area_ids, focus_area) if fa_ids: ph = ",".join(["%s"] * len(fa_ids)) @@ -1241,38 +1379,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/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/version.py b/backend/version.py index 5902c86..73e271a 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,12 +1,12 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.38" +APP_VERSION = "0.8.39" BUILD_DATE = "2026-05-06" -DB_SCHEMA_VERSION = "20260505042" +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,7 +15,7 @@ MODULE_VERSIONS = { "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", - "exercises": "2.8.0", # PATCH bulk-metadata: Sichtbarkeit/Status + Katalog-Zuordnungen (REPLACE je Feld) + "exercises": "2.9.0", # DELETE RBAC (Trainer/Vereinsadmin/Plattform); Nutzungs-409; Listenfilter Negativlisten + Archiv-Standard; exercise_list_prefs "training_units": "0.2.0", "training_programs": "0.1.0", "planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile @@ -27,6 +27,17 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "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", diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx index 7424b95..8e4af3e 100644 --- a/frontend/src/components/ExercisePickerModal.jsx +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -4,23 +4,18 @@ */ import React, { useState, useEffect, useMemo, useCallback } from 'react' import api from '../utils/api' +import { useAuth } from '../context/AuthContext' import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels' +import { + INITIAL_EXERCISE_LIST_FILTERS, + mergeExerciseListPrefsFromApi, +} from '../constants/exerciseListFilters' import MultiSelectCombo from './MultiSelectCombo' const PAGE_SIZE = 100 const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null) -const INITIAL_FILTERS = { - focus_area_ids: [], - style_direction_ids: [], - training_type_ids: [], - target_group_ids: [], - skill_ids: [], - skill_min_level: '', - skill_max_level: '', - visibility_any: [], - status_any: [], -} +const INITIAL_FILTERS = { ...INITIAL_EXERCISE_LIST_FILTERS } export default function ExercisePickerModal({ open, @@ -29,6 +24,7 @@ export default function ExercisePickerModal({ multiSelect = false, onSelectExercises = null, }) { + const { user } = useAuth() const [catalogs, setCatalogs] = useState({ focusAreas: [], styleDirections: [], @@ -110,8 +106,10 @@ export default function ExercisePickerModal({ setOffset(0) setHasMore(false) setMultiPicked([]) + return } - }, [open]) + setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs)) + }, [open, user?.exercise_list_prefs]) const focusOptions = useMemo( () => catalogs.focusAreas.map((fa) => ({ id: fa.id, label: `${fa.icon || ''} ${fa.name || ''}`.trim() })), @@ -170,6 +168,11 @@ export default function ExercisePickerModal({ if (filters.skill_max_level) q.skill_max_level = n(filters.skill_max_level) if (filters.visibility_any?.length) q.visibility_any = [...filters.visibility_any] if (filters.status_any?.length) q.status_any = [...filters.status_any] + if (filters.visibility_exclude_any?.length) + q.visibility_exclude_any = [...filters.visibility_exclude_any] + if (filters.status_exclude_any?.length) q.status_exclude_any = [...filters.status_exclude_any] + if (filters.exclude_without_focus) q.exclude_without_focus = true + if (filters.include_archived) q.include_archived = true if (debouncedSearch) q.search = debouncedSearch if (debouncedAi) q.ai_search = debouncedAi return q @@ -182,6 +185,7 @@ export default function ExercisePickerModal({ try { const batch = await api.listExercises({ ...queryBase, + include_archived: true, include_variants: true, limit: PAGE_SIZE, offset: 0, @@ -209,6 +213,7 @@ export default function ExercisePickerModal({ try { const batch = await api.listExercises({ ...queryBase, + include_archived: true, include_variants: true, limit: PAGE_SIZE, offset, @@ -389,6 +394,41 @@ export default function ExercisePickerModal({ /> +

Ausblenden

+
+
+ + setFilters((f) => ({ ...f, visibility_exclude_any: v }))} + options={visibilityOptions} + placeholder="ausblenden …" + /> +
+
+ + setFilters((f) => ({ ...f, status_exclude_any: v }))} + options={statusOptions} + placeholder="ausblenden …" + /> +
+
+
+ +

+ Hinweis: Für die Planung werden archivierte Übungen bei der Suche immer mit eingeschlossen (bestehende + Zuordnungen). +

+
)} diff --git a/frontend/src/constants/exerciseListFilters.js b/frontend/src/constants/exerciseListFilters.js new file mode 100644 index 0000000..31c92f2 --- /dev/null +++ b/frontend/src/constants/exerciseListFilters.js @@ -0,0 +1,54 @@ +/** Gemeinsame Default-Filter für Übungslisten (Übersicht + Auswahlmodal). */ +export const INITIAL_EXERCISE_LIST_FILTERS = { + focus_area_ids: [], + style_direction_ids: [], + training_type_ids: [], + target_group_ids: [], + skill_ids: [], + skill_min_level: '', + skill_max_level: '', + visibility_any: [], + status_any: [], + visibility_exclude_any: [], + status_exclude_any: [], + exclude_without_focus: false, + include_archived: false, +} + +const PREFS_KEYS = Object.keys(INITIAL_EXERCISE_LIST_FILTERS) + +/** + * Ruft aus dem Profilfeld exercise_list_prefs einen gültigen Filter-State ab. + */ +export function mergeExerciseListPrefsFromApi(raw) { + const out = { ...INITIAL_EXERCISE_LIST_FILTERS } + if (!raw || typeof raw !== 'object') return out + for (const k of PREFS_KEYS) { + if (raw[k] === undefined) continue + if (k.endsWith('_ids') || k.endsWith('_any')) { + if (Array.isArray(raw[k])) out[k] = raw[k].map(String) + continue + } + if (k === 'exclude_without_focus' || k === 'include_archived') { + out[k] = !!raw[k] + continue + } + if (k === 'skill_min_level' || k === 'skill_max_level') { + out[k] = raw[k] === '' || raw[k] == null ? '' : String(raw[k]) + } + } + return out +} + +/** Nur von den Defaults abweichende Werte — kompaktes Profil-JSON. */ +export function compactExerciseListPrefsPayload(filters) { + const full = { ...INITIAL_EXERCISE_LIST_FILTERS, ...filters } + const o = {} + for (const k of PREFS_KEYS) { + const v = full[k] + const ini = INITIAL_EXERCISE_LIST_FILTERS[k] + if (JSON.stringify(v) === JSON.stringify(ini)) continue + o[k] = v + } + return o +} diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx index e092c46..51a075a 100644 --- a/frontend/src/pages/ExercisesListPage.jsx +++ b/frontend/src/pages/ExercisesListPage.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo, useCallback } from 'react' +import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react' import { Link } from 'react-router-dom' import { Eye, @@ -18,7 +18,12 @@ import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels' import MultiSelectCombo from '../components/MultiSelectCombo' import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel' import PageSectionNav from '../components/PageSectionNav' -import { sanitizeExerciseRichText, coerceApiNameList } from '../utils/sanitizeHtml' +import { + INITIAL_EXERCISE_LIST_FILTERS, + mergeExerciseListPrefsFromApi, + compactExerciseListPrefsPayload, +} from '../constants/exerciseListFilters' +import { canUserRequestExerciseDelete } from '../utils/exercisePermissions' const PAGE_SIZE = 100 const BULK_MAX_IDS = 500 @@ -28,18 +33,6 @@ const EXERCISES_PAGE_TABS = [ ] const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null) -const INITIAL_FILTERS = { - focus_area_ids: [], - style_direction_ids: [], - training_type_ids: [], - target_group_ids: [], - skill_ids: [], - skill_min_level: '', - skill_max_level: '', - visibility_any: [], - status_any: [], -} - const VIS_LABELS = { official: 'Global', club: 'Verein', private: 'Privat' } const STATUS_LABELS = { draft: 'Entwurf', @@ -110,7 +103,7 @@ function levelOptionShort(levelStr) { } function ExercisesListPage() { - const { user } = useAuth() + const { user, checkAuth } = useAuth() const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' const [exercises, setExercises] = useState([]) @@ -130,9 +123,11 @@ function ExercisesListPage() { const [aiSearchInput, setAiSearchInput] = useState('') const [debouncedSearch, setDebouncedSearch] = useState('') const [debouncedAiSearch, setDebouncedAiSearch] = useState('') - const [filters, setFilters] = useState(() => ({ ...INITIAL_FILTERS })) + const [filters, setFilters] = useState(() => ({ ...INITIAL_EXERCISE_LIST_FILTERS })) const [filterModalOpen, setFilterModalOpen] = useState(false) + const [savingExercisePrefs, setSavingExercisePrefs] = useState(false) const [pageTab, setPageTab] = useState('list') + const prefsAppliedRef = useRef(false) const [selectedIds, setSelectedIds] = useState(() => new Set()) const [bulkModalOpen, setBulkModalOpen] = useState(false) @@ -150,6 +145,17 @@ function ExercisesListPage() { const [bulkPatchTargetGroups, setBulkPatchTargetGroups] = useState(false) const [bulkTargetGroupIds, setBulkTargetGroupIds] = useState([]) + useEffect(() => { + if (!user?.id) return + if (prefsAppliedRef.current) return + setFilters(mergeExerciseListPrefsFromApi(user.exercise_list_prefs)) + prefsAppliedRef.current = true + }, [user?.id, user?.exercise_list_prefs]) + + useEffect(() => { + if (!user?.id) prefsAppliedRef.current = false + }, [user?.id]) + useEffect(() => { const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400) return () => clearTimeout(t) @@ -315,6 +321,46 @@ function ExercisesListPage() { }) }) + ;(filters.visibility_exclude_any || []).forEach((id) => { + const opt = visibilityOptions.find((o) => String(o.id) === String(id)) + chips.push({ + key: `vex-${id}`, + label: `Sichtbarkeit ausblenden: ${opt?.label ?? id}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + visibility_exclude_any: prev.visibility_exclude_any.filter((x) => String(x) !== String(id)), + })), + }) + }) + ;(filters.status_exclude_any || []).forEach((id) => { + const opt = statusOptions.find((o) => String(o.id) === String(id)) + chips.push({ + key: `sex-${id}`, + label: `Status ausblenden: ${opt?.label ?? id}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + status_exclude_any: prev.status_exclude_any.filter((x) => String(x) !== String(id)), + })), + }) + }) + + if (filters.exclude_without_focus) { + chips.push({ + key: 'ex-no-focus', + label: 'Ohne Fokus ausblenden', + onRemove: () => setFilters((prev) => ({ ...prev, exclude_without_focus: false })), + }) + } + if (filters.include_archived) { + chips.push({ + key: 'inc-arch', + label: 'Archivierte anzeigen', + onRemove: () => setFilters((prev) => ({ ...prev, include_archived: false })), + }) + } + return chips }, [ filters, @@ -352,6 +398,11 @@ function ExercisesListPage() { if (filters.skill_max_level) q.skill_max_level = n(filters.skill_max_level) if (filters.visibility_any?.length) q.visibility_any = [...filters.visibility_any] if (filters.status_any?.length) q.status_any = [...filters.status_any] + if (filters.visibility_exclude_any?.length) + q.visibility_exclude_any = [...filters.visibility_exclude_any] + if (filters.status_exclude_any?.length) q.status_exclude_any = [...filters.status_exclude_any] + if (filters.exclude_without_focus) q.exclude_without_focus = true + if (filters.include_archived) q.include_archived = true if (debouncedSearch) q.search = debouncedSearch if (debouncedAiSearch) q.ai_search = debouncedAiSearch return q @@ -504,7 +555,26 @@ function ExercisesListPage() { } } - const resetAllFilters = useCallback(() => setFilters({ ...INITIAL_FILTERS }), []) + const resetAllFilters = useCallback(() => setFilters({ ...INITIAL_EXERCISE_LIST_FILTERS }), []) + + const handleSaveExerciseFilterPrefs = useCallback(async () => { + const uid = user?.id + if (!uid) { + alert('Nicht angemeldet.') + return + } + setSavingExercisePrefs(true) + try { + const payload = compactExerciseListPrefsPayload(filters) + await api.updateProfile(uid, { exercise_list_prefs: payload }) + await checkAuth() + alert('Standardfilter für die Übungsliste gespeichert.') + } catch (e) { + alert('Speichern fehlgeschlagen: ' + (e.message || String(e))) + } finally { + setSavingExercisePrefs(false) + } + }, [user?.id, filters, checkAuth]) const openBulkModal = () => { setBulkVisibility('') @@ -886,6 +956,51 @@ function ExercisesListPage() { +
+

Ausblenden

+

+ Negativlisten schließen Treffer aus (weitere Felder weiterhin mit UND verknüpft). +

+
+
+ + setFilters({ ...filters, visibility_exclude_any: v })} + options={visibilityOptions} + placeholder="z. B. Global ausblenden …" + /> +
+
+ + setFilters({ ...filters, status_exclude_any: v })} + options={statusOptions} + placeholder="z. B. Entwurf ausblenden …" + /> +
+
+
+ + +
+
+

Freigabe

@@ -911,6 +1026,9 @@ function ExercisesListPage() {
+ @@ -1215,6 +1333,7 @@ function ExercisesListPage() { > + {canUserRequestExerciseDelete(user, exercise) ? ( + ) : null}
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 5c67634..447a7d1 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -375,7 +375,7 @@ export async function listExercises(filters = {}) { Object.entries(filters).forEach(([k, v]) => { if (v === undefined || v === null) return if (typeof v === 'boolean') { - if (v) q.set(k, 'true') + q.set(k, v ? 'true' : 'false') return } if (Array.isArray(v)) { diff --git a/frontend/src/utils/exercisePermissions.js b/frontend/src/utils/exercisePermissions.js new file mode 100644 index 0000000..c66e297 --- /dev/null +++ b/frontend/src/utils/exercisePermissions.js @@ -0,0 +1,27 @@ +function userIsClubAdminForClub(user, clubId) { + if (clubId == null || user == null) return false + const cid = Number(clubId) + const row = (user.clubs || []).find((c) => Number(c.id) === cid) + return Array.isArray(row?.roles) && row.roles.includes('club_admin') +} + +function userHasAnyClubAdminRole(user) { + return (user?.clubs || []).some((c) => Array.isArray(c.roles) && c.roles.includes('club_admin')) +} + +/** + * Ob die Löschen-Aktion in der Liste sinnvoll angeboten werden kann (Server hat letzte Instanz). + */ +export function canUserRequestExerciseDelete(user, exercise) { + if (!user || !exercise) return false + const role = String(user.role || '').toLowerCase() + if (role === 'admin' || role === 'superadmin') return true + const vis = exercise.visibility || 'private' + const mine = Number(exercise.created_by) === Number(user.id) + if (vis === 'official') return false + if (vis === 'club') { + return userIsClubAdminForClub(user, exercise.club_id) + } + if (mine) return true + return userHasAnyClubAdminRole(user) +} diff --git a/frontend/src/version.js b/frontend/src/version.js index c61688f..ec76d77 100644 --- a/frontend/src/version.js +++ b/frontend/src/version.js @@ -1,13 +1,13 @@ // Shinkan Jinkendo Frontend Version -export const APP_VERSION = "0.8.38" +export const APP_VERSION = "0.8.39" export const BUILD_DATE = "2026-05-06" export const PAGE_VERSIONS = { LoginPage: "1.0.0", Dashboard: "1.0.0", AccountSettingsPage: "1.0.0", - ExercisesPage: "1.3.0", // Massenänderung inkl. Fokusbereiche, Stilrichtungen, Trainingsstile, Zielgruppen + ExercisesPage: "1.4.0", // Negativfilter, Archiv-Standard, gespeicherte Standardfilter (exercise_list_prefs); Löschen-UX ClubsPage: "1.1.0", SkillsPage: "1.0.0", TrainingPlanningPage: "1.4.0",