feat: enhance exercise management features and UI
Some checks failed
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 27s

- 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.
This commit is contained in:
Lars 2026-05-06 13:52:24 +02:00
parent 8eec145393
commit 585ee8c90d
13 changed files with 619 additions and 59 deletions

View File

@ -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 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: def can_manage_club_org(cur, profile_id: int, club_id: int, global_role: Optional[str]) -> bool:
"""Sparten/Gruppen/Struktur: Vereinsadmin oder Plattform-Admin.""" """Sparten/Gruppen/Struktur: Vereinsadmin oder Plattform-Admin."""
if is_platform_admin(global_role): if is_platform_admin(global_role):

View File

@ -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;

View File

@ -4,7 +4,7 @@ Pydantic Models for Shinkan Jinkendo API
Request/Response schemas for all endpoints Request/Response schemas for all endpoints
""" """
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr, Field
from typing import Optional, List from typing import Optional, List, Dict, Any
from datetime import date, time, datetime from datetime import date, time, datetime
# ============================================================================ # ============================================================================
@ -43,6 +43,10 @@ class ProfileUpdate(BaseModel):
description="Portal-Rolle: user, trainer, admin, superadmin (nur Plattform-Admin)", description="Portal-Rolle: user, trainer, admin, superadmin (nur Plattform-Admin)",
) )
tier: Optional[str] = Field(default=None, max_length=50) 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): class ProfileResponse(BaseModel):
id: int id: int

View File

@ -17,6 +17,8 @@ from pydantic import BaseModel, Field, model_validator
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from club_tenancy import ( from club_tenancy import (
assert_valid_governance_visibility, assert_valid_governance_visibility,
club_admin_shares_club_with_creator,
has_club_role,
is_platform_admin, is_platform_admin,
library_content_visible_to_profile, library_content_visible_to_profile,
) )
@ -232,6 +234,8 @@ class ExerciseVariantsReorder(BaseModel):
_VALID_EXERCISE_STATUS_BULK = frozenset({"draft", "in_review", "approved", "archived"}) _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_METADATA_IDS = 500
_MAX_BULK_RELATION_IDS_PER_KIND = 80 _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 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") @router.patch("/exercises/bulk-metadata")
def bulk_patch_exercises_metadata( def bulk_patch_exercises_metadata(
body: ExerciseBulkMetadataPatch, body: ExerciseBulkMetadataPatch,
@ -850,6 +944,20 @@ def list_exercises(
default=False, default=False,
description="Wenn true: Feld variants[] pro Übung (id, variant_name, sequence_order) für Planung/UI", 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), tenant: TenantContext = Depends(get_tenant_context),
): ):
""" """
@ -889,6 +997,36 @@ def list_exercises(
where.append(f"e.status IN ({ph})") where.append(f"e.status IN ({ph})")
params.extend(st_list) 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) fa_ids = _merge_ids(focus_area_ids, focus_area)
if fa_ids: if fa_ids:
ph = ",".join(["%s"] * len(fa_ids)) ph = ",".join(["%s"] * len(fa_ids))
@ -1241,38 +1379,32 @@ def delete_exercise(
): ):
""" """
Löscht eine Übung. 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: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
# Existiert die Übung? cur.execute(
cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,)) "SELECT id, created_by, visibility, club_id FROM exercises WHERE id = %s",
(exercise_id,),
)
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
raise HTTPException(status_code=404, detail="Übung nicht gefunden") raise HTTPException(status_code=404, detail="Übung nicht gefunden")
ex = r2d(row)
# Permission Check _assert_can_delete_exercise(cur, tenant, ex)
if _row_created_by(row) != profile_id and not is_platform_admin(role):
raise HTTPException(status_code=403, detail="Nur Ersteller oder Admin darf löschen")
# Prüfen ob Übung in Block-Items verwendet wird counts = _exercise_delete_usage_counts(cur, exercise_id)
cur.execute( usage_msg = _exercise_delete_usage_message(counts)
"SELECT COUNT(*) AS cnt FROM exercise_block_items WHERE exercise_id = %s", if usage_msg:
(exercise_id,) raise HTTPException(status_code=409, detail=usage_msg)
)
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"
)
# DELETE (Cascade löscht M:N Zuordnungen automatisch)
cur.execute("DELETE FROM exercises WHERE id = %s", (exercise_id,)) cur.execute("DELETE FROM exercises WHERE id = %s", (exercise_id,))
conn.commit() conn.commit()

