feat: implement multi-tenancy governance and enhance club member management
Some checks failed
Deploy Development / deploy (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 39s

- Bumped application version to 0.8.17, reflecting updates in both backend and frontend.
- Introduced governance visibility for training plan templates and framework programs, allowing access based on visibility settings (private, club, official).
- Added API endpoints for managing club members, including listing, adding, updating, and removing members.
- Updated changelog to document the new features and changes made in this release.
This commit is contained in:
Lars 2026-05-05 16:29:11 +02:00
parent c7bf7dcd9d
commit 7d476268b8
9 changed files with 501 additions and 45 deletions

View File

@ -172,14 +172,16 @@ Ziel: **vereinszentrierte** Vertrags- und Limitlogik, analog zur bestehenden Tie
### Phase 3 Effektive Berechtigungen (RBAC)
- **Mitgliederverwaltung per API (ohne UI):** `GET/POST /api/clubs/{club_id}/members`, `GET/PUT/DELETE /api/clubs/{club_id}/members/{profile_id}` — nur Plattform-Admin oder `club_admin` im Zielverein (Stand Code **0.8.16**).
- Zentrale Modulfunktion z.B. `authorization/club_permissions.py`:
`can(club_id, profile_id, permission, division_id=None)`.
`can(club_id, profile_id, permission, division_id=None)` — optional später; aktuell `club_tenancy.can_manage_club_org` / `has_club_role`.
- Router schrittweise umbinden: Sparten/Gruppen CRUD nach Rolle `club_admin` im Kontext; Systemadmin unverändert Vollzugriff.
### Phase 4 Sichtbarkeit & Leaks schließen
- **Übungen:** `club`-Sichtbarkeit nur bei Übereinstimmung `exercise.club_id` mit Mitgliedschaft (und später `division`).
- Gleiches Muster für `training_plan_templates`, `training_framework_programs`, Progressionsgraphen, ggf. Medien.
- **Trainingsplan-Vorlagen** (`training_plan_templates`) und **Rahmenprogramme** (`training_framework_programs`): gleiches Muster für Listen/GET (Stand **0.8.17**); Schreiben weiterhin nur Ersteller oder Plattform-Admin.
- Gleiches Muster für Progressionsgraphen, ggf. Medien (offen).
- Tests: zwei Vereine, zwei Nutzer, keine Kreuzzugriffe.
### Phase 5 Mitgliedschaft / Limits

View File

@ -96,6 +96,30 @@ def memberships_with_roles(cur, profile_id: int) -> List[Dict[str, Any]]:
return out
_GOVERNANCE_VISIBILITY = frozenset({"private", "club", "official"})
def assert_valid_governance_visibility(
cur,
profile_id: int,
role: Optional[str],
visibility: str,
club_id: Optional[int],
) -> None:
"""Pflicht club_id bei visibility=club + Mitgliedschaft; official nur Plattform-Admin."""
if visibility not in _GOVERNANCE_VISIBILITY:
raise HTTPException(status_code=400, detail="Ungültige visibility")
if visibility == "official" and not is_platform_admin(role):
raise HTTPException(
status_code=403,
detail="Nur Plattform-Admins dürfen offizielle Inhalte setzen",
)
if visibility == "club":
if club_id is None:
raise HTTPException(status_code=400, detail="club_id ist bei visibility=club erforderlich")
assert_club_member(cur, profile_id, club_id)
def exercise_visible_to_profile(
cur,
profile_id: int,

View File

@ -154,13 +154,14 @@ def read_root():
}
# Register routers
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin
app.include_router(auth.router)
app.include_router(profiles.router)
app.include_router(exercises.router)
app.include_router(exercise_progression_graphs.router)
app.include_router(clubs.router)
app.include_router(club_memberships.router)
app.include_router(skills.router)
app.include_router(training_planning.router)
app.include_router(training_framework_programs.router)

View File

@ -0,0 +1,268 @@
"""
Vereins-Mitgliedschaften und Rollen (ohne UI nutzbar für Admin/Automatisierung).
Berechtigung: Plattform-Admin (admin/superadmin) oder Vereinsadmin (club_admin) im Zielverein.
"""
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from auth import require_auth
from club_tenancy import can_manage_club_org
from db import get_db, get_cursor, r2d
router = APIRouter(prefix="/api", tags=["club_memberships"])
_ALLOWED_ROLES = frozenset({"club_admin", "trainer", "division_lead", "content_editor"})
_ALLOWED_STATUS = frozenset({"active", "inactive"})
def _normalize_roles(raw: List[str]) -> List[str]:
out: List[str] = []
seen = set()
for r in raw:
if not isinstance(r, str):
raise HTTPException(status_code=400, detail="Rollen müssen Strings sein")
code = r.strip().lower()
if not code:
continue
if code not in _ALLOWED_ROLES:
raise HTTPException(
status_code=400,
detail=f"Unbekannte Rolle: {code}. Erlaubt: {', '.join(sorted(_ALLOWED_ROLES))}",
)
if code not in seen:
seen.add(code)
out.append(code)
return out
def _assert_manage(cur, session: dict, club_id: int) -> None:
pid = session["profile_id"]
role = session.get("role")
if not can_manage_club_org(cur, pid, club_id, role):
raise HTTPException(status_code=403, detail="Keine Berechtigung zur Mitglieder-Verwaltung in diesem Verein")
def _club_exists(cur, club_id: int) -> bool:
cur.execute("SELECT 1 FROM clubs WHERE id = %s", (club_id,))
return cur.fetchone() is not None
class ClubMemberUpsert(BaseModel):
profile_id: int = Field(..., ge=1)
roles: List[str] = Field(default_factory=list, description="Mindestens eine Vereinsrolle")
class ClubMemberPatch(BaseModel):
roles: Optional[List[str]] = None
status: Optional[str] = Field(None, description="active oder inactive")
@router.get("/clubs/{club_id}/members")
def list_club_members(
club_id: int,
include_inactive: bool = Query(False),
session: dict = Depends(require_auth),
):
"""Alle Mitglieder eines Vereins mit Rollen (nur Vereins-/Plattform-Admin)."""
with get_db() as conn:
cur = get_cursor(conn)
if not _club_exists(cur, club_id):
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
_assert_manage(cur, session, club_id)
status_clause = "" if include_inactive else "AND cm.status = 'active'"
cur.execute(
f"""
SELECT cm.id AS membership_id, cm.profile_id, cm.status, cm.created_at, cm.updated_at,
p.email, p.name,
COALESCE(
ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL),
ARRAY[]::varchar[]
) AS roles
FROM club_members cm
INNER JOIN profiles p ON p.id = cm.profile_id
LEFT JOIN club_member_roles r ON r.club_member_id = cm.id
WHERE cm.club_id = %s {status_clause}
GROUP BY cm.id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name
ORDER BY p.name NULLS LAST, p.email
""",
(club_id,),
)
rows = []
for row in cur.fetchall():
d = r2d(row)
roles = d.get("roles") or []
if hasattr(roles, "tolist"):
roles = roles.tolist()
d["roles"] = list(roles)
rows.append(d)
return rows
@router.post("/clubs/{club_id}/members", status_code=201)
def upsert_club_member(
club_id: int,
body: ClubMemberUpsert,
session: dict = Depends(require_auth),
):
"""Mitglied anlegen oder aktivieren; Rollen werden vollständig ersetzt."""
roles = _normalize_roles(body.roles)
if not roles:
raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben")
with get_db() as conn:
cur = get_cursor(conn)
if not _club_exists(cur, club_id):
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
_assert_manage(cur, session, club_id)
cur.execute("SELECT id FROM profiles WHERE id = %s", (body.profile_id,))
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
cur.execute(
"""
INSERT INTO club_members (profile_id, club_id, status)
VALUES (%s, %s, 'active')
ON CONFLICT (profile_id, club_id)
DO UPDATE SET status = 'active', updated_at = NOW()
RETURNING id
""",
(body.profile_id, club_id),
)
cm_id = cur.fetchone()["id"]
cur.execute("DELETE FROM club_member_roles WHERE club_member_id = %s", (cm_id,))
for rc in roles:
cur.execute(
"""
INSERT INTO club_member_roles (club_member_id, role_code)
VALUES (%s, %s)
ON CONFLICT (club_member_id, role_code) DO NOTHING
""",
(cm_id, rc),
)
conn.commit()
return _one_member(cur, club_id, body.profile_id)
def _one_member(cur, club_id: int, profile_id: int) -> Dict[str, Any]:
cur.execute(
"""
SELECT cm.id AS membership_id, cm.profile_id, cm.status, cm.created_at, cm.updated_at,
p.email, p.name,
COALESCE(
ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL),
ARRAY[]::varchar[]
) AS roles
FROM club_members cm
INNER JOIN profiles p ON p.id = cm.profile_id
LEFT JOIN club_member_roles r ON r.club_member_id = cm.id
WHERE cm.club_id = %s AND cm.profile_id = %s
GROUP BY cm.id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name
""",
(club_id, profile_id),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Mitgliedschaft nicht gefunden")
d = r2d(row)
roles = d.get("roles") or []
if hasattr(roles, "tolist"):
roles = roles.tolist()
d["roles"] = list(roles)
return d
@router.get("/clubs/{club_id}/members/{profile_id}")
def get_club_member(
club_id: int,
profile_id: int,
session: dict = Depends(require_auth),
):
with get_db() as conn:
cur = get_cursor(conn)
if not _club_exists(cur, club_id):
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
_assert_manage(cur, session, club_id)
return _one_member(cur, club_id, profile_id)
@router.put("/clubs/{club_id}/members/{profile_id}")
def update_club_member(
club_id: int,
profile_id: int,
body: ClubMemberPatch,
session: dict = Depends(require_auth),
):
"""Rollen ersetzen und/oder Status setzen."""
with get_db() as conn:
cur = get_cursor(conn)
if not _club_exists(cur, club_id):
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
_assert_manage(cur, session, club_id)
cur.execute(
"SELECT id FROM club_members WHERE club_id = %s AND profile_id = %s",
(club_id, profile_id),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Mitgliedschaft nicht gefunden")
cm_id = row["id"]
if body.roles is None and body.status is None:
return _one_member(cur, club_id, profile_id)
if body.status is not None:
st = body.status.strip().lower()
if st not in _ALLOWED_STATUS:
raise HTTPException(status_code=400, detail="status muss active oder inactive sein")
cur.execute(
"UPDATE club_members SET status = %s, updated_at = NOW() WHERE id = %s",
(st, cm_id),
)
if body.roles is not None:
roles = _normalize_roles(body.roles)
if not roles:
raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben")
cur.execute("DELETE FROM club_member_roles WHERE club_member_id = %s", (cm_id,))
for rc in roles:
cur.execute(
"""
INSERT INTO club_member_roles (club_member_id, role_code)
VALUES (%s, %s)
ON CONFLICT (club_member_id, role_code) DO NOTHING
""",
(cm_id, rc),
)
conn.commit()
return _one_member(cur, club_id, profile_id)
@router.delete("/clubs/{club_id}/members/{profile_id}")
def delete_club_member(
club_id: int,
profile_id: int,
session: dict = Depends(require_auth),
):
"""Mitgliedschaft löschen (Rollen per CASCADE)."""
with get_db() as conn:
cur = get_cursor(conn)
if not _club_exists(cur, club_id):
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
_assert_manage(cur, session, club_id)
cur.execute(
"DELETE FROM club_members WHERE club_id = %s AND profile_id = %s RETURNING id",
(club_id, profile_id),
)
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Mitgliedschaft nicht gefunden")
conn.commit()
return {"ok": True}

View File

@ -3,13 +3,18 @@ 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.
AuthZ wie Planungs-Vorlagen: Admin sieht alle, sonst nur eigene Artefakte; Schreibzugriff mit Planungsrolle.
Lesen wie Übungen (official / private / club); Schreiben nur Ersteller oder Plattform-Admin.
"""
from typing import Any, Dict, List, Optional, Sequence
from fastapi import APIRouter, Depends, HTTPException
from auth import require_auth
from club_tenancy import (
assert_valid_governance_visibility,
exercise_visible_to_profile,
is_platform_admin,
)
from db import get_db, get_cursor, r2d
from routers.training_planning import (
@ -25,16 +30,40 @@ router = APIRouter(prefix="/api", tags=["training_framework_programs"])
_VALID_VISIBILITY = frozenset({"private", "club", "official"})
def _framework_access(cur, framework_id: int, profile_id: int, role: str) -> Dict[str, Any]:
def _fetch_framework_row(cur, framework_id: int) -> Dict[str, Any]:
cur.execute("SELECT * FROM training_framework_programs WHERE id = %s", (framework_id,))
r = cur.fetchone()
if not r:
raise HTTPException(status_code=404, detail="Trainingsrahmen nicht gefunden")
row = r2d(r)
if role in ("admin", "superadmin"):
return row
return r2d(r)
def _framework_assert_readable(
cur, row: Dict[str, Any], profile_id: int, role: Optional[str]
) -> None:
if is_platform_admin(role):
return
if not exercise_visible_to_profile(
cur,
profile_id,
row.get("visibility") or "private",
row.get("club_id"),
row.get("created_by"),
role,
):
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)
return row
@ -312,12 +341,25 @@ def list_training_framework_programs(session=Depends(require_auth)):
LEFT JOIN focus_areas fa ON fa.id = fp.focus_area_id
LEFT JOIN style_directions sd ON sd.id = fp.style_direction_id
"""
if role in ("admin", "superadmin"):
if is_platform_admin(role):
cur.execute(base_sel + " ORDER BY fp.updated_at DESC NULLS LAST, fp.title")
else:
cur.execute(
base_sel + " WHERE fp.created_by = %s ORDER BY fp.updated_at DESC NULLS LAST, fp.title",
(profile_id,),
base_sel
+ """ WHERE (
fp.visibility = 'official'
OR (fp.visibility = 'private' AND fp.created_by = %s)
OR (
fp.visibility = 'club'
AND fp.club_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM club_members cm
WHERE cm.profile_id = %s AND cm.club_id = fp.club_id AND cm.status = 'active'
)
)
)
ORDER BY fp.updated_at DESC NULLS LAST, fp.title""",
(profile_id, profile_id),
)
return [r2d(r) for r in cur.fetchall()]
@ -346,6 +388,8 @@ def create_training_framework_program(data: dict, session=Depends(require_auth))
vis = data.get("visibility") or "private"
vis = _assert_visibility(vis)
club_id = data.get("club_id")
if club_id in ("", []):
club_id = None
goals_in = data.get("goals")
slots_in = data.get("slots")
if not isinstance(goals_in, list) or not goals_in:
@ -358,6 +402,7 @@ def create_training_framework_program(data: dict, session=Depends(require_auth))
with get_db() as conn:
cur = get_cursor(conn)
assert_valid_governance_visibility(cur, profile_id, role, vis, club_id)
cur.execute(
"""
INSERT INTO training_framework_programs (
@ -399,7 +444,22 @@ def update_training_framework_program(framework_id: int, data: dict, session=Dep
with get_db() as conn:
cur = get_cursor(conn)
_framework_access(cur, framework_id, profile_id, role)
row_prev = _fetch_framework_row(cur, framework_id)
_framework_assert_writable(cur, row_prev, profile_id, role)
merged_vis = row_prev.get("visibility") or "private"
merged_club = row_prev.get("club_id")
if "visibility" in data:
v_m = _assert_visibility(data.get("visibility"))
if v_m is None:
raise HTTPException(status_code=400, detail="visibility fehlt")
merged_vis = v_m
if "club_id" in data:
merged_club = data.get("club_id")
if merged_club in ("", []):
merged_club = None
if "visibility" in data or "club_id" in data:
assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club)
header_fields = []
header_params: List[Any] = []
@ -422,14 +482,11 @@ def update_training_framework_program(framework_id: int, data: dict, session=Dep
header_params.append(data.get("planned_period_end"))
if "visibility" in data:
v = _assert_visibility(data.get("visibility"))
if v is None:
raise HTTPException(status_code=400, detail="visibility fehlt")
header_fields.append("visibility = %s")
header_params.append(v)
header_params.append(merged_vis)
if "club_id" in data:
header_fields.append("club_id = %s")
header_params.append(data.get("club_id"))
header_params.append(merged_club)
if "focus_area_id" in data:
fidv = data.get("focus_area_id")
@ -498,7 +555,8 @@ def delete_training_framework_program(framework_id: int, session=Depends(require
role = session.get("role")
with get_db() as conn:
cur = get_cursor(conn)
_framework_access(cur, framework_id, profile_id, role)
row_fw = _fetch_framework_row(cur, framework_id)
_framework_assert_writable(cur, row_fw, profile_id, role)
cur.execute(
"DELETE FROM training_framework_programs WHERE id = %s",
(framework_id,),

View File

@ -2,7 +2,7 @@
Training Planning Trainingseinheiten, strukturierter Ablauf (Sektionen + Übungen/Notizen)
und wiederverwendbare Trainingsvorlagen (Sektions-Gliederung).
Governance (Vorlagen-rechte über Vereine/offiziell) kann später nachgezogen werden.
Governance: Sichtbarkeit wie Übungen (private / club / official); Schreiben nur Ersteller oder Plattform-Admin.
"""
from typing import Any, Dict, List, Optional
@ -10,6 +10,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from db import get_db, get_cursor, r2d
from auth import require_auth
from club_tenancy import (
assert_valid_governance_visibility,
exercise_visible_to_profile,
is_platform_admin,
)
router = APIRouter(prefix="/api", tags=["training_planning"])
@ -515,23 +520,39 @@ def _instantiate_from_template(cur, unit_id: int, template_id: int):
)
def _template_access(cur, tid: int, profile_id: int, role: str) -> Dict[str, Any]:
cur.execute(
"""
SELECT *
FROM training_plan_templates
WHERE id = %s
""",
(tid,),
)
def _fetch_training_plan_template_row(cur, tid: int) -> Dict[str, Any]:
cur.execute("SELECT * FROM training_plan_templates WHERE id = %s", (tid,))
r = cur.fetchone()
if not r:
raise HTTPException(status_code=404, detail="Trainingsvorlage nicht gefunden")
row = r2d(r)
if role in ["admin", "superadmin"]:
return row
if row["created_by"] != profile_id:
return r2d(r)
def _template_assert_readable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None:
if is_platform_admin(role):
return
if not exercise_visible_to_profile(
cur,
profile_id,
row.get("visibility") or "club",
row.get("club_id"),
row.get("created_by"),
role,
):
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)
_template_assert_readable(cur, row, profile_id, role)
return row
@ -544,7 +565,7 @@ def list_training_plan_templates(session=Depends(require_auth)):
role = session.get("role")
with get_db() as conn:
cur = get_cursor(conn)
if role in ["admin", "superadmin"]:
if is_platform_admin(role):
cur.execute(
"""
SELECT t.*,
@ -561,10 +582,21 @@ def list_training_plan_templates(session=Depends(require_auth)):
(SELECT COUNT(*) FROM training_plan_template_sections s WHERE s.template_id = t.id)
AS sections_count
FROM training_plan_templates t
WHERE t.created_by = %s
WHERE (
t.visibility = 'official'
OR (t.visibility = 'private' AND t.created_by = %s)
OR (
t.visibility = 'club'
AND t.club_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM club_members cm
WHERE cm.profile_id = %s AND cm.club_id = t.club_id AND cm.status = 'active'
)
)
)
ORDER BY t.updated_at DESC NULLS LAST, t.name
""",
(profile_id,),
(profile_id, profile_id),
)
return [r2d(r) for r in cur.fetchall()]
@ -598,18 +630,23 @@ def create_training_plan_template(data: dict, session=Depends(require_auth)):
name = (data.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name ist Pflicht")
vis_raw = data.get("visibility")
visibility = (vis_raw if isinstance(vis_raw, str) else "club").strip() or "club"
club_id = data.get("club_id")
if club_id in ("", []):
club_id = None
sections_in = data.get("sections") or []
with get_db() as conn:
cur = get_cursor(conn)
assert_valid_governance_visibility(cur, profile_id, role, visibility, club_id)
cur.execute(
"""
INSERT INTO training_plan_templates (club_id, created_by, name, description)
VALUES (%s, %s, %s, %s)
INSERT INTO training_plan_templates (club_id, created_by, name, description, visibility)
VALUES (%s, %s, %s, %s, %s)
RETURNING id
""",
(club_id, profile_id, name, data.get("description")),
(club_id, profile_id, name, data.get("description"), visibility),
)
tid = cur.fetchone()["id"]
for si, sec in enumerate(sections_in):
@ -634,7 +671,21 @@ def update_training_plan_template(template_id: int, data: dict, session=Depends(
role = session.get("role")
with get_db() as conn:
cur = get_cursor(conn)
_template_access(cur, template_id, profile_id, role)
row_prev = _fetch_training_plan_template_row(cur, template_id)
_template_assert_writable(cur, row_prev, profile_id, role)
merged_vis = row_prev.get("visibility") or "club"
merged_club = row_prev.get("club_id")
if "visibility" in data:
v_in = data.get("visibility")
if not isinstance(v_in, str) or v_in not in ("private", "club", "official"):
raise HTTPException(status_code=400, detail="visibility ungültig")
merged_vis = v_in
if "club_id" in data:
merged_club = data.get("club_id")
if merged_club in ("", []):
merged_club = None
if "visibility" in data or "club_id" in data:
assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club)
fields = []
params: List[Any] = []
if "name" in data:
@ -649,7 +700,10 @@ def update_training_plan_template(template_id: int, data: dict, session=Depends(
params.append(data.get("description"))
if "club_id" in data:
fields.append("club_id = %s")
params.append(data.get("club_id"))
params.append(merged_club)
if "visibility" in data:
fields.append("visibility = %s")
params.append(merged_vis)
fields.append("updated_at = NOW()")
params.append(template_id)
cur.execute(
@ -686,7 +740,8 @@ def delete_training_plan_template(template_id: int, session=Depends(require_auth
role = session.get("role")
with get_db() as conn:
cur = get_cursor(conn)
_template_access(cur, template_id, profile_id, role)
row_del = _fetch_training_plan_template_row(cur, template_id)
_template_assert_writable(cur, row_del, profile_id, role)
cur.execute("DELETE FROM training_plan_templates WHERE id = %s", (template_id,))
conn.commit()
return {"ok": True}

View File

@ -1,20 +1,21 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.15"
APP_VERSION = "0.8.17"
BUILD_DATE = "2026-05-05"
DB_SCHEMA_VERSION = "20260505039"
MODULE_VERSIONS = {
"auth": "1.0.0",
"profiles": "1.1.0", # /profiles/me: clubs[], pin_hash ausgeblendet, active_club_id
"clubs": "0.2.0",
"clubs": "0.3.0",
"club_memberships": "1.0.0",
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
"exercises": "2.4.0", # Multi-Tenancy: club-Sichtbarkeit nur im eigenen Verein
"training_units": "0.1.0",
"training_programs": "0.1.0",
"planning": "0.6.0",
"planning": "0.7.0", # Vorlagen + Rahmenprogramme: Listen/GET wie Übungen (visibility/club); Governance-Validierung
"import_wiki": "1.0.0",
"admin": "1.0.0",
"membership": "1.0.0",
@ -23,6 +24,20 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.17",
"date": "2026-05-05",
"changes": [
"Multi-Tenancy Phase 4: training_plan_templates + training_framework_programs Listen und Lesen nach visibility/club wie Übungen; Schreiben nur Ersteller oder Plattform-Admin; club_tenancy.assert_valid_governance_visibility",
],
},
{
"version": "0.8.16",
"date": "2026-05-05",
"changes": [
"API Vereinsmitglieder: GET/POST/GET-one/PUT/DELETE /api/clubs/{id}/members (Plattform- oder Vereinsadmin); Frontend api.js Hooks",
],
},
{
"version": "0.8.15",
"date": "2026-05-05",

View File

@ -174,6 +174,34 @@ export async function deleteClub(id) {
return request(`/api/clubs/${id}`, { method: 'DELETE' })
}
/** Vereinsmitglieder (API für Admin ohne eigene UI) */
export async function listClubMembers(clubId, { includeInactive = false } = {}) {
const q = includeInactive ? '?include_inactive=true' : ''
return request(`/api/clubs/${clubId}/members${q}`)
}
export async function getClubMember(clubId, profileId) {
return request(`/api/clubs/${clubId}/members/${profileId}`)
}
export async function addClubMember(clubId, payload) {
return request(`/api/clubs/${clubId}/members`, {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function updateClubMember(clubId, profileId, payload) {
return request(`/api/clubs/${clubId}/members/${profileId}`, {
method: 'PUT',
body: JSON.stringify(payload),
})
}
export async function removeClubMember(clubId, profileId) {
return request(`/api/clubs/${clubId}/members/${profileId}`, { method: 'DELETE' })
}
export async function listDivisions(clubId) {
const query = clubId ? `?club_id=${clubId}` : ''
return request(`/api/divisions${query}`)
@ -1036,6 +1064,11 @@ export const api = {
createClub,
updateClub,
deleteClub,
listClubMembers,
getClubMember,
addClubMember,
updateClubMember,
removeClubMember,
listDivisions,
createDivision,
updateDivision,

View File

@ -1,6 +1,6 @@
// Shinkan Jinkendo Frontend Version
export const APP_VERSION = "0.8.15"
export const APP_VERSION = "0.8.17"
export const BUILD_DATE = "2026-05-05"
export const PAGE_VERSIONS = {