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
|
||||
|
||||
|
||||
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):
|
||||
|
|
|
|||
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
|
||||
"""
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
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
|
||||
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
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 {
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
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
|
||||
|
||||
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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user