View File

@ -9,6 +9,8 @@ from datetime import datetime
from fastapi import APIRouter, HTTPException, Header, Depends from fastapi import APIRouter, HTTPException, Header, Depends
from psycopg2.extras import Json
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth, hash_pin from auth import require_auth, hash_pin
from club_tenancy import assert_club_member, memberships_with_roles, is_platform_admin 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) assert_club_member(cur, int(pid), cid)
data["active_club_id"] = 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"} nullable_keys = {"goal_weight", "goal_bf_pct", "dob"}
for k, v in patch.items(): for k, v in patch.items():
if k == "email": if k == "email":

View File

@ -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

View File

@ -1,12 +1,12 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.38" APP_VERSION = "0.8.39"
BUILD_DATE = "2026-05-06" BUILD_DATE = "2026-05-06"
DB_SCHEMA_VERSION = "20260505042" DB_SCHEMA_VERSION = "20260506043"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute) "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) "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 "clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext
"club_memberships": "1.0.1", # Depends(get_tenant_context) "club_memberships": "1.0.1", # Depends(get_tenant_context)
@ -15,7 +15,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0", "groups": "0.1.0",
"skills": "0.1.0", "skills": "0.1.0",
"methods": "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_units": "0.2.0",
"training_programs": "0.1.0", "training_programs": "0.1.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile "planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
@ -27,6 +27,17 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ 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", "version": "0.8.38",
"date": "2026-05-06", "date": "2026-05-06",

View File

