feat: integrate tenant context across club-related APIs
Some checks failed
Deploy Development / deploy (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 34s

- Refactored club join requests, memberships, and clubs routers to utilize TenantContext for authentication and authorization, enhancing security and consistency.
- Updated session handling to replace direct session dictionary access with TenantContext, improving code clarity and maintainability.
- Ensured proper role and profile ID retrieval from TenantContext in various endpoints, streamlining access control for club management functionalities.
This commit is contained in:
Lars 2026-05-05 22:05:10 +02:00
parent 870a7611dc
commit 5aee9c52fc
9 changed files with 294 additions and 182 deletions

View File

@ -5,20 +5,20 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
| Router / Bereich | Beispiel-Endpunkt | tenant-relevant | `Depends(get_tenant_context)` / Kontext | Governance geprüft (Liste+Detail) | Notizen | | Router / Bereich | Beispiel-Endpunkt | tenant-relevant | `Depends(get_tenant_context)` / Kontext | Governance geprüft (Liste+Detail) | Notizen |
|------------------|-------------------|-----------------|----------------------------------------|-------------------------------------|---------| |------------------|-------------------|-----------------|----------------------------------------|-------------------------------------|---------|
| profiles | `GET /api/profiles/me` | ja | `resolve_tenant_context` inline (`invalid_header_policy=ignore`) | teils | + `effective_club_id`; veralteter Header bricht Refresh nicht | | profiles | `GET /api/profiles/me` | ja | `resolve_tenant_context` inline (`invalid_header_policy=ignore`) | teils | + `effective_club_id`; veralteter Header bricht Refresh nicht |
| profiles | `PUT /api/profiles/{id}` | ja | — | `active_club_id` Mitgliedschaft | TenantContext später auch hier | | profiles | `PUT /api/profiles/{id}`, `PUT /api/profile` | ja | `get_tenant_context` | `active_club_id` Mitgliedschaft | Validiert `X-Active-Club-Id` konsistent zu Mitgliedschaft |
| clubs | `GET /api/clubs` | ja | — | Mitgliedschaft vs Admin | Liste gefiltert Nicht-Admins | | clubs | geschützte `/api/clubs*`, `/divisions*`, `/groups*` | ja | `get_tenant_context` | Mitgliedschaft / `can_manage_*` | Öffentlich: `/clubs/public-directory` ohne Auth |
| clubs | CRUD Organisation | ja | — | `can_manage_club_org` / member | schrittweise auf TenantContext | | club_memberships | `/clubs/{id}/members*` | ja | `get_tenant_context` | ja | |
| club_memberships | `/clubs/{id}/members*` | ja | geplant | ja | | | club_join_requests | `/me/club-join-requests`, `/clubs/{id}/join-requests*` | ja | `get_tenant_context` | ja | |
| club_join_requests | `/clubs/{id}/join-requests*` | ja | geplant | ja | | | exercises | alle geschützten `/api/exercises*` | ja | `get_tenant_context` | ja | |
| exercises | alle geschützten `/api/exercises*` (Liste war schon) | ja | `get_tenant_context` | Detail visibility; Mutations Owner/Admin | PATCH ohne zusätzliche assert_valid bei Visibility — nächster Schritt | | exercise_progression_graphs | `/api/exercise-progression-graphs*` | ja | `get_tenant_context` | Liste wie Bibliothek; Schreiben Ersteller/Plattform-Admin | Kanten: Lesen wenn Graph lesbar |
| training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage Default club_id | | training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage Default club_id |
| training_framework_programs | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Liste + POST Default club_id | | training_framework_programs | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Liste + POST Default club_id |
| admin_users | `GET /api/admin/users` | Plattform | optional | Admin-Rolle | | | admin_users | `GET /api/admin/users` | Plattform | optional | Admin-Rolle | |
| Sonstige | skills, methods, catalogs | zu klären | — | oft global | Zeilen ergänzen | | Sonstige | skills, methods, catalogs | zu klären | — | oft global | Zeilen ergänzen |
**Legende:** „geplant“ = beim nächsten Umbau dieser Router `get_tenant_context` verwenden bzw. zentrale Governance-Helfer. **Legende:** „zu klären“ = keine Vereinsdaten oder globales Lesen; bei neuem Bezug zu `club_id`/`visibility` nachziehen.
Letzte Änderung: 2026-05-05 — u. a. Übungen komplett TenantContext; Stufe B/C partiell (Bibliothekslisten + Planung); `GET /training-units` ohne automatischen club_id-Filter (Kompatibilität). Letzte Änderung: 2026-05-05 — Vereins-/Mitgliedschafts-/Antrags-Router und Profil-PUT auf `get_tenant_context`; Progressionsgraphen Sichtbarkeit wie Bibliothek.
--- ---

View File

@ -78,8 +78,8 @@ def get_me(session: dict=Depends(require_auth)):
"""Get current user info.""" """Get current user info."""
pid = session['profile_id'] pid = session['profile_id']
# Import here to avoid circular dependency # Import here to avoid circular dependency
from routers.profiles import get_profile from routers.profiles import profile_document
return get_profile(pid, session) return profile_document(pid)
@router.get("/status") @router.get("/status")

View File

@ -6,9 +6,9 @@ from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from auth import require_auth
from club_tenancy import can_manage_club_org from club_tenancy import can_manage_club_org
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from tenant_context import TenantContext, get_tenant_context
router = APIRouter(prefix="/api", tags=["club_join_requests"]) router = APIRouter(prefix="/api", tags=["club_join_requests"])
@ -35,9 +35,9 @@ def _club_active(cur, club_id: int) -> bool:
return cur.fetchone() is not None return cur.fetchone() is not None
def _assert_manage_club(cur, session: dict, club_id: int) -> None: def _assert_manage_club(cur, tenant: TenantContext, club_id: int) -> None:
pid = session["profile_id"] pid = tenant.profile_id
role = session.get("role") role = tenant.global_role
if not can_manage_club_org(cur, pid, club_id, role): if not can_manage_club_org(cur, pid, club_id, role):
raise HTTPException(status_code=403, detail="Keine Berechtigung für Mitglieder-Verwaltung in diesem Verein") raise HTTPException(status_code=403, detail="Keine Berechtigung für Mitglieder-Verwaltung in diesem Verein")
@ -107,8 +107,8 @@ def _response_one(cur, req_id: int, viewer_profile_id: int) -> Dict[str, Any]:
@router.get("/me/club-join-requests") @router.get("/me/club-join-requests")
def get_my_join_requests(session: dict = Depends(require_auth)): def get_my_join_requests(tenant: TenantContext = Depends(get_tenant_context)):
pid = session["profile_id"] pid = tenant.profile_id
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute( cur.execute(
@ -126,9 +126,9 @@ def get_my_join_requests(session: dict = Depends(require_auth)):
@router.post("/me/club-join-requests", status_code=201) @router.post("/me/club-join-requests", status_code=201)
def create_my_join_request(body: JoinRequestCreate, session: dict = Depends(require_auth)): def create_my_join_request(body: JoinRequestCreate, tenant: TenantContext = Depends(get_tenant_context)):
"""Antrag stellen (nicht möglich wenn bereits aktives Mitglied).""" """Antrag stellen (nicht möglich wenn bereits aktives Mitglied)."""
pid = session["profile_id"] pid = tenant.profile_id
msg = (body.message or "").strip() or None msg = (body.message or "").strip() or None
cid = body.club_id cid = body.club_id
@ -168,8 +168,8 @@ def create_my_join_request(body: JoinRequestCreate, session: dict = Depends(requ
@router.delete("/me/club-join-requests/{request_id}") @router.delete("/me/club-join-requests/{request_id}")
def withdraw_my_join_request(request_id: int, session: dict = Depends(require_auth)): def withdraw_my_join_request(request_id: int, tenant: TenantContext = Depends(get_tenant_context)):
pid = session["profile_id"] pid = tenant.profile_id
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute( cur.execute(
@ -188,11 +188,11 @@ def withdraw_my_join_request(request_id: int, session: dict = Depends(require_au
@router.get("/clubs/{club_id}/join-requests") @router.get("/clubs/{club_id}/join-requests")
def list_club_join_requests(club_id: int, session: dict = Depends(require_auth)): def list_club_join_requests(club_id: int, tenant: TenantContext = Depends(get_tenant_context)):
"""Offene Anträge für einen Verein (Vereins-/Plattform-Admin).""" """Offene Anträge für einen Verein (Vereins-/Plattform-Admin)."""
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
_assert_manage_club(cur, session, club_id) _assert_manage_club(cur, tenant, club_id)
cur.execute( cur.execute(
""" """
SELECT r.*, p.email AS applicant_email, p.name AS applicant_name SELECT r.*, p.email AS applicant_email, p.name AS applicant_name
@ -211,14 +211,14 @@ def accept_club_join_request(
club_id: int, club_id: int,
request_id: int, request_id: int,
body: JoinRequestAccept, body: JoinRequestAccept,
session: dict = Depends(require_auth), tenant: TenantContext = Depends(get_tenant_context),
): ):
admin_pid = session["profile_id"] admin_pid = tenant.profile_id
roles = _normalize_roles(body.roles) roles = _normalize_roles(body.roles)
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
_assert_manage_club(cur, session, club_id) _assert_manage_club(cur, tenant, club_id)
cur.execute( cur.execute(
""" """
@ -257,11 +257,11 @@ def accept_club_join_request(
@router.post("/clubs/{club_id}/join-requests/{request_id}/reject") @router.post("/clubs/{club_id}/join-requests/{request_id}/reject")
def reject_club_join_request(club_id: int, request_id: int, session: dict = Depends(require_auth)): def reject_club_join_request(club_id: int, request_id: int, tenant: TenantContext = Depends(get_tenant_context)):
admin_pid = session["profile_id"] admin_pid = tenant.profile_id
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
_assert_manage_club(cur, session, club_id) _assert_manage_club(cur, tenant, club_id)
cur.execute( cur.execute(
""" """

View File

@ -8,9 +8,9 @@ from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from auth import require_auth
from club_tenancy import can_manage_club_org from club_tenancy import can_manage_club_org
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from tenant_context import TenantContext, get_tenant_context
router = APIRouter(prefix="/api", tags=["club_memberships"]) router = APIRouter(prefix="/api", tags=["club_memberships"])
@ -38,9 +38,9 @@ def _normalize_roles(raw: List[str]) -> List[str]:
return out return out
def _assert_manage(cur, session: dict, club_id: int) -> None: def _assert_manage(cur, tenant: TenantContext, club_id: int) -> None:
pid = session["profile_id"] pid = tenant.profile_id
role = session.get("role") role = tenant.global_role
if not can_manage_club_org(cur, pid, club_id, 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") raise HTTPException(status_code=403, detail="Keine Berechtigung zur Mitglieder-Verwaltung in diesem Verein")
@ -64,14 +64,14 @@ class ClubMemberPatch(BaseModel):
def list_club_members( def list_club_members(
club_id: int, club_id: int,
include_inactive: bool = Query(False), include_inactive: bool = Query(False),
session: dict = Depends(require_auth), tenant: TenantContext = Depends(get_tenant_context),
): ):
"""Alle Mitglieder eines Vereins mit Rollen (nur Vereins-/Plattform-Admin).""" """Alle Mitglieder eines Vereins mit Rollen (nur Vereins-/Plattform-Admin)."""
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
if not _club_exists(cur, club_id): if not _club_exists(cur, club_id):
raise HTTPException(status_code=404, detail="Verein nicht gefunden") raise HTTPException(status_code=404, detail="Verein nicht gefunden")
_assert_manage(cur, session, club_id) _assert_manage(cur, tenant, club_id)
status_clause = "" if include_inactive else "AND cm.status = 'active'" status_clause = "" if include_inactive else "AND cm.status = 'active'"
cur.execute( cur.execute(
@ -106,7 +106,7 @@ def list_club_members(
def upsert_club_member( def upsert_club_member(
club_id: int, club_id: int,
body: ClubMemberUpsert, body: ClubMemberUpsert,
session: dict = Depends(require_auth), tenant: TenantContext = Depends(get_tenant_context),
): ):
"""Mitglied anlegen oder aktivieren; Rollen werden vollständig ersetzt.""" """Mitglied anlegen oder aktivieren; Rollen werden vollständig ersetzt."""
roles = _normalize_roles(body.roles) roles = _normalize_roles(body.roles)
@ -117,7 +117,7 @@ def upsert_club_member(
cur = get_cursor(conn) cur = get_cursor(conn)
if not _club_exists(cur, club_id): if not _club_exists(cur, club_id):
raise HTTPException(status_code=404, detail="Verein nicht gefunden") raise HTTPException(status_code=404, detail="Verein nicht gefunden")
_assert_manage(cur, session, club_id) _assert_manage(cur, tenant, club_id)
cur.execute("SELECT id FROM profiles WHERE id = %s", (body.profile_id,)) cur.execute("SELECT id FROM profiles WHERE id = %s", (body.profile_id,))
if not cur.fetchone(): if not cur.fetchone():
@ -181,13 +181,13 @@ def _one_member(cur, club_id: int, profile_id: int) -> Dict[str, Any]:
def get_club_member( def get_club_member(
club_id: int, club_id: int,
profile_id: int, profile_id: int,
session: dict = Depends(require_auth), tenant: TenantContext = Depends(get_tenant_context),
): ):
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
if not _club_exists(cur, club_id): if not _club_exists(cur, club_id):
raise HTTPException(status_code=404, detail="Verein nicht gefunden") raise HTTPException(status_code=404, detail="Verein nicht gefunden")
_assert_manage(cur, session, club_id) _assert_manage(cur, tenant, club_id)
return _one_member(cur, club_id, profile_id) return _one_member(cur, club_id, profile_id)
@ -196,14 +196,14 @@ def update_club_member(
club_id: int, club_id: int,
profile_id: int, profile_id: int,
body: ClubMemberPatch, body: ClubMemberPatch,
session: dict = Depends(require_auth), tenant: TenantContext = Depends(get_tenant_context),
): ):
"""Rollen ersetzen und/oder Status setzen.""" """Rollen ersetzen und/oder Status setzen."""
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
if not _club_exists(cur, club_id): if not _club_exists(cur, club_id):
raise HTTPException(status_code=404, detail="Verein nicht gefunden") raise HTTPException(status_code=404, detail="Verein nicht gefunden")
_assert_manage(cur, session, club_id) _assert_manage(cur, tenant, club_id)
cur.execute( cur.execute(
"SELECT id FROM club_members WHERE club_id = %s AND profile_id = %s", "SELECT id FROM club_members WHERE club_id = %s AND profile_id = %s",
@ -249,14 +249,14 @@ def update_club_member(
def delete_club_member( def delete_club_member(
club_id: int, club_id: int,
profile_id: int, profile_id: int,
session: dict = Depends(require_auth), tenant: TenantContext = Depends(get_tenant_context),
): ):
"""Mitgliedschaft löschen (Rollen per CASCADE).""" """Mitgliedschaft löschen (Rollen per CASCADE)."""
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
if not _club_exists(cur, club_id): if not _club_exists(cur, club_id):
raise HTTPException(status_code=404, detail="Verein nicht gefunden") raise HTTPException(status_code=404, detail="Verein nicht gefunden")
_assert_manage(cur, session, club_id) _assert_manage(cur, tenant, club_id)
cur.execute( cur.execute(
"DELETE FROM club_members WHERE club_id = %s AND profile_id = %s RETURNING id", "DELETE FROM club_members WHERE club_id = %s AND profile_id = %s RETURNING id",

View File

@ -7,7 +7,7 @@ from typing import Any, List, Optional
from fastapi import APIRouter, HTTPException, Depends, Query from fastapi import APIRouter, HTTPException, Depends, Query
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth from tenant_context import TenantContext, get_tenant_context
from club_tenancy import ( from club_tenancy import (
assert_club_member, assert_club_member,
can_manage_club_org, can_manage_club_org,
@ -23,13 +23,13 @@ router = APIRouter(prefix="/api", tags=["clubs"])
@router.get("/clubs") @router.get("/clubs")
def list_clubs( def list_clubs(
status: Optional[str] = Query(default=None), status: Optional[str] = Query(default=None),
session=Depends(require_auth), tenant: TenantContext = Depends(get_tenant_context),
): ):
""" """
Vereine: für normale Nutzer nur Mitgliedschaft-Vereine; Plattform-Admins sehen alle. Vereine: für normale Nutzer nur Mitgliedschaft-Vereine; Plattform-Admins sehen alle.
""" """
role = session.get("role") role = tenant.global_role
profile_id = session["profile_id"] profile_id = tenant.profile_id
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
query = "SELECT * FROM clubs" query = "SELECT * FROM clubs"
@ -76,9 +76,9 @@ def public_club_directory():
# ── Aktive Mitglieder für Trainer-/Co-Trainer-Auswahl (jeder Vereinsmitglied) ── # ── Aktive Mitglieder für Trainer-/Co-Trainer-Auswahl (jeder Vereinsmitglied) ──
@router.get("/clubs/{club_id}/members/directory") @router.get("/clubs/{club_id}/members/directory")
def club_members_directory(club_id: int, session=Depends(require_auth)): def club_members_directory(club_id: int, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = session["profile_id"] profile_id = tenant.profile_id
role = session.get("role") role = tenant.global_role
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
if not is_platform_admin(role): if not is_platform_admin(role):
@ -101,10 +101,10 @@ def club_members_directory(club_id: int, session=Depends(require_auth)):
# ── Get Club ────────────────────────────────────────────────────────── # ── Get Club ──────────────────────────────────────────────────────────
@router.get("/clubs/{club_id}") @router.get("/clubs/{club_id}")
def get_club(club_id: int, session=Depends(require_auth)): def get_club(club_id: int, tenant: TenantContext = Depends(get_tenant_context)):
"""Get club by ID with divisions and groups nur Mitglied oder Plattform-Admin.""" """Get club by ID with divisions and groups nur Mitglied oder Plattform-Admin."""
profile_id = session["profile_id"] profile_id = tenant.profile_id
role = session.get("role") role = tenant.global_role
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
if not is_platform_admin(role): if not is_platform_admin(role):
@ -149,9 +149,9 @@ def get_club(club_id: int, session=Depends(require_auth)):
# ── Create Club ─────────────────────────────────────────────────────── # ── Create Club ───────────────────────────────────────────────────────
@router.post("/clubs") @router.post("/clubs")
def create_club(data: dict, session=Depends(require_auth)): def create_club(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
"""Neuen Verein anlegen nur Plattform-Admin; Pflicht: primary_admin_profile_id (Hauptverwalter:in).""" """Neuen Verein anlegen nur Plattform-Admin; Pflicht: primary_admin_profile_id (Hauptverwalter:in)."""
role = session.get("role") role = tenant.global_role
if not is_platform_admin(role): if not is_platform_admin(role):
raise HTTPException(403, "Nur Plattform-Administratoren dürfen neue Vereine anlegen") raise HTTPException(403, "Nur Plattform-Administratoren dürfen neue Vereine anlegen")
@ -213,15 +213,15 @@ def create_club(data: dict, session=Depends(require_auth)):
conn.commit() conn.commit()
return get_club(club_id, session) return get_club(club_id, tenant)
# ── Update Club ─────────────────────────────────────────────────────── # ── Update Club ───────────────────────────────────────────────────────
@router.put("/clubs/{club_id}") @router.put("/clubs/{club_id}")
def update_club(club_id: int, data: dict, session=Depends(require_auth)): def update_club(club_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)):
"""Verein bearbeiten Plattform-Admin oder Vereinsadmin.""" """Verein bearbeiten Plattform-Admin oder Vereinsadmin."""
profile_id = session["profile_id"] profile_id = tenant.profile_id
role = session.get("role") role = tenant.global_role
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
@ -263,14 +263,14 @@ def update_club(club_id: int, data: dict, session=Depends(require_auth)):
conn.commit() conn.commit()
return get_club(club_id, session) return get_club(club_id, tenant)
# ── Delete Club ─────────────────────────────────────────────────────── # ── Delete Club ───────────────────────────────────────────────────────
@router.delete("/clubs/{club_id}") @router.delete("/clubs/{club_id}")
def delete_club(club_id: int, session=Depends(require_auth)): def delete_club(club_id: int, tenant: TenantContext = Depends(get_tenant_context)):
"""Delete club (superadmin only).""" """Delete club (superadmin only)."""
role = session.get('role') role = tenant.global_role
if role != 'superadmin': if role != 'superadmin':
raise HTTPException(403, "Nur Superadmins dürfen Vereine löschen") raise HTTPException(403, "Nur Superadmins dürfen Vereine löschen")
@ -293,11 +293,11 @@ def delete_club(club_id: int, session=Depends(require_auth)):
@router.get("/divisions") @router.get("/divisions")
def list_divisions( def list_divisions(
club_id: Optional[int] = Query(default=None), club_id: Optional[int] = Query(default=None),
session=Depends(require_auth), tenant: TenantContext = Depends(get_tenant_context),
): ):
"""Sparten ohne Admin-Rechte nur in eigenen Vereinen sichtbar.""" """Sparten ohne Admin-Rechte nur in eigenen Vereinen sichtbar."""
profile_id = session["profile_id"] profile_id = tenant.profile_id
role = session.get("role") role = tenant.global_role
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
@ -338,10 +338,10 @@ def list_divisions(
# ── Create Division ─────────────────────────────────────────────────── # ── Create Division ───────────────────────────────────────────────────
@router.post("/divisions") @router.post("/divisions")
def create_division(data: dict, session=Depends(require_auth)): def create_division(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
"""Create new division Vereinsadmin / Plattform-Admin.""" """Create new division Vereinsadmin / Plattform-Admin."""
profile_id = session["profile_id"] profile_id = tenant.profile_id
role = session.get("role") role = tenant.global_role
club_id = data.get("club_id") club_id = data.get("club_id")
name = data.get("name") name = data.get("name")
@ -392,10 +392,10 @@ def create_division(data: dict, session=Depends(require_auth)):
# ── Update Division ─────────────────────────────────────────────────── # ── Update Division ───────────────────────────────────────────────────
@router.put("/divisions/{division_id}") @router.put("/divisions/{division_id}")
def update_division(division_id: int, data: dict, session=Depends(require_auth)): def update_division(division_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)):
"""Update division Vereinsadmin / Plattform-Admin.""" """Update division Vereinsadmin / Plattform-Admin."""
profile_id = session["profile_id"] profile_id = tenant.profile_id
role = session.get("role") role = tenant.global_role
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
@ -439,10 +439,10 @@ def update_division(division_id: int, data: dict, session=Depends(require_auth))
# ── Delete Division ─────────────────────────────────────────────────── # ── Delete Division ───────────────────────────────────────────────────
@router.delete("/divisions/{division_id}") @router.delete("/divisions/{division_id}")
def delete_division(division_id: int, session=Depends(require_auth)): def delete_division(division_id: int, tenant: TenantContext = Depends(get_tenant_context)):
"""Delete division Vereinsadmin / Plattform-Admin.""" """Delete division Vereinsadmin / Plattform-Admin."""
profile_id = session["profile_id"] profile_id = tenant.profile_id
role = session.get("role") role = tenant.global_role
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
@ -467,13 +467,13 @@ def list_training_groups(
club_id: Optional[int] = Query(default=None), club_id: Optional[int] = Query(default=None),
division_id: Optional[int] = Query(default=None), division_id: Optional[int] = Query(default=None),
status: Optional[str] = Query(default=None), status: Optional[str] = Query(default=None),
session=Depends(require_auth), tenant: TenantContext = Depends(get_tenant_context),
): ):
""" """
Trainingsgruppen ohne Plattform-Admin nur in eigenen Vereinen. Trainingsgruppen ohne Plattform-Admin nur in eigenen Vereinen.
""" """
profile_id = session["profile_id"] profile_id = tenant.profile_id
role = session.get("role") role = tenant.global_role
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
@ -526,10 +526,10 @@ def list_training_groups(
# ── Get Training Group ──────────────────────────────────────────────── # ── Get Training Group ────────────────────────────────────────────────
@router.get("/groups/{group_id}") @router.get("/groups/{group_id}")
def get_training_group(group_id: int, session=Depends(require_auth)): def get_training_group(group_id: int, tenant: TenantContext = Depends(get_tenant_context)):
"""Trainingsgruppe nur mit Vereinszugriff.""" """Trainingsgruppe nur mit Vereinszugriff."""
profile_id = session["profile_id"] profile_id = tenant.profile_id
role = session.get("role") role = tenant.global_role
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
@ -562,11 +562,11 @@ def get_training_group(group_id: int, session=Depends(require_auth)):
# ── Create Training Group ───────────────────────────────────────────── # ── Create Training Group ─────────────────────────────────────────────
@router.post("/groups") @router.post("/groups")
def create_training_group(data: dict, session=Depends(require_auth)): def create_training_group(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
"""Trainingsgruppe anlegen Mitglied mit Planungs-/Admin-Rolle im Verein.""" """Trainingsgruppe anlegen Mitglied mit Planungs-/Admin-Rolle im Verein."""
profile_id = session["profile_id"] profile_id = tenant.profile_id
role = session.get("role") role = tenant.global_role
if role not in ["admin", "superadmin", "trainer", "user"]: if role not in ("admin", "superadmin", "trainer", "user"):
raise HTTPException(403, "Keine Berechtigung, Trainingsgruppen anzulegen") raise HTTPException(403, "Keine Berechtigung, Trainingsgruppen anzulegen")
club_id = data.get("club_id") club_id = data.get("club_id")
@ -623,15 +623,15 @@ def create_training_group(data: dict, session=Depends(require_auth)):
group_id = cur.fetchone()["id"] group_id = cur.fetchone()["id"]
conn.commit() conn.commit()
return get_training_group(group_id, session) return get_training_group(group_id, tenant)
# ── Update Training Group ───────────────────────────────────────────── # ── Update Training Group ─────────────────────────────────────────────
@router.put("/groups/{group_id}") @router.put("/groups/{group_id}")
def update_training_group(group_id: int, data: dict, session=Depends(require_auth)): def update_training_group(group_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)):
"""Update training group Vereinsadmin, Plattform-Admin oder zugewiesene Trainer.""" """Update training group Vereinsadmin, Plattform-Admin oder zugewiesene Trainer."""
profile_id = session["profile_id"] profile_id = tenant.profile_id
role = session.get("role") role = tenant.global_role
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
@ -647,7 +647,7 @@ def update_training_group(group_id: int, data: dict, session=Depends(require_aut
co_trainers = row["co_trainer_ids"] or [] co_trainers = row["co_trainer_ids"] or []
club_id = row["club_id"] club_id = row["club_id"]
allowed = role in ["admin", "superadmin"] allowed = role in ("admin", "superadmin")
if not allowed: if not allowed:
allowed = can_manage_club_org(cur, profile_id, club_id, role) allowed = can_manage_club_org(cur, profile_id, club_id, role)
if not allowed: if not allowed:
@ -693,15 +693,15 @@ def update_training_group(group_id: int, data: dict, session=Depends(require_aut
conn.commit() conn.commit()
return get_training_group(group_id, session) return get_training_group(group_id, tenant)
# ── Delete Training Group ───────────────────────────────────────────── # ── Delete Training Group ─────────────────────────────────────────────
@router.delete("/groups/{group_id}") @router.delete("/groups/{group_id}")
def delete_training_group(group_id: int, session=Depends(require_auth)): def delete_training_group(group_id: int, tenant: TenantContext = Depends(get_tenant_context)):
"""Delete training group Vereinsadmin oder Plattform-Admin.""" """Delete training group Vereinsadmin oder Plattform-Admin."""
profile_id = session["profile_id"] profile_id = tenant.profile_id
role = session.get("role") role = tenant.global_role
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)

View File

@ -9,8 +9,13 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
from psycopg2 import IntegrityError from psycopg2 import IntegrityError
from auth import require_auth
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 club_tenancy import (
assert_valid_governance_visibility,
exercise_visible_to_profile,
is_platform_admin,
)
from routers.training_planning import _has_planning_role from routers.training_planning import _has_planning_role
@ -82,7 +87,7 @@ _EDGE_SELECT = """
""" """
def _graph_access(cur, graph_id: int, profile_id: int, role: str) -> dict: def _graph_row(cur, graph_id: int) -> dict:
cur.execute( cur.execute(
"SELECT * FROM exercise_progression_graphs WHERE id = %s", "SELECT * FROM exercise_progression_graphs WHERE id = %s",
(graph_id,), (graph_id,),
@ -90,11 +95,40 @@ def _graph_access(cur, graph_id: int, profile_id: int, role: str) -> dict:
r = cur.fetchone() r = cur.fetchone()
if not r: if not r:
raise HTTPException(status_code=404, detail="Progressionsgraph nicht gefunden") raise HTTPException(status_code=404, detail="Progressionsgraph nicht gefunden")
row = r2d(r) return r2d(r)
if role in ("admin", "superadmin"):
return row
if row.get("created_by") != profile_id: def _assert_graph_readable(cur, row: dict, profile_id: int, role: str) -> None:
vis = (row.get("visibility") or "private").strip().lower()
cid = row.get("club_id")
if cid is not None:
cid = int(cid)
cr = row.get("created_by")
if cr is not None:
cr = int(cr)
if not exercise_visible_to_profile(cur, profile_id, vis, cid, cr, role):
raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Progressionsgraph") raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Progressionsgraph")
def _assert_graph_writable(cur, row: dict, profile_id: int, role: str) -> None:
if is_platform_admin(role):
return
created_by = row.get("created_by")
if created_by is not None:
created_by = int(created_by)
if created_by != profile_id:
raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Progressionsgraph")
def _require_graph_read(cur, graph_id: int, profile_id: int, role: str) -> dict:
row = _graph_row(cur, graph_id)
_assert_graph_readable(cur, row, profile_id, role)
return row
def _require_graph_write(cur, graph_id: int, profile_id: int, role: str) -> dict:
row = _graph_row(cur, graph_id)
_assert_graph_writable(cur, row, profile_id, role)
return row return row
@ -167,12 +201,12 @@ def _insert_edge_row(
@router.get("/exercise-progression-graphs") @router.get("/exercise-progression-graphs")
def list_progression_graphs(session: dict = Depends(require_auth)): def list_progression_graphs(tenant: TenantContext = Depends(get_tenant_context)):
profile_id = session["profile_id"] profile_id = tenant.profile_id
role = session.get("role") role = tenant.global_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 g.*, SELECT g.*,
@ -182,15 +216,21 @@ def list_progression_graphs(session: dict = Depends(require_auth)):
""" """
) )
else: else:
vis_sql, vis_params = library_content_visibility_sql(
alias="g",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
cur.execute( cur.execute(
""" f"""
SELECT g.*, SELECT g.*,
(SELECT COUNT(*) FROM exercise_progression_edges e WHERE e.graph_id = g.id) AS edges_count (SELECT COUNT(*) FROM exercise_progression_edges e WHERE e.graph_id = g.id) AS edges_count
FROM exercise_progression_graphs g FROM exercise_progression_graphs g
WHERE g.created_by = %s WHERE ({vis_sql})
ORDER BY g.updated_at DESC NULLS LAST, g.name ORDER BY g.updated_at DESC NULLS LAST, g.name
""", """,
(profile_id,), vis_params,
) )
return [r2d(r) for r in cur.fetchall()] return [r2d(r) for r in cur.fetchall()]
@ -199,13 +239,13 @@ def list_progression_graphs(session: dict = Depends(require_auth)):
def get_progression_graph( def get_progression_graph(
graph_id: int, graph_id: int,
include_edges: bool = Query(default=False), include_edges: bool = Query(default=False),
session: dict = Depends(require_auth), tenant: TenantContext = Depends(get_tenant_context),
): ):
profile_id = session["profile_id"] profile_id = tenant.profile_id
role = session.get("role") role = tenant.global_role
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
row = _graph_access(cur, graph_id, profile_id, role) row = _require_graph_read(cur, graph_id, profile_id, role)
if include_edges: if include_edges:
cur.execute( cur.execute(
_EDGE_SELECT + " WHERE e.graph_id = %s ORDER BY e.id", _EDGE_SELECT + " WHERE e.graph_id = %s ORDER BY e.id",
@ -218,10 +258,10 @@ def get_progression_graph(
@router.post("/exercise-progression-graphs", status_code=201) @router.post("/exercise-progression-graphs", status_code=201)
def create_progression_graph( def create_progression_graph(
body: ProgressionGraphCreate, body: ProgressionGraphCreate,
session: dict = Depends(require_auth), tenant: TenantContext = Depends(get_tenant_context),
): ):
profile_id = session["profile_id"] profile_id = tenant.profile_id
role = session.get("role") role = tenant.global_role
if not _has_planning_role(role): if not _has_planning_role(role):
raise HTTPException(status_code=403, detail="Keine Berechtigung zum Anlegen von Progressionsgraphen") raise HTTPException(status_code=403, detail="Keine Berechtigung zum Anlegen von Progressionsgraphen")
@ -229,55 +269,105 @@ def create_progression_graph(
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 = (body.visibility or "private").strip().lower()
cid = body.club_id
if vis == "club":
if cid is None:
cid = tenant.effective_club_id
if cid is None:
raise HTTPException(
status_code=400,
detail="Vereins-Graph: club_id angeben oder aktiven Verein wählen (X-Active-Club-Id).",
)
gov_club = cid if vis == "club" else None
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, gov_club)
cur.execute( cur.execute(
""" """
INSERT INTO exercise_progression_graphs (name, description, visibility, club_id, created_by) INSERT INTO exercise_progression_graphs (name, description, visibility, club_id, created_by)
VALUES (%s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s)
RETURNING id RETURNING id
""", """,
(name, body.description, body.visibility, body.club_id, profile_id), (name, body.description, vis, cid if vis == "club" else None, profile_id),
) )
gid = cur.fetchone()["id"] gid = cur.fetchone()["id"]
conn.commit() conn.commit()
return get_progression_graph(gid, include_edges=False, session=session) return get_progression_graph(gid, include_edges=False, tenant=tenant)
@router.put("/exercise-progression-graphs/{graph_id}") @router.put("/exercise-progression-graphs/{graph_id}")
def update_progression_graph( def update_progression_graph(
graph_id: int, graph_id: int,
body: ProgressionGraphUpdate, body: ProgressionGraphUpdate,
session: dict = Depends(require_auth), tenant: TenantContext = Depends(get_tenant_context),
): ):
profile_id = session["profile_id"] profile_id = tenant.profile_id
role = session.get("role") role = tenant.global_role
data = body.model_dump(exclude_unset=True) original = body.model_dump(exclude_unset=True)
if not data: if not original:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren") raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
_graph_access(cur, graph_id, profile_id, role) row = _require_graph_write(cur, graph_id, profile_id, role)
ex_vis = (row.get("visibility") or "private").strip().lower()
ex_cid_raw = row.get("club_id")
ex_cid = int(ex_cid_raw) if ex_cid_raw is not None else None
next_vis = ex_vis
if "visibility" in original and original["visibility"] is not None:
v_raw = str(original["visibility"]).strip().lower()
if v_raw:
next_vis = v_raw
next_club = ex_cid
if "club_id" in original:
raw_c = original["club_id"]
if raw_c in (None, "", []):
next_club = None
else:
next_club = int(raw_c)
if next_vis == "club":
if next_club is None:
next_club = tenant.effective_club_id
if next_club is None:
raise HTTPException(
status_code=400,
detail="Vereins-Graph: club_id angeben oder aktiven Verein wählen (X-Active-Club-Id).",
)
gov_club = next_club if next_vis == "club" else None
assert_valid_governance_visibility(cur, profile_id, role, next_vis, gov_club)
fields: List[str] = [] fields: List[str] = []
params: List[Any] = [] params: List[Any] = []
if "name" in data: if "name" in original:
n = (data["name"] or "").strip() n = (original["name"] or "").strip()
if not n: if not n:
raise HTTPException(status_code=400, detail="name ist Pflicht") raise HTTPException(status_code=400, detail="name ist Pflicht")
fields.append("name = %s") fields.append("name = %s")
params.append(n) params.append(n)
if "description" in data: if "description" in original:
fields.append("description = %s") fields.append("description = %s")
params.append(data["description"]) params.append(original["description"])
if "visibility" in data:
vis_changed = next_vis != ex_vis
if "visibility" in original or vis_changed:
fields.append("visibility = %s") fields.append("visibility = %s")
params.append(data["visibility"]) params.append(next_vis)
if "club_id" in data:
if "club_id" in original or vis_changed:
fields.append("club_id = %s") fields.append("club_id = %s")
params.append(data["club_id"]) params.append(next_club if next_vis == "club" else None)
if not fields:
return get_progression_graph(graph_id, include_edges=False, tenant=tenant)
fields.append("updated_at = NOW()") fields.append("updated_at = NOW()")
params.append(graph_id) params.append(graph_id)
@ -287,16 +377,16 @@ def update_progression_graph(
) )
conn.commit() conn.commit()
return get_progression_graph(graph_id, include_edges=False, session=session) return get_progression_graph(graph_id, include_edges=False, tenant=tenant)
@router.delete("/exercise-progression-graphs/{graph_id}") @router.delete("/exercise-progression-graphs/{graph_id}")
def delete_progression_graph(graph_id: int, session: dict = Depends(require_auth)): def delete_progression_graph(graph_id: int, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = session["profile_id"] profile_id = tenant.profile_id
role = session.get("role") role = tenant.global_role
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
_graph_access(cur, graph_id, profile_id, role) _require_graph_write(cur, graph_id, profile_id, role)
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}
@ -307,13 +397,13 @@ def list_progression_edges(
graph_id: int, graph_id: int,
from_exercise_id: Optional[int] = Query(default=None), from_exercise_id: Optional[int] = Query(default=None),
to_exercise_id: Optional[int] = Query(default=None), to_exercise_id: Optional[int] = Query(default=None),
session: dict = Depends(require_auth), tenant: TenantContext = Depends(get_tenant_context),
): ):
profile_id = session["profile_id"] profile_id = tenant.profile_id
role = session.get("role") role = tenant.global_role
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
_graph_access(cur, graph_id, profile_id, role) _require_graph_read(cur, graph_id, profile_id, role)
q = _EDGE_SELECT + " WHERE e.graph_id = %s" q = _EDGE_SELECT + " WHERE e.graph_id = %s"
params: List[Any] = [graph_id] params: List[Any] = [graph_id]
if from_exercise_id is not None: if from_exercise_id is not None:
@ -331,14 +421,14 @@ def list_progression_edges(
def create_progression_edge( def create_progression_edge(
graph_id: int, graph_id: int,
body: ProgressionEdgeCreate, body: ProgressionEdgeCreate,
session: dict = Depends(require_auth), tenant: TenantContext = Depends(get_tenant_context),
): ):
profile_id = session["profile_id"] profile_id = tenant.profile_id
role = session.get("role") role = tenant.global_role
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
_graph_access(cur, graph_id, profile_id, role) _require_graph_write(cur, graph_id, profile_id, role)
_assert_exercises_exist(cur, body.from_exercise_id, body.to_exercise_id) _assert_exercises_exist(cur, body.from_exercise_id, body.to_exercise_id)
fv = body.from_exercise_variant_id fv = body.from_exercise_variant_id
tv = body.to_exercise_variant_id tv = body.to_exercise_variant_id
@ -372,11 +462,11 @@ def create_progression_edge(
def create_progression_sequence( def create_progression_sequence(
graph_id: int, graph_id: int,
body: ProgressionSequenceCreate, body: ProgressionSequenceCreate,
session: dict = Depends(require_auth), tenant: TenantContext = Depends(get_tenant_context),
): ):
"""Legt n1 Nachfolger-Kanten (next_exercise) für eine geordnete Schrittliste an.""" """Legt n1 Nachfolger-Kanten (next_exercise) für eine geordnete Schrittliste an."""
profile_id = session["profile_id"] profile_id = tenant.profile_id
role = session.get("role") role = tenant.global_role
steps = body.steps steps = body.steps
n_seg = len(steps) - 1 n_seg = len(steps) - 1
seg_notes = body.segment_notes seg_notes = body.segment_notes
@ -384,7 +474,7 @@ def create_progression_sequence(
created: List[dict] = [] created: List[dict] = []
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
_graph_access(cur, graph_id, profile_id, role) _require_graph_write(cur, graph_id, profile_id, role)
ex_ids = [s.exercise_id for s in steps] ex_ids = [s.exercise_id for s in steps]
_assert_exercises_exist(cur, *ex_ids) _assert_exercises_exist(cur, *ex_ids)
@ -425,17 +515,17 @@ def update_progression_edge(
graph_id: int, graph_id: int,
edge_id: int, edge_id: int,
body: ProgressionEdgeUpdate, body: ProgressionEdgeUpdate,
session: dict = Depends(require_auth), tenant: TenantContext = Depends(get_tenant_context),
): ):
profile_id = session["profile_id"] profile_id = tenant.profile_id
role = session.get("role") role = tenant.global_role
data = body.model_dump(exclude_unset=True) data = body.model_dump(exclude_unset=True)
if not data: if not data:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren") raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
_graph_access(cur, graph_id, profile_id, role) _require_graph_write(cur, graph_id, profile_id, role)
cur.execute( cur.execute(
"SELECT id FROM exercise_progression_edges WHERE id = %s AND graph_id = %s", "SELECT id FROM exercise_progression_edges WHERE id = %s AND graph_id = %s",
(edge_id, graph_id), (edge_id, graph_id),
@ -459,13 +549,13 @@ def update_progression_edge(
def delete_progression_edge( def delete_progression_edge(
graph_id: int, graph_id: int,
edge_id: int, edge_id: int,
session: dict = Depends(require_auth), tenant: TenantContext = Depends(get_tenant_context),
): ):
profile_id = session["profile_id"] profile_id = tenant.profile_id
role = session.get("role") role = tenant.global_role
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
_graph_access(cur, graph_id, profile_id, role) _require_graph_write(cur, graph_id, profile_id, role)
cur.execute( cur.execute(
""" """
DELETE FROM exercise_progression_edges DELETE FROM exercise_progression_edges
@ -484,17 +574,17 @@ def delete_progression_edge(
def delete_progression_edges_batch( def delete_progression_edges_batch(
graph_id: int, graph_id: int,
body: EdgeIdsBatch, body: EdgeIdsBatch,
session: dict = Depends(require_auth), tenant: TenantContext = Depends(get_tenant_context),
): ):
"""Löscht mehrere Kanten (z. B. eine zusammenhängende Kette in einem Schritt).""" """Löscht mehrere Kanten (z. B. eine zusammenhängende Kette in einem Schritt)."""
profile_id = session["profile_id"] profile_id = tenant.profile_id
role = session.get("role") role = tenant.global_role
ids = body.edge_ids ids = body.edge_ids
clean_ids = list(dict.fromkeys(ids)) clean_ids = list(dict.fromkeys(ids))
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
_graph_access(cur, graph_id, profile_id, role) _require_graph_write(cur, graph_id, profile_id, role)
cur.execute( cur.execute(
f""" f"""
DELETE FROM exercise_progression_edges DELETE FROM exercise_progression_edges

View File

@ -12,7 +12,7 @@ from fastapi import APIRouter, HTTPException, Header, Depends
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_club_member, memberships_with_roles, is_platform_admin from club_tenancy import assert_club_member, memberships_with_roles, is_platform_admin
from tenant_context import resolve_tenant_context from tenant_context import resolve_tenant_context, TenantContext, get_tenant_context
from models import ProfileCreate, ProfileUpdate from models import ProfileCreate, ProfileUpdate
router = APIRouter(prefix="/api", tags=["profiles"]) router = APIRouter(prefix="/api", tags=["profiles"])
@ -100,26 +100,29 @@ def create_profile(p: ProfileCreate, session=Depends(require_auth)):
return r2d(cur.fetchone()) return r2d(cur.fetchone())
@router.get("/profiles/{pid}") def profile_document(pid: str) -> dict:
def get_profile(pid: str, session=Depends(require_auth)): """Profil ohne PIN — für Routen und interne Aufrufe (z. B. /auth/me)."""
"""Get profile by ID."""
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
row = cur.fetchone() row = cur.fetchone()
if not row: raise HTTPException(404, "Profil nicht gefunden") if not row:
raise HTTPException(404, "Profil nicht gefunden")
d = r2d(row) d = r2d(row)
d.pop("pin_hash", None) d.pop("pin_hash", None)
return d return d
@router.put("/profiles/{pid}") @router.get("/profiles/{pid}")
def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)): def get_profile(pid: str, _session=Depends(require_auth)):
"""Update profile — nur eigenes Profil oder Admin.""" """Get profile by ID."""
sess_pid = session.get('profile_id') return profile_document(pid)
role = (session.get('role') or '').lower() def _run_profile_update(pid: str, p: ProfileUpdate, tenant: TenantContext) -> dict:
if str(sess_pid) != str(pid) and role not in ('admin', 'superadmin'): """Gemeinsame PUT-Logik für /profiles/{id} und Legacy /profile."""
raise HTTPException(403, 'Keine Berechtigung für dieses Profil') sess_pid = tenant.profile_id
role = tenant.global_role
if str(sess_pid) != str(pid) and role not in ("admin", "superadmin"):
raise HTTPException(403, "Keine Berechtigung für dieses Profil")
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
@ -229,13 +232,19 @@ def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)):
data[k] = v data[k] = v
if not data: if not data:
return get_profile(pid, session) return profile_document(pid)
data["updated_at"] = datetime.now() data["updated_at"] = datetime.now()
cols = ", ".join(f"{k}=%s" for k in data) cols = ", ".join(f"{k}=%s" for k in data)
vals = list(data.values()) + [pid] vals = list(data.values()) + [pid]
cur.execute(f"UPDATE profiles SET {cols} WHERE id=%s", vals) cur.execute(f"UPDATE profiles SET {cols} WHERE id=%s", vals)
return get_profile(pid, session) return profile_document(pid)
@router.put("/profiles/{pid}")
def update_profile(pid: str, p: ProfileUpdate, tenant: TenantContext = Depends(get_tenant_context)):
"""Update profile — nur eigenes Profil oder Admin; TenantContext validiert X-Active-Club-Id."""
return _run_profile_update(pid, p, tenant)
@router.delete("/profiles/{pid}") @router.delete("/profiles/{pid}")
@ -257,11 +266,15 @@ def delete_profile(pid: str, session=Depends(require_auth)):
def get_active_profile(x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth)): def get_active_profile(x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth)):
"""Legacy endpoint returns active profile.""" """Legacy endpoint returns active profile."""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
return get_profile(pid, session) return profile_document(pid)
@router.put("/profile") @router.put("/profile")
def update_active_profile(p: ProfileUpdate, x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth)): def update_active_profile(
p: ProfileUpdate,
x_profile_id: Optional[str] = Header(default=None),
tenant: TenantContext = Depends(get_tenant_context),
):
"""Update current user's profile.""" """Update current user's profile."""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
return update_profile(pid, p, session) return _run_profile_update(pid, p, tenant)

View File

@ -1,21 +1,21 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.25" APP_VERSION = "0.8.26"
BUILD_DATE = "2026-05-05" BUILD_DATE = "2026-05-05"
DB_SCHEMA_VERSION = "20260505041" DB_SCHEMA_VERSION = "20260505041"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute) "auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute)
"profiles": "1.4.0", # GET /profiles/me: effective_club_id (TenantContext-Auflösung); TenantContext-Modul "profiles": "1.4.1", # PUT /profiles*, Legacy /profile: Depends(get_tenant_context); profile_document für internes Laden
"tenant_context": "1.0.0", # resolve/get_depends; library_content_visibility_sql "tenant_context": "1.0.1", # Vereine/Mitglieder/Vereinsanträge/Profil-PUT nutzen get_tenant_context (Header-Membership)
"clubs": "0.4.0", # public-directory, members/directory; Vereins-GUI verwendet Endpoints "clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext
"club_memberships": "1.0.0", "club_memberships": "1.0.1", # Depends(get_tenant_context)
"club_join_requests": "1.0.0", "club_join_requests": "1.0.1", # Depends(get_tenant_context)
"admin_users": "1.0.0", # GET /api/admin/users "admin_users": "1.0.0", # GET /api/admin/users
"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.6.1", # PUT Übung: visibility club ohne club_id → effective_club_id / Body club_id; Governance für Plattform-Admins ohne Vereinsmitgliedschaft bei club "exercises": "2.6.2", # Progressionsgraphen: library_content_visibility_sql + Lesen wie Bibliothek; Schreiben Ersteller/Admin; Governance club_id wie Übungen
"training_units": "0.1.0", "training_units": "0.1.0",
"training_programs": "0.1.0", "training_programs": "0.1.0",
"planning": "0.8.0", # TenantContext auf allen Planungs-Endpunkten; Vorlagen-Liste wie Übungen nach aktivem Verein "planning": "0.8.0", # TenantContext auf allen Planungs-Endpunkten; Vorlagen-Liste wie Übungen nach aktivem Verein
@ -27,6 +27,15 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.26",
"date": "2026-05-05",
"changes": [
"ACCESS_LAYER: clubs-, club_memberships-, club_join_requests-Router nutzen Depends(get_tenant_context) statt nur require_auth",
"profiles: PUT /profiles/{id} und /profile mit TenantContext; profile_document für /auth/me und intern",
"exercise_progression_graphs: Liste/Detail nach library_content_visibility_sql; Leserechte Vereins-Graphs; POST/PUT mit assert_valid_governance_visibility und club_id wie Übungen",
],
},
{ {
"version": "0.8.25", "version": "0.8.25",
"date": "2026-05-05", "date": "2026-05-05",

View File

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