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
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):

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

View File

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

View File

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

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
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",

View File

@ -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({
/>
</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>

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 {
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() {
</div>
</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">
<h4 className="exercise-filter-section-title">Freigabe</h4>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two">
@ -911,6 +1026,9 @@ function ExercisesListPage() {
</section>
</div>
<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}>
Alle Filter zurücksetzen
</button>
@ -1215,6 +1333,7 @@ function ExercisesListPage() {
>
<Pencil size={18} strokeWidth={2} aria-hidden />
</Link>
{canUserRequestExerciseDelete(user, exercise) ? (
<button
type="button"
className="exercise-card__icon-btn exercise-card__icon-btn--danger"
@ -1224,6 +1343,7 @@ function ExercisesListPage() {
>
<Trash2 size={18} strokeWidth={2} aria-hidden />
</button>
) : null}
</div>
</div>
</div>

View File

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

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
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",