@ -4,23 +4,18 @@
*/ */
import React, { useState, useEffect, useMemo, useCallback } from 'react' import React, { useState, useEffect, useMemo, useCallback } from 'react'
import api from '../utils/api' import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels' import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import {
INITIAL_EXERCISE_LIST_FILTERS,
mergeExerciseListPrefsFromApi,
} from '../constants/exerciseListFilters'
import MultiSelectCombo from './MultiSelectCombo' import MultiSelectCombo from './MultiSelectCombo'
const PAGE_SIZE = 100 const PAGE_SIZE = 100
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null) const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
const INITIAL_FILTERS = { const INITIAL_FILTERS = { ...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: [],
}
export default function ExercisePickerModal({ export default function ExercisePickerModal({
open, open,
@ -29,6 +24,7 @@ export default function ExercisePickerModal({
multiSelect = false, multiSelect = false,
onSelectExercises = null, onSelectExercises = null,
}) { }) {
const { user } = useAuth()
const [catalogs, setCatalogs] = useState({ const [catalogs, setCatalogs] = useState({
focusAreas: [], focusAreas: [],
styleDirections: [], styleDirections: [],
@ -110,8 +106,10 @@ export default function ExercisePickerModal({
setOffset(0) setOffset(0)
setHasMore(false) setHasMore(false)
setMultiPicked([]) setMultiPicked([])
return
} }
}, [open]) setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs))
}, [open, user?.exercise_list_prefs])
const focusOptions = useMemo( const focusOptions = useMemo(
() => catalogs.focusAreas.map((fa) => ({ id: fa.id, label: `${fa.icon || ''} ${fa.name || ''}`.trim() })), () => 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.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.visibility_any?.length) q.visibility_any = [...filters.visibility_any]
if (filters.status_any?.length) q.status_any = [...filters.status_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 (debouncedSearch) q.search = debouncedSearch
if (debouncedAi) q.ai_search = debouncedAi if (debouncedAi) q.ai_search = debouncedAi
return q return q
@ -182,6 +185,7 @@ export default function ExercisePickerModal({
try { try {
const batch = await api.listExercises({ const batch = await api.listExercises({
...queryBase, ...queryBase,
include_archived: true,
include_variants: true, include_variants: true,
limit: PAGE_SIZE, limit: PAGE_SIZE,
offset: 0, offset: 0,
@ -209,6 +213,7 @@ export default function ExercisePickerModal({
try { try {
const batch = await api.listExercises({ const batch = await api.listExercises({
...queryBase, ...queryBase,
include_archived: true,
include_variants: true, include_variants: true,
limit: PAGE_SIZE, limit: PAGE_SIZE,
offset, offset,
@ -389,6 +394,41 @@ export default function ExercisePickerModal({
/> />
</div> </div>
</div> </div>
<p style={{ margin: '12px 0 8px', fontWeight: 600, fontSize: '13px' }}>Ausblenden</p>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two">
<div>
<label className="form-label">Sichtbarkeit nicht</label>
<MultiSelectCombo
value={filters.visibility_exclude_any}
onChange={(v) => setFilters((f) => ({ ...f, visibility_exclude_any: v }))}
options={visibilityOptions}
placeholder="ausblenden …"
/>
</div>
<div>
<label className="form-label">Status nicht</label>
<MultiSelectCombo
value={filters.status_exclude_any}
onChange={(v) => setFilters((f) => ({ ...f, status_exclude_any: v }))}
options={statusOptions}
placeholder="ausblenden …"
/>
</div>
</div>
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input
type="checkbox"
checked={!!filters.exclude_without_focus}
onChange={(e) => setFilters((f) => ({ ...f, exclude_without_focus: e.target.checked }))}
/>
<span>Ohne Fokus ausblenden</span>
</label>
<p style={{ margin: 0, fontSize: '12px', color: 'var(--text2)' }}>
Hinweis: Für die Planung werden archivierte Übungen bei der Suche immer mit eingeschlossen (bestehende
Zuordnungen).
</p>
</div>
</div> </div>
)} )}
</div> </div>

View File

@ -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
}

View File

@ -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 { Link } from 'react-router-dom'
import { import {
Eye, Eye,
@ -18,7 +18,12 @@ import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import MultiSelectCombo from '../components/MultiSelectCombo' import MultiSelectCombo from '../components/MultiSelectCombo'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel' import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
import PageSectionNav from '../components/PageSectionNav' 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 PAGE_SIZE = 100
const BULK_MAX_IDS = 500 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 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 VIS_LABELS = { official: 'Global', club: 'Verein', private: 'Privat' }
const STATUS_LABELS = { const STATUS_LABELS = {
draft: 'Entwurf', draft: 'Entwurf',
@ -110,7 +103,7 @@ function levelOptionShort(levelStr) {
} }
function ExercisesListPage() { function ExercisesListPage() {
const { user } = useAuth() const { user, checkAuth } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const [exercises, setExercises] = useState([]) const [exercises, setExercises] = useState([])
@ -130,9 +123,11 @@ function ExercisesListPage() {
const [aiSearchInput, setAiSearchInput] = useState('') const [aiSearchInput, setAiSearchInput] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('') const [debouncedSearch, setDebouncedSearch] = useState('')
const [debouncedAiSearch, setDebouncedAiSearch] = 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 [filterModalOpen, setFilterModalOpen] = useState(false)
const [savingExercisePrefs, setSavingExercisePrefs] = useState(false)
const [pageTab, setPageTab] = useState('list') const [pageTab, setPageTab] = useState('list')
const prefsAppliedRef = useRef(false)
const [selectedIds, setSelectedIds] = useState(() => new Set()) const [selectedIds, setSelectedIds] = useState(() => new Set())
const [bulkModalOpen, setBulkModalOpen] = useState(false) const [bulkModalOpen, setBulkModalOpen] = useState(false)
@ -150,6 +145,17 @@ function ExercisesListPage() {
const [bulkPatchTargetGroups, setBulkPatchTargetGroups] = useState(false) const [bulkPatchTargetGroups, setBulkPatchTargetGroups] = useState(false)
const [bulkTargetGroupIds, setBulkTargetGroupIds] = useState([]) 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(() => { useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400) const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
return () => clearTimeout(t) 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 return chips
}, [ }, [
filters, filters,
@ -352,6 +398,11 @@ function ExercisesListPage() {
if (filters.skill_max_level) q.skill_max_level = n(filters.skill_max_level) 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.visibility_any?.length) q.visibility_any = [...filters.visibility_any]
if (filters.status_any?.length) q.status_any = [...filters.status_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 (debouncedSearch) q.search = debouncedSearch
if (debouncedAiSearch) q.ai_search = debouncedAiSearch if (debouncedAiSearch) q.ai_search = debouncedAiSearch
return q 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 = () => { const openBulkModal = () => {
setBulkVisibility('') setBulkVisibility('')
@ -886,6 +956,51 @@ function ExercisesListPage() {
</div> </div>
</section> </section>
<section className="exercise-filter-section">
<h4 className="exercise-filter-section-title">Ausblenden</h4>
<p className="muted" style={{ marginTop: 0, marginBottom: '12px', fontSize: '13px' }}>
Negativlisten schließen Treffer aus (weitere Felder weiterhin mit UND verknüpft).
</p>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two">
<div>
<label className="form-label">Sichtbarkeit nicht anzeigen</label>
<MultiSelectCombo
value={filters.visibility_exclude_any}
onChange={(v) => setFilters({ ...filters, visibility_exclude_any: v })}
options={visibilityOptions}
placeholder="z. B. Global ausblenden …"
/>
</div>
<div>
<label className="form-label">Status nicht anzeigen</label>
<MultiSelectCombo
value={filters.status_exclude_any}
onChange={(v) => setFilters({ ...filters, status_exclude_any: v })}
options={statusOptions}
placeholder="z. B. Entwurf ausblenden …"
/>
</div>
</div>
<div style={{ marginTop: '14px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={!!filters.exclude_without_focus}
onChange={(e) => setFilters({ ...filters, exclude_without_focus: e.target.checked })}
/>
<span>Übungen ohne Fokusbereich ausblenden</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={!!filters.include_archived}
onChange={(e) => setFilters({ ...filters, include_archived: e.target.checked })}
/>
<span>Archivierte Übungen einblenden (ohne Haken werden sie standardmäßig ausgeblendet)</span>
</label>
</div>
</section>
<section className="exercise-filter-section exercise-filter-section--last"> <section className="exercise-filter-section exercise-filter-section--last">
<h4 className="exercise-filter-section-title">Freigabe</h4> <h4 className="exercise-filter-section-title">Freigabe</h4>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two"> <div className="exercise-filters-modal-grid exercise-filters-modal-grid--two">
@ -911,6 +1026,9 @@ function ExercisesListPage() {
</section> </section>
</div> </div>
<div className="exercise-filter-modal__footer"> <div className="exercise-filter-modal__footer">
<button type="button" className="btn btn-secondary" disabled={savingExercisePrefs} onClick={handleSaveExerciseFilterPrefs}>
{savingExercisePrefs ? 'Speichern…' : 'Als Standard speichern'}
</button>
<button type="button" className="btn" onClick={resetAllFilters}> <button type="button" className="btn" onClick={resetAllFilters}>
Alle Filter zurücksetzen Alle Filter zurücksetzen
</button> </button>
@ -1215,6 +1333,7 @@ function ExercisesListPage() {
> >
<Pencil size={18} strokeWidth={2} aria-hidden /> <Pencil size={18} strokeWidth={2} aria-hidden />
</Link> </Link>
{canUserRequestExerciseDelete(user, exercise) ? (
<button <button
type="button" type="button"
className="exercise-card__icon-btn exercise-card__icon-btn--danger" className="exercise-card__icon-btn exercise-card__icon-btn--danger"
@ -1224,6 +1343,7 @@ function ExercisesListPage() {
> >
<Trash2 size={18} strokeWidth={2} aria-hidden /> <Trash2 size={18} strokeWidth={2} aria-hidden />
</button> </button>
) : null}
</div> </div>
</div> </div>
</div> </div>

View File

@ -375,7 +375,7 @@ export async function listExercises(filters = {}) {
Object.entries(filters).forEach(([k, v]) => { Object.entries(filters).forEach(([k, v]) => {
if (v === undefined || v === null) return if (v === undefined || v === null) return
if (typeof v === 'boolean') { if (typeof v === 'boolean') {
if (v) q.set(k, 'true') q.set(k, v ? 'true' : 'false')
return return
} }
if (Array.isArray(v)) { if (Array.isArray(v)) {

View File

@ -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)
}

View File

@ -1,13 +1,13 @@
// Shinkan Jinkendo Frontend Version // 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 BUILD_DATE = "2026-05-06"
export const PAGE_VERSIONS = { export const PAGE_VERSIONS = {
LoginPage: "1.0.0", LoginPage: "1.0.0",
Dashboard: "1.0.0", Dashboard: "1.0.0",
AccountSettingsPage: "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", ClubsPage: "1.1.0",
SkillsPage: "1.0.0", SkillsPage: "1.0.0",
TrainingPlanningPage: "1.4.0", TrainingPlanningPage: "1.4.0",