From 7d476268b8306b0c0743cd8291df0447a70fa361 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 5 May 2026 16:29:11 +0200 Subject: [PATCH] feat: implement multi-tenancy governance and enhance club member management - 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. --- .../MULTI_TENANCY_RBAC_ARCHITECTURE.md | 6 +- backend/club_tenancy.py | 24 ++ backend/main.py | 3 +- backend/routers/club_memberships.py | 268 ++++++++++++++++++ .../routers/training_framework_programs.py | 88 +++++- backend/routers/training_planning.py | 101 +++++-- backend/version.py | 21 +- frontend/src/utils/api.js | 33 +++ frontend/src/version.js | 2 +- 9 files changed, 501 insertions(+), 45 deletions(-) create mode 100644 backend/routers/club_memberships.py diff --git a/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md b/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md index 244ef6e..1f50e47 100644 --- a/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md +++ b/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md @@ -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 diff --git a/backend/club_tenancy.py b/backend/club_tenancy.py index 21f954b..ff731f5 100644 --- a/backend/club_tenancy.py +++ b/backend/club_tenancy.py @@ -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, diff --git a/backend/main.py b/backend/main.py index a03c0b3..27fffbb 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/routers/club_memberships.py b/backend/routers/club_memberships.py new file mode 100644 index 0000000..7b2e22a --- /dev/null +++ b/backend/routers/club_memberships.py @@ -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} diff --git a/backend/routers/training_framework_programs.py b/backend/routers/training_framework_programs.py index 459f7f1..954d693 100644 --- a/backend/routers/training_framework_programs.py +++ b/backend/routers/training_framework_programs.py @@ -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,), diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index 2cd7f03..e5bd24e 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -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} diff --git a/backend/version.py b/backend/version.py index 14e0108..4a8a9d1 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 20c76f3..279b8b6 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -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, diff --git a/frontend/src/version.js b/frontend/src/version.js index a3383e9..f3a1220 100644 --- a/frontend/src/version.js +++ b/frontend/src/version.js @@ -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 = {