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
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:
parent
8eec145393
commit
585ee8c90d
|
|
@ -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):
|
||||||
|
|
|
||||||
3
backend/migrations/043_profiles_exercise_list_prefs.sql
Normal file
3
backend/migrations/043_profiles_exercise_list_prefs.sql
Normal 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;
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
131
backend/tests/test_exercises_delete_policy.py
Normal file
131
backend/tests/test_exercises_delete_policy.py
Normal 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
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
54
frontend/src/constants/exerciseListFilters.js
Normal file
54
frontend/src/constants/exerciseListFilters.js
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
27
frontend/src/utils/exercisePermissions.js
Normal file
27
frontend/src/utils/exercisePermissions.js
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user