Implement RBAC for library content management in club tenancy
Some checks failed
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 41s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Has been cancelled
Some checks failed
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 41s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Has been cancelled
- Introduced new functions for managing edit, delete, and governance transition permissions for library content, aligning with role-based access control (RBAC) principles. - Updated existing routers to utilize these new functions, ensuring consistent permission checks across training frameworks, modules, and progression graphs. - Enhanced visibility and governance handling for training plan templates and library content, improving overall content management and user experience. - Incremented app version to 0.8.142 and updated changelog to reflect these changes.
This commit is contained in:
parent
bc1790bd82
commit
0275f76432
|
|
@ -3,7 +3,7 @@ Vereins-Mandanten: Mitgliedschaften, aktiver Vereinskontext, einfache Berechtigu
|
|||
|
||||
Siehe .claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md
|
||||
"""
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
from typing import Any, Dict, List, Mapping, Optional, Set, Union
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
|
@ -155,6 +155,165 @@ def club_ids_for_profile_with_roles(cur, profile_id: int, *role_codes: str) -> S
|
|||
_GOVERNANCE_VISIBILITY = frozenset({"private", "club", "official"})
|
||||
|
||||
|
||||
def _library_governance_triplet(
|
||||
row: Mapping[str, Any],
|
||||
) -> tuple[str, Optional[int], Optional[int]]:
|
||||
"""visibility, club_id, created_by als normalisierte Werte für Bibliotheks-/Planungsartefakte."""
|
||||
vis = str(row.get("visibility") or "private").strip().lower()
|
||||
if vis not in _GOVERNANCE_VISIBILITY:
|
||||
vis = "private"
|
||||
cid_raw = row.get("club_id")
|
||||
try:
|
||||
ex_cid = int(cid_raw) if cid_raw is not None else None
|
||||
except (TypeError, ValueError):
|
||||
ex_cid = None
|
||||
cr_raw = row.get("created_by")
|
||||
try:
|
||||
creator = int(cr_raw) if cr_raw is not None else None
|
||||
except (TypeError, ValueError):
|
||||
creator = None
|
||||
return vis, ex_cid, creator
|
||||
|
||||
|
||||
def assert_library_content_editable(
|
||||
cur,
|
||||
profile_id: int,
|
||||
role: Optional[str],
|
||||
row: Union[Dict[str, Any], Mapping[str, Any]],
|
||||
) -> None:
|
||||
"""Inhalt bearbeiten: wie Übungen — Ersteller, Plattform-Admin oder Planungsberechtigter im Verein."""
|
||||
pid = int(profile_id)
|
||||
ex_vis, ex_cid, creator = _library_governance_triplet(row)
|
||||
if creator is not None and creator == pid:
|
||||
return
|
||||
if is_platform_admin(role):
|
||||
return
|
||||
if ex_vis == "club" and ex_cid is not None and can_plan_in_club(cur, pid, ex_cid, role):
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung zum Bearbeiten dieses Inhalts")
|
||||
|
||||
|
||||
def assert_library_content_deletable(
|
||||
cur,
|
||||
profile_id: int,
|
||||
role: Optional[str],
|
||||
row: Union[Dict[str, Any], Mapping[str, Any]],
|
||||
) -> None:
|
||||
"""Löschen: wie Übungen — privat Eigentümer/Vereins-Admin-Kontext, Verein nur Vereinsadmin, offiziell nur Plattform."""
|
||||
pid = int(profile_id)
|
||||
if is_platform_admin(role):
|
||||
return
|
||||
vis, cid, creator = _library_governance_triplet(row)
|
||||
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="Offizielle Inhalte 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="Vereinsinhalt 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-Inhalte 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 dieses Inhalts")
|
||||
|
||||
|
||||
def assert_library_content_governance_transition(
|
||||
cur,
|
||||
profile_id: int,
|
||||
role: Optional[str],
|
||||
prev_row: Union[Dict[str, Any], Mapping[str, Any]],
|
||||
next_visibility: str,
|
||||
next_club_id: Optional[int],
|
||||
) -> None:
|
||||
"""
|
||||
Zusätzliche Regeln beim Ändern von visibility/club_id (Zielzustand vor assert_valid_governance_visibility prüfen).
|
||||
|
||||
- Abwahl „official“: nur Plattform-Admin.
|
||||
- private → club: nur Ersteller (oder Plattform-Admin).
|
||||
- club → private: Ersteller, Vereinsadmin im bisherigen Verein oder Plattform-Admin.
|
||||
- club → club mit Wechsel club_id: Vereinsadmin im alten oder neuen Verein oder Plattform-Admin.
|
||||
"""
|
||||
nv = str(next_visibility or "").strip().lower()
|
||||
if nv not in _GOVERNANCE_VISIBILITY:
|
||||
raise HTTPException(status_code=400, detail="Ungültige visibility")
|
||||
|
||||
old_vis, old_cid, creator = _library_governance_triplet(prev_row)
|
||||
new_cid: Optional[int]
|
||||
try:
|
||||
new_cid = int(next_club_id) if next_club_id is not None else None
|
||||
except (TypeError, ValueError):
|
||||
new_cid = None
|
||||
|
||||
pid = int(profile_id)
|
||||
try:
|
||||
creator_int = int(creator) if creator is not None else None
|
||||
except (TypeError, ValueError):
|
||||
creator_int = None
|
||||
|
||||
if old_vis == nv and (nv != "club" or old_cid == new_cid):
|
||||
return
|
||||
|
||||
if old_vis == "official" and nv != "official":
|
||||
if not is_platform_admin(role):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Nur Plattform-Admins dürfen offizielle Inhalte auf Verein oder privat setzen.",
|
||||
)
|
||||
|
||||
if nv == "official":
|
||||
return
|
||||
|
||||
if old_vis == "private" and nv == "club":
|
||||
if creator_int is not None and creator_int != pid and not is_platform_admin(role):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Nur der Ersteller darf private Inhalte für den Verein freigeben.",
|
||||
)
|
||||
return
|
||||
|
||||
if old_vis == "club" and nv == "private":
|
||||
if is_platform_admin(role):
|
||||
return
|
||||
if creator_int is not None and creator_int == pid:
|
||||
return
|
||||
if old_cid is not None and has_club_role(cur, pid, old_cid, "club_admin"):
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Nur Ersteller, Vereins-Admins oder Plattform-Admins dürfen Vereins-Inhalte auf privat setzen.",
|
||||
)
|
||||
|
||||
if old_vis == "club" and nv == "club" and old_cid != new_cid:
|
||||
if is_platform_admin(role):
|
||||
return
|
||||
ok_old = old_cid is not None and has_club_role(cur, pid, old_cid, "club_admin")
|
||||
ok_new = new_cid is not None and has_club_role(cur, pid, new_cid, "club_admin")
|
||||
if ok_old or ok_new:
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Nur Vereins-Admins oder Plattform-Admins dürfen die Vereinszuordnung ändern.",
|
||||
)
|
||||
|
||||
|
||||
def assert_valid_governance_visibility(
|
||||
cur,
|
||||
profile_id: int,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""
|
||||
Progressionsgraph zwischen Übungen (Übung → Übung), Migration 032–034.
|
||||
Optional Übungsvarianten als Knoten-Endpunkte; Sequenz-Bulk-Anlage.
|
||||
AuthZ analog training_plan_templates (_template_access / _has_planning_role).
|
||||
AuthZ analog training_plan_templates: Bearbeiten/Löschen wie Übungen; Governance-Übergänge zentral.
|
||||
"""
|
||||
from typing import Any, List, Optional
|
||||
|
||||
|
|
@ -12,8 +12,10 @@ from psycopg2 import IntegrityError
|
|||
from db import get_db, get_cursor, r2d
|
||||
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
|
||||
from club_tenancy import (
|
||||
assert_library_content_deletable,
|
||||
assert_library_content_editable,
|
||||
assert_library_content_governance_transition,
|
||||
assert_valid_governance_visibility,
|
||||
is_platform_admin,
|
||||
library_content_visible_to_profile,
|
||||
)
|
||||
|
||||
|
|
@ -111,13 +113,7 @@ def _assert_graph_readable(cur, row: dict, profile_id: int, role: str) -> None:
|
|||
|
||||
|
||||
def _assert_graph_writable(cur, row: dict, profile_id: int, role: str) -> None:
|
||||
if is_platform_admin(role):
|
||||
return
|
||||
created_by = row.get("created_by")
|
||||
if created_by is not None:
|
||||
created_by = int(created_by)
|
||||
if created_by != profile_id:
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Progressionsgraph")
|
||||
assert_library_content_editable(cur, profile_id, role, row)
|
||||
|
||||
|
||||
def _require_graph_read(cur, graph_id: int, profile_id: int, role: str) -> dict:
|
||||
|
|
@ -333,6 +329,7 @@ def update_progression_graph(
|
|||
)
|
||||
|
||||
gov_club = next_club if next_vis == "club" else None
|
||||
assert_library_content_governance_transition(cur, profile_id, role, row, next_vis, gov_club)
|
||||
assert_valid_governance_visibility(cur, profile_id, role, next_vis, gov_club)
|
||||
|
||||
fields: List[str] = []
|
||||
|
|
@ -376,7 +373,9 @@ def delete_progression_graph(graph_id: int, tenant: TenantContext = Depends(get_
|
|||
role = tenant.global_role
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
_require_graph_write(cur, graph_id, profile_id, role)
|
||||
row = _graph_row(cur, graph_id)
|
||||
_assert_graph_readable(cur, row, profile_id, role)
|
||||
assert_library_content_deletable(cur, profile_id, role, row)
|
||||
cur.execute("DELETE FROM exercise_progression_graphs WHERE id = %s", (graph_id,))
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,16 @@ Trainingsrahmenprogramm — wiederverwendbare Vorlage (Bibliothek) über mehrere
|
|||
|
||||
Zuordnung zu Trainingsgruppen / konkreten Einheiten erfolgt aus der Planung (Kopie + Lineage),
|
||||
nicht über group_id oder training_unit_id am Rahmen.
|
||||
Lesen wie Übungen (official / private / club); Schreiben nur Ersteller oder Plattform-Admin.
|
||||
Lesen wie Übungen (official / private / club); Bearbeiten wie Übungen; Löschen nach Rolle (s. club_tenancy).
|
||||
"""
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from club_tenancy import (
|
||||
assert_library_content_deletable,
|
||||
assert_library_content_editable,
|
||||
assert_library_content_governance_transition,
|
||||
assert_valid_governance_visibility,
|
||||
is_platform_admin,
|
||||
library_content_visible_to_profile,
|
||||
|
|
@ -56,13 +59,6 @@ def _framework_assert_readable(
|
|||
raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Trainingsrahmen")
|
||||
|
||||
|
||||
def _framework_assert_writable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None:
|
||||
if is_platform_admin(role):
|
||||
return
|
||||
if row.get("created_by") != profile_id:
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Trainingsrahmen")
|
||||
|
||||
|
||||
def _framework_access(cur, framework_id: int, profile_id: int, role: str) -> Dict[str, Any]:
|
||||
row = _fetch_framework_row(cur, framework_id)
|
||||
_framework_assert_readable(cur, row, profile_id, role)
|
||||
|
|
@ -459,7 +455,7 @@ def update_training_framework_program(
|
|||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
row_prev = _fetch_framework_row(cur, framework_id)
|
||||
_framework_assert_writable(cur, row_prev, profile_id, role)
|
||||
assert_library_content_editable(cur, profile_id, role, row_prev)
|
||||
|
||||
merged_vis = row_prev.get("visibility") or "private"
|
||||
merged_club = row_prev.get("club_id")
|
||||
|
|
@ -472,7 +468,12 @@ def update_training_framework_program(
|
|||
merged_club = data.get("club_id")
|
||||
if merged_club in ("", []):
|
||||
merged_club = None
|
||||
if merged_vis == "club" and merged_club is None:
|
||||
merged_club = tenant.effective_club_id
|
||||
if "visibility" in data or "club_id" in data:
|
||||
assert_library_content_governance_transition(
|
||||
cur, profile_id, role, row_prev, merged_vis, merged_club
|
||||
)
|
||||
assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club)
|
||||
|
||||
header_fields = []
|
||||
|
|
@ -574,7 +575,7 @@ def delete_training_framework_program(
|
|||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
row_fw = _fetch_framework_row(cur, framework_id)
|
||||
_framework_assert_writable(cur, row_fw, profile_id, role)
|
||||
assert_library_content_deletable(cur, profile_id, role, row_fw)
|
||||
cur.execute(
|
||||
"DELETE FROM training_framework_programs WHERE id = %s",
|
||||
(framework_id,),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
Trainingsmodule — wiederverwendbare Planungsbausteine (Bibliothek).
|
||||
|
||||
Governance wie Trainings‑Mikrovorlagen (`training_plan_templates`):
|
||||
Liste/Detail über `library_content_visibility_sql`; Schreiben: Ersteller oder Plattform‑Admin.
|
||||
Liste/Detail über `library_content_visibility_sql`; Bearbeiten/Löschen wie Übungen (s. club_tenancy).
|
||||
|
||||
Siehe `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md`.
|
||||
"""
|
||||
|
|
@ -13,6 +13,9 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||
from db import get_db, get_cursor, r2d
|
||||
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
|
||||
from club_tenancy import (
|
||||
assert_library_content_deletable,
|
||||
assert_library_content_editable,
|
||||
assert_library_content_governance_transition,
|
||||
assert_valid_governance_visibility,
|
||||
is_platform_admin,
|
||||
library_content_visible_to_profile,
|
||||
|
|
@ -47,13 +50,6 @@ def _module_assert_readable(cur, row: Dict[str, Any], profile_id: int, role: Opt
|
|||
raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Modul")
|
||||
|
||||
|
||||
def _module_assert_writable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None:
|
||||
if is_platform_admin(role):
|
||||
return
|
||||
if row.get("created_by") != profile_id:
|
||||
raise HTTPException(status_code=403, detail="Nur der Ersteller darf dieses Modul ändern")
|
||||
|
||||
|
||||
def _module_access(cur, mid: int, profile_id: int, role: str) -> Dict[str, Any]:
|
||||
row = _fetch_training_module_row(cur, mid)
|
||||
_module_assert_readable(cur, row, profile_id, role)
|
||||
|
|
@ -288,7 +284,7 @@ def update_training_module(
|
|||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
row_prev = _fetch_training_module_row(cur, module_id)
|
||||
_module_assert_writable(cur, row_prev, profile_id, role)
|
||||
assert_library_content_editable(cur, profile_id, role, row_prev)
|
||||
|
||||
merged_vis = row_prev.get("visibility") or "club"
|
||||
merged_club = row_prev.get("club_id")
|
||||
|
|
@ -303,8 +299,12 @@ def update_training_module(
|
|||
merged_club = data.get("club_id")
|
||||
if merged_club in ("", []):
|
||||
merged_club = None
|
||||
|
||||
if merged_vis == "club" and merged_club is None:
|
||||
merged_club = tenant.effective_club_id
|
||||
if "visibility" in data or "club_id" in data:
|
||||
assert_library_content_governance_transition(
|
||||
cur, profile_id, role, row_prev, merged_vis, merged_club
|
||||
)
|
||||
assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club)
|
||||
|
||||
fields: List[str] = []
|
||||
|
|
@ -375,7 +375,7 @@ def delete_training_module(module_id: int, tenant: TenantContext = Depends(get_t
|
|||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
row_del = _fetch_training_module_row(cur, module_id)
|
||||
_module_assert_writable(cur, row_del, profile_id, role)
|
||||
assert_library_content_deletable(cur, profile_id, role, row_del)
|
||||
cur.execute("DELETE FROM training_modules WHERE id = %s", (module_id,))
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
Training Planning – Trainingseinheiten, strukturierter Ablauf (Sektionen + Übungen/Notizen)
|
||||
und wiederverwendbare Trainingsvorlagen (Sektions-Gliederung).
|
||||
|
||||
Governance: Sichtbarkeit wie Übungen (private / club / official); Schreiben nur Ersteller oder Plattform-Admin.
|
||||
Governance: Sichtbarkeit wie Übungen (private / club / official); Bearbeiten wie Übungen; Löschen nach Rolle (s. club_tenancy).
|
||||
"""
|
||||
from datetime import date, datetime, time as dt_time, timedelta
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
|
@ -15,6 +15,9 @@ from fastapi_param_unwrap import unwrap_query_default
|
|||
from db import get_db, get_cursor, r2d
|
||||
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
|
||||
from club_tenancy import (
|
||||
assert_library_content_deletable,
|
||||
assert_library_content_editable,
|
||||
assert_library_content_governance_transition,
|
||||
assert_valid_governance_visibility,
|
||||
can_manage_club_org,
|
||||
is_platform_admin,
|
||||
|
|
@ -1748,13 +1751,6 @@ def _template_assert_readable(cur, row: Dict[str, Any], profile_id: int, role: O
|
|||
raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Vorlage")
|
||||
|
||||
|
||||
def _template_assert_writable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None:
|
||||
if is_platform_admin(role):
|
||||
return
|
||||
if row.get("created_by") != profile_id:
|
||||
raise HTTPException(status_code=403, detail="Nur der Ersteller darf diese Vorlage ändern")
|
||||
|
||||
|
||||
def _template_access(cur, tid: int, profile_id: int, role: str) -> Dict[str, Any]:
|
||||
"""Lesender Zugriff (Liste der Vorlage für Einheit); Schreiben: _template_assert_writable."""
|
||||
row = _fetch_training_plan_template_row(cur, tid)
|
||||
|
|
@ -1853,7 +1849,7 @@ def update_training_plan_template(template_id: int, data: dict, tenant: TenantCo
|
|||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
row_prev = _fetch_training_plan_template_row(cur, template_id)
|
||||
_template_assert_writable(cur, row_prev, profile_id, role)
|
||||
assert_library_content_editable(cur, profile_id, role, row_prev)
|
||||
merged_vis = row_prev.get("visibility") or "club"
|
||||
merged_club = row_prev.get("club_id")
|
||||
if "visibility" in data:
|
||||
|
|
@ -1865,7 +1861,12 @@ def update_training_plan_template(template_id: int, data: dict, tenant: TenantCo
|
|||
merged_club = data.get("club_id")
|
||||
if merged_club in ("", []):
|
||||
merged_club = None
|
||||
if merged_vis == "club" and merged_club is None:
|
||||
merged_club = tenant.effective_club_id
|
||||
if "visibility" in data or "club_id" in data:
|
||||
assert_library_content_governance_transition(
|
||||
cur, profile_id, role, row_prev, merged_vis, merged_club
|
||||
)
|
||||
assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club)
|
||||
fields = []
|
||||
params: List[Any] = []
|
||||
|
|
@ -1911,7 +1912,7 @@ def delete_training_plan_template(template_id: int, tenant: TenantContext = Depe
|
|||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
row_del = _fetch_training_plan_template_row(cur, template_id)
|
||||
_template_assert_writable(cur, row_del, profile_id, role)
|
||||
assert_library_content_deletable(cur, profile_id, role, row_del)
|
||||
cur.execute("DELETE FROM training_plan_templates WHERE id = %s", (template_id,))
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.141"
|
||||
APP_VERSION = "0.8.142"
|
||||
BUILD_DATE = "2026-05-14"
|
||||
DB_SCHEMA_VERSION = "20260515064"
|
||||
|
||||
|
|
@ -24,9 +24,9 @@ MODULE_VERSIONS = {
|
|||
"exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break
|
||||
"training_units": "0.3.0", # GET /api/training-units Keyset cursor_planned_date + cursor_id (+ optional cursor_planned_time); Sort mit id-Tiebreak
|
||||
"training_programs": "0.1.0",
|
||||
"planning": "0.12.0", # Trainingsvorlagen: Phasen/Streams in template_sections (064); Instantiate über _replace_unit_phases
|
||||
"planning": "0.13.0", # Vorlagen/Framework/Module/Graphs: RBAC wie Übungen (edit/delete/governance transition); Planungs-UI Sichtbarkeit neue Vorlage
|
||||
"dashboard": "1.1.0", # GET /api/dashboard/kpis inkl. training_home (ein Client-Roundtrip für KPIs + nächste Termine)
|
||||
"training_modules": "1.0.0",
|
||||
"training_modules": "1.1.0", # PUT/DELETE: assert_library_content_* (Vereinsadmin löscht Vereins-Inhalt, Trainer bearbeitet club wie Übungen)
|
||||
"import_wiki": "1.0.0",
|
||||
"admin": "1.0.0",
|
||||
"membership": "1.0.0",
|
||||
|
|
@ -36,6 +36,14 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.142",
|
||||
"date": "2026-05-16",
|
||||
"changes": [
|
||||
"Bibliothek Planung: Bearbeiten/Löschen von Vorlagen, Rahmenprogrammen, Trainingsmodulen, Progressionsgraphen nach RBAC wie Übungen (club_tenancy.assert_library_content_* + Governance-Übergänge)",
|
||||
"Planungs-UI: Sichtbarkeit/Verein beim Speichern neuer Trainingsvorlage",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.141",
|
||||
"date": "2026-05-14",
|
||||
|
|
|
|||
|
|
@ -643,12 +643,31 @@ function TrainingPlanningPageRoot() {
|
|||
})
|
||||
}, [user?.id, loading, searchParams, handleEdit, setSearchParams])
|
||||
|
||||
const handleSaveAsTemplate = async () => {
|
||||
const handleSaveAsTemplate = async (opts = {}) => {
|
||||
const name = window.prompt('Name für die neue Trainingsvorlage (nur Abschnitts-Gliederung):')
|
||||
if (!name?.trim()) return
|
||||
const visibility =
|
||||
typeof opts.visibility === 'string' && opts.visibility.trim()
|
||||
? String(opts.visibility).trim().toLowerCase()
|
||||
: 'private'
|
||||
let club_id = opts.club_id != null && opts.club_id !== '' ? Number(opts.club_id) : null
|
||||
if (visibility === 'club') {
|
||||
if (!Number.isFinite(club_id) || club_id < 1) {
|
||||
const fb = planningModalClubId != null ? Number(planningModalClubId) : NaN
|
||||
if (Number.isFinite(fb) && fb >= 1) club_id = fb
|
||||
}
|
||||
if (!Number.isFinite(club_id) || club_id < 1) {
|
||||
toast.error('Bitte einen Verein wählen (Sichtbarkeit „Verein“).')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
club_id = null
|
||||
}
|
||||
try {
|
||||
await api.createTrainingPlanTemplate({
|
||||
name: name.trim(),
|
||||
visibility,
|
||||
club_id: visibility === 'club' ? club_id : null,
|
||||
sections: templateSectionsPayloadFromFormSections(formData.sections),
|
||||
})
|
||||
await loadPlanTemplates()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import React from 'react'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import TrainingPlanExerciseVisibilityPanel from '../TrainingPlanExerciseVisibilityPanel'
|
||||
import TrainingUnitSectionsEditor from '../TrainingUnitSectionsEditor'
|
||||
import { activeClubMemberships } from '../../utils/activeClub'
|
||||
import { canDeleteLibraryContent } from '../../utils/libraryContentPermissions'
|
||||
import { frameworkLineageText } from '../../utils/trainingPlanningPageHelpers'
|
||||
|
||||
/**
|
||||
|
|
@ -31,6 +33,22 @@ export default function TrainingPlanningUnitFormModal({
|
|||
onRequestExercisePick,
|
||||
onPeekExercise,
|
||||
}) {
|
||||
const [newTplVisibility, setNewTplVisibility] = useState('private')
|
||||
const [newTplClubId, setNewTplClubId] = useState('')
|
||||
|
||||
const memberClubs = useMemo(() => activeClubMemberships(user?.clubs), [user?.clubs])
|
||||
const roleLc = String(user?.role || '').toLowerCase()
|
||||
const isSuperadmin = roleLc === 'superadmin'
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (planningModalClubId != null && planningModalClubId !== '') {
|
||||
setNewTplClubId(String(planningModalClubId))
|
||||
} else if (memberClubs.length === 1) {
|
||||
setNewTplClubId(String(memberClubs[0].id))
|
||||
}
|
||||
}, [open, planningModalClubId, memberClubs])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
|
|
@ -116,12 +134,17 @@ export default function TrainingPlanningUnitFormModal({
|
|||
onChange={(e) => onDraftTemplateSelect(e.target.value)}
|
||||
>
|
||||
<option value="">Ohne Vorlage — leere Gliederung (ein Abschnitt)</option>
|
||||
{planTemplates.map((t) => (
|
||||
{planTemplates.map((t) => {
|
||||
const v = String(t.visibility || 'club').toLowerCase()
|
||||
const vLabel = v === 'private' ? 'Privat' : v === 'official' ? 'Offiziell' : 'Verein'
|
||||
return (
|
||||
<option key={t.id} value={String(t.id)}>
|
||||
{t.name}
|
||||
{typeof t.sections_count === 'number' ? ` · ${t.sections_count} Abschn.` : ''}
|
||||
{typeof t.sections_count === 'number' ? ` · ${t.sections_count} Abschn.` : ''}{' '}
|
||||
· {vLabel}
|
||||
</option>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
<p className="training-planning-template-panel__help">
|
||||
Übernimmt nur die <strong>Sektionsstruktur</strong> aus der Bibliothek; Übungen trägst du unten bei den
|
||||
|
|
@ -144,17 +167,12 @@ export default function TrainingPlanningUnitFormModal({
|
|||
Gespeicherte Vorlagen löschen
|
||||
</summary>
|
||||
<p style={{ margin: '0.65rem 0 0.75rem', fontSize: '0.82rem', color: 'var(--text2)', lineHeight: 1.45 }}>
|
||||
Du kannst eigene Vorlagen entfernen. Plattform-Admins dürfen auch fremde Vorlagen löschen. Einheiten, die
|
||||
noch auf eine Vorlage verweisen, behalten ihren Ablauf; die Verknüpfung zur Vorlage wird vom Server
|
||||
entfernt.
|
||||
Entfernen nach Rolle: eigene private Vorlagen; Vereinsinhalte als Vereinsadmin; offizielle nur als
|
||||
Plattform‑Admin. Einheiten mit Verweis behalten den Ablauf; die Vorlage wird entkoppelt.
|
||||
</p>
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{planTemplates.map((t, ti) => {
|
||||
const roleLc = String(user?.role || '').toLowerCase()
|
||||
const isPlatformAdmin = roleLc === 'admin' || roleLc === 'superadmin'
|
||||
const canDel =
|
||||
user &&
|
||||
(isPlatformAdmin || Number(t.created_by) === Number(user.id))
|
||||
const canDel = user && canDeleteLibraryContent(user, t)
|
||||
return (
|
||||
<li
|
||||
key={t.id}
|
||||
|
|
@ -169,6 +187,15 @@ export default function TrainingPlanningUnitFormModal({
|
|||
>
|
||||
<span style={{ minWidth: 0, flex: 1, fontSize: '0.9rem' }}>
|
||||
<strong style={{ color: 'var(--text1)' }}>{t.name}</strong>
|
||||
<span style={{ fontSize: '0.78rem', color: 'var(--text3)', marginLeft: '6px' }}>
|
||||
(
|
||||
{String(t.visibility || 'club').toLowerCase() === 'private'
|
||||
? 'Privat'
|
||||
: String(t.visibility || 'club').toLowerCase() === 'official'
|
||||
? 'Offiziell'
|
||||
: 'Verein'}
|
||||
)
|
||||
</span>
|
||||
{typeof t.sections_count === 'number' ? (
|
||||
<span style={{ fontSize: '0.82rem', color: 'var(--text2)', marginLeft: '6px' }}>
|
||||
· {t.sections_count} Abschn.
|
||||
|
|
@ -401,9 +428,71 @@ export default function TrainingPlanningUnitFormModal({
|
|||
heading="Abschnitte & Übungen"
|
||||
headingAccessory={
|
||||
<>
|
||||
<button type="button" className="btn btn-secondary" onClick={onSaveAsTemplate}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'flex-end',
|
||||
gap: '10px',
|
||||
marginBottom: '10px',
|
||||
}}
|
||||
>
|
||||
<div className="form-row" style={{ marginBottom: 0, minWidth: 'min(160px, 100%)' }}>
|
||||
<label className="form-label" style={{ fontSize: '0.82rem' }}>
|
||||
Neue Vorlage: Sichtbarkeit
|
||||
</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={newTplVisibility}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setNewTplVisibility(v)
|
||||
if (v === 'club' && !newTplClubId && planningModalClubId != null) {
|
||||
setNewTplClubId(String(planningModalClubId))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="private">Privat (nur du)</option>
|
||||
<option value="club">Verein</option>
|
||||
{isSuperadmin ? <option value="official">Offiziell (global)</option> : null}
|
||||
</select>
|
||||
</div>
|
||||
{newTplVisibility === 'club' ? (
|
||||
<div className="form-row" style={{ marginBottom: 0, flex: '1 1 200px' }}>
|
||||
<label className="form-label" style={{ fontSize: '0.82rem' }}>
|
||||
Verein
|
||||
</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={newTplClubId}
|
||||
onChange={(e) => setNewTplClubId(e.target.value)}
|
||||
>
|
||||
<option value="">— Verein wählen —</option>
|
||||
{memberClubs.map((c) => (
|
||||
<option key={c.id} value={String(c.id)}>
|
||||
{c.name || `Verein #${c.id}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ marginBottom: '2px' }}
|
||||
onClick={() =>
|
||||
onSaveAsTemplate?.({
|
||||
visibility: newTplVisibility,
|
||||
club_id:
|
||||
newTplVisibility === 'club' && newTplClubId
|
||||
? parseInt(newTplClubId, 10)
|
||||
: null,
|
||||
})
|
||||
}
|
||||
>
|
||||
Vorlage aus Aufbau speichern
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
sections={formData.sections}
|
||||
|
|
|
|||
28
frontend/src/utils/libraryContentPermissions.js
Normal file
28
frontend/src/utils/libraryContentPermissions.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { activeClubMemberships } from './activeClub'
|
||||
|
||||
export function clubAdminInClub(user, clubId) {
|
||||
const cid = Number(clubId)
|
||||
if (!Number.isFinite(cid)) return false
|
||||
const row = activeClubMemberships(user?.clubs).find((c) => Number(c.id) === cid)
|
||||
return Boolean(row?.roles?.includes('club_admin'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Löschen von Bibliotheks-/Planungsinhalten (Vorlage, Modul, Rahmen, Graph) — grob wie Backend club_tenancy.
|
||||
* Vereins-Admins können fremde private Einträge im API löschen (gemeinsamer Verein); das blenden wir hier nicht ein.
|
||||
*/
|
||||
export function canDeleteLibraryContent(user, row) {
|
||||
const grole = String(user?.role || '').toLowerCase()
|
||||
if (grole === 'admin' || grole === 'superadmin') return true
|
||||
const uid = Number(user?.id)
|
||||
if (!Number.isFinite(uid)) return false
|
||||
|
||||
const vis = String(row?.visibility ?? 'club').toLowerCase()
|
||||
const createdBy = row?.created_by != null ? Number(row.created_by) : null
|
||||
const clubId = row?.club_id != null ? Number(row.club_id) : null
|
||||
|
||||
if (vis === 'official') return false
|
||||
if (vis === 'club') return clubAdminInClub(user, clubId)
|
||||
if (vis === 'private') return Number.isFinite(createdBy) && createdBy === uid
|
||||
return false
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user