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
|
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
|
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"})
|
_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(
|
def assert_valid_governance_visibility(
|
||||||
cur,
|
cur,
|
||||||
profile_id: int,
|
profile_id: int,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""
|
"""
|
||||||
Progressionsgraph zwischen Übungen (Übung → Übung), Migration 032–034.
|
Progressionsgraph zwischen Übungen (Übung → Übung), Migration 032–034.
|
||||||
Optional Übungsvarianten als Knoten-Endpunkte; Sequenz-Bulk-Anlage.
|
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
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
|
|
@ -12,8 +12,10 @@ from psycopg2 import IntegrityError
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
|
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
|
||||||
from club_tenancy import (
|
from club_tenancy import (
|
||||||
|
assert_library_content_deletable,
|
||||||
|
assert_library_content_editable,
|
||||||
|
assert_library_content_governance_transition,
|
||||||
assert_valid_governance_visibility,
|
assert_valid_governance_visibility,
|
||||||
is_platform_admin,
|
|
||||||
library_content_visible_to_profile,
|
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:
|
def _assert_graph_writable(cur, row: dict, profile_id: int, role: str) -> None:
|
||||||
if is_platform_admin(role):
|
assert_library_content_editable(cur, profile_id, role, row)
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
def _require_graph_read(cur, graph_id: int, profile_id: int, role: str) -> dict:
|
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
|
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)
|
assert_valid_governance_visibility(cur, profile_id, role, next_vis, gov_club)
|
||||||
|
|
||||||
fields: List[str] = []
|
fields: List[str] = []
|
||||||
|
|
@ -376,7 +373,9 @@ def delete_progression_graph(graph_id: int, tenant: TenantContext = Depends(get_
|
||||||
role = tenant.global_role
|
role = tenant.global_role
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(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,))
|
cur.execute("DELETE FROM exercise_progression_graphs WHERE id = %s", (graph_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,16 @@ Trainingsrahmenprogramm — wiederverwendbare Vorlage (Bibliothek) über mehrere
|
||||||
|
|
||||||
Zuordnung zu Trainingsgruppen / konkreten Einheiten erfolgt aus der Planung (Kopie + Lineage),
|
Zuordnung zu Trainingsgruppen / konkreten Einheiten erfolgt aus der Planung (Kopie + Lineage),
|
||||||
nicht über group_id oder training_unit_id am Rahmen.
|
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 typing import Any, Dict, List, Optional, Sequence
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from club_tenancy import (
|
from club_tenancy import (
|
||||||
|
assert_library_content_deletable,
|
||||||
|
assert_library_content_editable,
|
||||||
|
assert_library_content_governance_transition,
|
||||||
assert_valid_governance_visibility,
|
assert_valid_governance_visibility,
|
||||||
is_platform_admin,
|
is_platform_admin,
|
||||||
library_content_visible_to_profile,
|
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")
|
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]:
|
def _framework_access(cur, framework_id: int, profile_id: int, role: str) -> Dict[str, Any]:
|
||||||
row = _fetch_framework_row(cur, framework_id)
|
row = _fetch_framework_row(cur, framework_id)
|
||||||
_framework_assert_readable(cur, row, profile_id, role)
|
_framework_assert_readable(cur, row, profile_id, role)
|
||||||
|
|
@ -459,7 +455,7 @@ def update_training_framework_program(
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
row_prev = _fetch_framework_row(cur, framework_id)
|
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_vis = row_prev.get("visibility") or "private"
|
||||||
merged_club = row_prev.get("club_id")
|
merged_club = row_prev.get("club_id")
|
||||||
|
|
@ -472,7 +468,12 @@ def update_training_framework_program(
|
||||||
merged_club = data.get("club_id")
|
merged_club = data.get("club_id")
|
||||||
if merged_club in ("", []):
|
if merged_club in ("", []):
|
||||||
merged_club = None
|
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:
|
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)
|
assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club)
|
||||||
|
|
||||||
header_fields = []
|
header_fields = []
|
||||||
|
|
@ -574,7 +575,7 @@ def delete_training_framework_program(
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
row_fw = _fetch_framework_row(cur, framework_id)
|
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(
|
cur.execute(
|
||||||
"DELETE FROM training_framework_programs WHERE id = %s",
|
"DELETE FROM training_framework_programs WHERE id = %s",
|
||||||
(framework_id,),
|
(framework_id,),
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
Trainingsmodule — wiederverwendbare Planungsbausteine (Bibliothek).
|
Trainingsmodule — wiederverwendbare Planungsbausteine (Bibliothek).
|
||||||
|
|
||||||
Governance wie Trainings‑Mikrovorlagen (`training_plan_templates`):
|
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`.
|
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 db import get_db, get_cursor, r2d
|
||||||
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
|
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
|
||||||
from club_tenancy import (
|
from club_tenancy import (
|
||||||
|
assert_library_content_deletable,
|
||||||
|
assert_library_content_editable,
|
||||||
|
assert_library_content_governance_transition,
|
||||||
assert_valid_governance_visibility,
|
assert_valid_governance_visibility,
|
||||||
is_platform_admin,
|
is_platform_admin,
|
||||||
library_content_visible_to_profile,
|
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")
|
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]:
|
def _module_access(cur, mid: int, profile_id: int, role: str) -> Dict[str, Any]:
|
||||||
row = _fetch_training_module_row(cur, mid)
|
row = _fetch_training_module_row(cur, mid)
|
||||||
_module_assert_readable(cur, row, profile_id, role)
|
_module_assert_readable(cur, row, profile_id, role)
|
||||||
|
|
@ -288,7 +284,7 @@ def update_training_module(
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
row_prev = _fetch_training_module_row(cur, module_id)
|
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_vis = row_prev.get("visibility") or "club"
|
||||||
merged_club = row_prev.get("club_id")
|
merged_club = row_prev.get("club_id")
|
||||||
|
|
@ -303,8 +299,12 @@ def update_training_module(
|
||||||
merged_club = data.get("club_id")
|
merged_club = data.get("club_id")
|
||||||
if merged_club in ("", []):
|
if merged_club in ("", []):
|
||||||
merged_club = None
|
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:
|
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)
|
assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club)
|
||||||
|
|
||||||
fields: List[str] = []
|
fields: List[str] = []
|
||||||
|
|
@ -375,7 +375,7 @@ def delete_training_module(module_id: int, tenant: TenantContext = Depends(get_t
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
row_del = _fetch_training_module_row(cur, module_id)
|
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,))
|
cur.execute("DELETE FROM training_modules WHERE id = %s", (module_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
Training Planning – Trainingseinheiten, strukturierter Ablauf (Sektionen + Übungen/Notizen)
|
Training Planning – Trainingseinheiten, strukturierter Ablauf (Sektionen + Übungen/Notizen)
|
||||||
und wiederverwendbare Trainingsvorlagen (Sektions-Gliederung).
|
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 datetime import date, datetime, time as dt_time, timedelta
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
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 db import get_db, get_cursor, r2d
|
||||||
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
|
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
|
||||||
from club_tenancy import (
|
from club_tenancy import (
|
||||||
|
assert_library_content_deletable,
|
||||||
|
assert_library_content_editable,
|
||||||
|
assert_library_content_governance_transition,
|
||||||
assert_valid_governance_visibility,
|
assert_valid_governance_visibility,
|
||||||
can_manage_club_org,
|
can_manage_club_org,
|
||||||
is_platform_admin,
|
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")
|
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]:
|
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."""
|
"""Lesender Zugriff (Liste der Vorlage für Einheit); Schreiben: _template_assert_writable."""
|
||||||
row = _fetch_training_plan_template_row(cur, tid)
|
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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
row_prev = _fetch_training_plan_template_row(cur, template_id)
|
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_vis = row_prev.get("visibility") or "club"
|
||||||
merged_club = row_prev.get("club_id")
|
merged_club = row_prev.get("club_id")
|
||||||
if "visibility" in data:
|
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")
|
merged_club = data.get("club_id")
|
||||||
if merged_club in ("", []):
|
if merged_club in ("", []):
|
||||||
merged_club = None
|
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:
|
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)
|
assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club)
|
||||||
fields = []
|
fields = []
|
||||||
params: List[Any] = []
|
params: List[Any] = []
|
||||||
|
|
@ -1911,7 +1912,7 @@ def delete_training_plan_template(template_id: int, tenant: TenantContext = Depe
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
row_del = _fetch_training_plan_template_row(cur, template_id)
|
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,))
|
cur.execute("DELETE FROM training_plan_templates WHERE id = %s", (template_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.141"
|
APP_VERSION = "0.8.142"
|
||||||
BUILD_DATE = "2026-05-14"
|
BUILD_DATE = "2026-05-14"
|
||||||
DB_SCHEMA_VERSION = "20260515064"
|
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
|
"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_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",
|
"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)
|
"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",
|
"import_wiki": "1.0.0",
|
||||||
"admin": "1.0.0",
|
"admin": "1.0.0",
|
||||||
"membership": "1.0.0",
|
"membership": "1.0.0",
|
||||||
|
|
@ -36,6 +36,14 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.141",
|
||||||
"date": "2026-05-14",
|
"date": "2026-05-14",
|
||||||
|
|
|
||||||
|
|
@ -643,12 +643,31 @@ function TrainingPlanningPageRoot() {
|
||||||
})
|
})
|
||||||
}, [user?.id, loading, searchParams, handleEdit, setSearchParams])
|
}, [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):')
|
const name = window.prompt('Name für die neue Trainingsvorlage (nur Abschnitts-Gliederung):')
|
||||||
if (!name?.trim()) return
|
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 {
|
try {
|
||||||
await api.createTrainingPlanTemplate({
|
await api.createTrainingPlanTemplate({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
|
visibility,
|
||||||
|
club_id: visibility === 'club' ? club_id : null,
|
||||||
sections: templateSectionsPayloadFromFormSections(formData.sections),
|
sections: templateSectionsPayloadFromFormSections(formData.sections),
|
||||||
})
|
})
|
||||||
await loadPlanTemplates()
|
await loadPlanTemplates()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import React from 'react'
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import TrainingPlanExerciseVisibilityPanel from '../TrainingPlanExerciseVisibilityPanel'
|
import TrainingPlanExerciseVisibilityPanel from '../TrainingPlanExerciseVisibilityPanel'
|
||||||
import TrainingUnitSectionsEditor from '../TrainingUnitSectionsEditor'
|
import TrainingUnitSectionsEditor from '../TrainingUnitSectionsEditor'
|
||||||
|
import { activeClubMemberships } from '../../utils/activeClub'
|
||||||
|
import { canDeleteLibraryContent } from '../../utils/libraryContentPermissions'
|
||||||
import { frameworkLineageText } from '../../utils/trainingPlanningPageHelpers'
|
import { frameworkLineageText } from '../../utils/trainingPlanningPageHelpers'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -31,6 +33,22 @@ export default function TrainingPlanningUnitFormModal({
|
||||||
onRequestExercisePick,
|
onRequestExercisePick,
|
||||||
onPeekExercise,
|
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
|
if (!open) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -116,12 +134,17 @@ export default function TrainingPlanningUnitFormModal({
|
||||||
onChange={(e) => onDraftTemplateSelect(e.target.value)}
|
onChange={(e) => onDraftTemplateSelect(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">Ohne Vorlage — leere Gliederung (ein Abschnitt)</option>
|
<option value="">Ohne Vorlage — leere Gliederung (ein Abschnitt)</option>
|
||||||
{planTemplates.map((t) => (
|
{planTemplates.map((t) => {
|
||||||
<option key={t.id} value={String(t.id)}>
|
const v = String(t.visibility || 'club').toLowerCase()
|
||||||
{t.name}
|
const vLabel = v === 'private' ? 'Privat' : v === 'official' ? 'Offiziell' : 'Verein'
|
||||||
{typeof t.sections_count === 'number' ? ` · ${t.sections_count} Abschn.` : ''}
|
return (
|
||||||
</option>
|
<option key={t.id} value={String(t.id)}>
|
||||||
))}
|
{t.name}
|
||||||
|
{typeof t.sections_count === 'number' ? ` · ${t.sections_count} Abschn.` : ''}{' '}
|
||||||
|
· {vLabel}
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</select>
|
</select>
|
||||||
<p className="training-planning-template-panel__help">
|
<p className="training-planning-template-panel__help">
|
||||||
Übernimmt nur die <strong>Sektionsstruktur</strong> aus der Bibliothek; Übungen trägst du unten bei den
|
Ü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
|
Gespeicherte Vorlagen löschen
|
||||||
</summary>
|
</summary>
|
||||||
<p style={{ margin: '0.65rem 0 0.75rem', fontSize: '0.82rem', color: 'var(--text2)', lineHeight: 1.45 }}>
|
<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
|
Entfernen nach Rolle: eigene private Vorlagen; Vereinsinhalte als Vereinsadmin; offizielle nur als
|
||||||
noch auf eine Vorlage verweisen, behalten ihren Ablauf; die Verknüpfung zur Vorlage wird vom Server
|
Plattform‑Admin. Einheiten mit Verweis behalten den Ablauf; die Vorlage wird entkoppelt.
|
||||||
entfernt.
|
|
||||||
</p>
|
</p>
|
||||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||||
{planTemplates.map((t, ti) => {
|
{planTemplates.map((t, ti) => {
|
||||||
const roleLc = String(user?.role || '').toLowerCase()
|
const canDel = user && canDeleteLibraryContent(user, t)
|
||||||
const isPlatformAdmin = roleLc === 'admin' || roleLc === 'superadmin'
|
|
||||||
const canDel =
|
|
||||||
user &&
|
|
||||||
(isPlatformAdmin || Number(t.created_by) === Number(user.id))
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={t.id}
|
key={t.id}
|
||||||
|
|
@ -169,6 +187,15 @@ export default function TrainingPlanningUnitFormModal({
|
||||||
>
|
>
|
||||||
<span style={{ minWidth: 0, flex: 1, fontSize: '0.9rem' }}>
|
<span style={{ minWidth: 0, flex: 1, fontSize: '0.9rem' }}>
|
||||||
<strong style={{ color: 'var(--text1)' }}>{t.name}</strong>
|
<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' ? (
|
{typeof t.sections_count === 'number' ? (
|
||||||
<span style={{ fontSize: '0.82rem', color: 'var(--text2)', marginLeft: '6px' }}>
|
<span style={{ fontSize: '0.82rem', color: 'var(--text2)', marginLeft: '6px' }}>
|
||||||
· {t.sections_count} Abschn.
|
· {t.sections_count} Abschn.
|
||||||
|
|
@ -401,9 +428,71 @@ export default function TrainingPlanningUnitFormModal({
|
||||||
heading="Abschnitte & Übungen"
|
heading="Abschnitte & Übungen"
|
||||||
headingAccessory={
|
headingAccessory={
|
||||||
<>
|
<>
|
||||||
<button type="button" className="btn btn-secondary" onClick={onSaveAsTemplate}>
|
<div
|
||||||
Vorlage aus Aufbau speichern
|
style={{
|
||||||
</button>
|
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}
|
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