Mandantenfähigkeit V1 #10

Merged
Lars merged 17 commits from develop into main 2026-05-05 22:34:25 +02:00
9 changed files with 501 additions and 45 deletions
Showing only changes of commit 7d476268b8 - Show all commits

View File

@ -172,14 +172,16 @@ Ziel: **vereinszentrierte** Vertrags- und Limitlogik, analog zur bestehenden Tie
### Phase 3 Effektive Berechtigungen (RBAC) ### 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`: - 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. - Router schrittweise umbinden: Sparten/Gruppen CRUD nach Rolle `club_admin` im Kontext; Systemadmin unverändert Vollzugriff.
### Phase 4 Sichtbarkeit & Leaks schließen ### Phase 4 Sichtbarkeit & Leaks schließen
- **Übungen:** `club`-Sichtbarkeit nur bei Übereinstimmung `exercise.club_id` mit Mitgliedschaft (und später `division`). - **Ü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. - Tests: zwei Vereine, zwei Nutzer, keine Kreuzzugriffe.
### Phase 5 Mitgliedschaft / Limits ### Phase 5 Mitgliedschaft / Limits

View File

@ -96,6 +96,30 @@ def memberships_with_roles(cur, profile_id: int) -> List[Dict[str, Any]]:
return out 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( def exercise_visible_to_profile(
cur, cur,
profile_id: int, profile_id: int,

View File

@ -154,13 +154,14 @@ def read_root():
} }
# Register routers # 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(auth.router)
app.include_router(profiles.router) app.include_router(profiles.router)
app.include_router(exercises.router) app.include_router(exercises.router)
app.include_router(exercise_progression_graphs.router) app.include_router(exercise_progression_graphs.router)
app.include_router(clubs.router) app.include_router(clubs.router)
app.include_router(club_memberships.router)
app.include_router(skills.router) app.include_router(skills.router)
app.include_router(training_planning.router) app.include_router(training_planning.router)
app.include_router(training_framework_programs.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), 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.
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 typing import Any, Dict, List, Optional, Sequence
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from auth import require_auth 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 db import get_db, get_cursor, r2d
from routers.training_planning import ( from routers.training_planning import (
@ -25,16 +30,40 @@ router = APIRouter(prefix="/api", tags=["training_framework_programs"])
_VALID_VISIBILITY = frozenset({"private", "club", "official"}) _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,)) cur.execute("SELECT * FROM training_framework_programs WHERE id = %s", (framework_id,))
r = cur.fetchone() r = cur.fetchone()
if not r: if not r:
raise HTTPException(status_code=404, detail="Trainingsrahmen nicht gefunden") raise HTTPException(status_code=404, detail="Trainingsrahmen nicht gefunden")
row = r2d(r) return r2d(r)
if role in ("admin", "superadmin"):
return row
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: if row.get("created_by") != profile_id:
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_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 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 focus_areas fa ON fa.id = fp.focus_area_id
LEFT JOIN style_directions sd ON sd.id = fp.style_direction_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") cur.execute(base_sel + " ORDER BY fp.updated_at DESC NULLS LAST, fp.title")
else: else:
cur.execute( cur.execute(
base_sel + " WHERE fp.created_by = %s ORDER BY fp.updated_at DESC NULLS LAST, fp.title", base_sel
(profile_id,), + """ 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()] 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 = data.get("visibility") or "private"
vis = _assert_visibility(vis) vis = _assert_visibility(vis)
club_id = data.get("club_id") club_id = data.get("club_id")
if club_id in ("", []):
club_id = None
goals_in = data.get("goals") goals_in = data.get("goals")
slots_in = data.get("slots") slots_in = data.get("slots")
if not isinstance(goals_in, list) or not goals_in: 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: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
assert_valid_governance_visibility(cur, profile_id, role, vis, club_id)
cur.execute( cur.execute(
""" """
INSERT INTO training_framework_programs ( 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: with get_db() as conn:
cur = get_cursor(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_fields = []
header_params: List[Any] = [] 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")) header_params.append(data.get("planned_period_end"))
if "visibility" in data: 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_fields.append("visibility = %s")
header_params.append(v) header_params.append(merged_vis)
if "club_id" in data: if "club_id" in data:
header_fields.append("club_id = %s") header_fields.append("club_id = %s")
header_params.append(data.get("club_id")) header_params.append(merged_club)
if "focus_area_id" in data: if "focus_area_id" in data:
fidv = data.get("focus_area_id") 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") role = session.get("role")
with get_db() as conn: with get_db() as conn:
cur = get_cursor(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( cur.execute(
"DELETE FROM training_framework_programs WHERE id = %s", "DELETE FROM training_framework_programs WHERE id = %s",
(framework_id,), (framework_id,),

View File

@ -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 (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 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 db import get_db, get_cursor, r2d
from auth import require_auth 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"]) 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]: def _fetch_training_plan_template_row(cur, tid: int) -> Dict[str, Any]:
cur.execute( cur.execute("SELECT * FROM training_plan_templates WHERE id = %s", (tid,))
"""
SELECT *
FROM training_plan_templates
WHERE id = %s
""",
(tid,),
)
r = cur.fetchone() r = cur.fetchone()
if not r: if not r:
raise HTTPException(status_code=404, detail="Trainingsvorlage nicht gefunden") raise HTTPException(status_code=404, detail="Trainingsvorlage nicht gefunden")
row = r2d(r) return r2d(r)
if role in ["admin", "superadmin"]:
return row
if row["created_by"] != profile_id: 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") 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 return row
@ -544,7 +565,7 @@ def list_training_plan_templates(session=Depends(require_auth)):
role = session.get("role") role = session.get("role")
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
if role in ["admin", "superadmin"]: if is_platform_admin(role):
cur.execute( cur.execute(
""" """
SELECT t.*, 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) (SELECT COUNT(*) FROM training_plan_template_sections s WHERE s.template_id = t.id)
AS sections_count AS sections_count
FROM training_plan_templates t 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 ORDER BY t.updated_at DESC NULLS LAST, t.name
""", """,
(profile_id,), (profile_id, profile_id),
) )
return [r2d(r) for r in cur.fetchall()] 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() name = (data.get("name") or "").strip()
if not name: if not name:
raise HTTPException(status_code=400, detail="name ist Pflicht") 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") club_id = data.get("club_id")
if club_id in ("", []):
club_id = None
sections_in = data.get("sections") or [] sections_in = data.get("sections") or []
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
assert_valid_governance_visibility(cur, profile_id, role, visibility, club_id)
cur.execute( cur.execute(
""" """
INSERT INTO training_plan_templates (club_id, created_by, name, description) INSERT INTO training_plan_templates (club_id, created_by, name, description, visibility)
VALUES (%s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s)
RETURNING id RETURNING id
""", """,
(club_id, profile_id, name, data.get("description")), (club_id, profile_id, name, data.get("description"), visibility),
) )
tid = cur.fetchone()["id"] tid = cur.fetchone()["id"]
for si, sec in enumerate(sections_in): 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") role = session.get("role")
with get_db() as conn: with get_db() as conn:
cur = get_cursor(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 = [] fields = []
params: List[Any] = [] params: List[Any] = []
if "name" in data: 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")) params.append(data.get("description"))
if "club_id" in data: if "club_id" in data:
fields.append("club_id = %s") 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()") fields.append("updated_at = NOW()")
params.append(template_id) params.append(template_id)
cur.execute( cur.execute(
@ -686,7 +740,8 @@ def delete_training_plan_template(template_id: int, session=Depends(require_auth
role = session.get("role") role = session.get("role")
with get_db() as conn: with get_db() as conn:
cur = get_cursor(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,)) cur.execute("DELETE FROM training_plan_templates WHERE id = %s", (template_id,))
conn.commit() conn.commit()
return {"ok": True} return {"ok": True}

View File

@ -1,20 +1,21 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.15" APP_VERSION = "0.8.17"
BUILD_DATE = "2026-05-05" BUILD_DATE = "2026-05-05"
DB_SCHEMA_VERSION = "20260505039" DB_SCHEMA_VERSION = "20260505039"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"auth": "1.0.0", "auth": "1.0.0",
"profiles": "1.1.0", # /profiles/me: clubs[], pin_hash ausgeblendet, active_club_id "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", "groups": "0.1.0",
"skills": "0.1.0", "skills": "0.1.0",
"methods": "0.1.0", "methods": "0.1.0",
"exercises": "2.4.0", # Multi-Tenancy: club-Sichtbarkeit nur im eigenen Verein "exercises": "2.4.0", # Multi-Tenancy: club-Sichtbarkeit nur im eigenen Verein
"training_units": "0.1.0", "training_units": "0.1.0",
"training_programs": "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", "import_wiki": "1.0.0",
"admin": "1.0.0", "admin": "1.0.0",
"membership": "1.0.0", "membership": "1.0.0",
@ -23,6 +24,20 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ 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", "version": "0.8.15",
"date": "2026-05-05", "date": "2026-05-05",

View File

@ -174,6 +174,34 @@ export async function deleteClub(id) {
return request(`/api/clubs/${id}`, { method: 'DELETE' }) 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) { export async function listDivisions(clubId) {
const query = clubId ? `?club_id=${clubId}` : '' const query = clubId ? `?club_id=${clubId}` : ''
return request(`/api/divisions${query}`) return request(`/api/divisions${query}`)
@ -1036,6 +1064,11 @@ export const api = {
createClub, createClub,
updateClub, updateClub,
deleteClub, deleteClub,
listClubMembers,
getClubMember,
addClubMember,
updateClubMember,
removeClubMember,
listDivisions, listDivisions,
createDivision, createDivision,
updateDivision, updateDivision,

View File

@ -1,6 +1,6 @@
// Shinkan Jinkendo Frontend Version // 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 BUILD_DATE = "2026-05-05"
export const PAGE_VERSIONS = { export const PAGE_VERSIONS = {