From 5aee9c52fc09ceeb2b77b6d4e9e823abee7e0a41 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 5 May 2026 22:05:10 +0200 Subject: [PATCH] feat: integrate tenant context across club-related APIs - 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. --- .../working/ACCESS_LAYER_ENDPOINT_AUDIT.md | 16 +- backend/routers/auth.py | 4 +- backend/routers/club_join_requests.py | 36 +-- backend/routers/club_memberships.py | 28 +-- backend/routers/clubs.py | 100 ++++---- .../routers/exercise_progression_graphs.py | 220 ++++++++++++------ backend/routers/profiles.py | 47 ++-- backend/version.py | 23 +- frontend/src/version.js | 2 +- 9 files changed, 294 insertions(+), 182 deletions(-) diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md index 6ed857e..aa7dbfb 100644 --- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -5,20 +5,20 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. | 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 | `PUT /api/profiles/{id}` | ja | — | `active_club_id` Mitgliedschaft | TenantContext später auch hier | -| clubs | `GET /api/clubs` | ja | — | Mitgliedschaft vs Admin | Liste gefiltert Nicht-Admins | -| clubs | CRUD Organisation | ja | — | `can_manage_club_org` / member | schrittweise auf TenantContext | -| club_memberships | `/clubs/{id}/members*` | ja | geplant | ja | | -| club_join_requests | `/clubs/{id}/join-requests*` | ja | geplant | 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 | +| 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 | geschützte `/api/clubs*`, `/divisions*`, `/groups*` | ja | `get_tenant_context` | Mitgliedschaft / `can_manage_*` | Öffentlich: `/clubs/public-directory` ohne Auth | +| club_memberships | `/clubs/{id}/members*` | ja | `get_tenant_context` | ja | | +| club_join_requests | `/me/club-join-requests`, `/clubs/{id}/join-requests*` | ja | `get_tenant_context` | ja | | +| exercises | alle geschützten `/api/exercises*` | ja | `get_tenant_context` | ja | | +| 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_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 | | | 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. --- diff --git a/backend/routers/auth.py b/backend/routers/auth.py index ce4b554..cb8bcc8 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -78,8 +78,8 @@ def get_me(session: dict=Depends(require_auth)): """Get current user info.""" pid = session['profile_id'] # Import here to avoid circular dependency - from routers.profiles import get_profile - return get_profile(pid, session) + from routers.profiles import profile_document + return profile_document(pid) @router.get("/status") diff --git a/backend/routers/club_join_requests.py b/backend/routers/club_join_requests.py index 20dda2b..dae7767 100644 --- a/backend/routers/club_join_requests.py +++ b/backend/routers/club_join_requests.py @@ -6,9 +6,9 @@ from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException 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 +from tenant_context import TenantContext, get_tenant_context 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 -def _assert_manage_club(cur, session: dict, club_id: int) -> None: - pid = session["profile_id"] - role = session.get("role") +def _assert_manage_club(cur, tenant: TenantContext, club_id: int) -> None: + pid = tenant.profile_id + role = tenant.global_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") @@ -107,8 +107,8 @@ def _response_one(cur, req_id: int, viewer_profile_id: int) -> Dict[str, Any]: @router.get("/me/club-join-requests") -def get_my_join_requests(session: dict = Depends(require_auth)): - pid = session["profile_id"] +def get_my_join_requests(tenant: TenantContext = Depends(get_tenant_context)): + pid = tenant.profile_id with get_db() as conn: cur = get_cursor(conn) 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) -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).""" - pid = session["profile_id"] + pid = tenant.profile_id msg = (body.message or "").strip() or None 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}") -def withdraw_my_join_request(request_id: int, session: dict = Depends(require_auth)): - pid = session["profile_id"] +def withdraw_my_join_request(request_id: int, tenant: TenantContext = Depends(get_tenant_context)): + pid = tenant.profile_id with get_db() as conn: cur = get_cursor(conn) 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") -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).""" with get_db() as conn: cur = get_cursor(conn) - _assert_manage_club(cur, session, club_id) + _assert_manage_club(cur, tenant, club_id) cur.execute( """ SELECT r.*, p.email AS applicant_email, p.name AS applicant_name @@ -211,14 +211,14 @@ def accept_club_join_request( club_id: int, request_id: int, 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) with get_db() as conn: cur = get_cursor(conn) - _assert_manage_club(cur, session, club_id) + _assert_manage_club(cur, tenant, club_id) cur.execute( """ @@ -257,11 +257,11 @@ def accept_club_join_request( @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)): - admin_pid = session["profile_id"] +def reject_club_join_request(club_id: int, request_id: int, tenant: TenantContext = Depends(get_tenant_context)): + admin_pid = tenant.profile_id with get_db() as conn: cur = get_cursor(conn) - _assert_manage_club(cur, session, club_id) + _assert_manage_club(cur, tenant, club_id) cur.execute( """ diff --git a/backend/routers/club_memberships.py b/backend/routers/club_memberships.py index 7b2e22a..d1a4a6a 100644 --- a/backend/routers/club_memberships.py +++ b/backend/routers/club_memberships.py @@ -8,9 +8,9 @@ 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 +from tenant_context import TenantContext, get_tenant_context router = APIRouter(prefix="/api", tags=["club_memberships"]) @@ -38,9 +38,9 @@ def _normalize_roles(raw: List[str]) -> List[str]: return out -def _assert_manage(cur, session: dict, club_id: int) -> None: - pid = session["profile_id"] - role = session.get("role") +def _assert_manage(cur, tenant: TenantContext, club_id: int) -> None: + pid = tenant.profile_id + role = tenant.global_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") @@ -64,14 +64,14 @@ class ClubMemberPatch(BaseModel): def list_club_members( club_id: int, 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).""" 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) + _assert_manage(cur, tenant, club_id) status_clause = "" if include_inactive else "AND cm.status = 'active'" cur.execute( @@ -106,7 +106,7 @@ def list_club_members( def upsert_club_member( club_id: int, body: ClubMemberUpsert, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): """Mitglied anlegen oder aktivieren; Rollen werden vollständig ersetzt.""" roles = _normalize_roles(body.roles) @@ -117,7 +117,7 @@ def upsert_club_member( 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) + _assert_manage(cur, tenant, club_id) cur.execute("SELECT id FROM profiles WHERE id = %s", (body.profile_id,)) 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( club_id: int, profile_id: int, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): 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) + _assert_manage(cur, tenant, club_id) return _one_member(cur, club_id, profile_id) @@ -196,14 +196,14 @@ def update_club_member( club_id: int, profile_id: int, body: ClubMemberPatch, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): """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) + _assert_manage(cur, tenant, club_id) cur.execute( "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( club_id: int, profile_id: int, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): """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) + _assert_manage(cur, tenant, club_id) cur.execute( "DELETE FROM club_members WHERE club_id = %s AND profile_id = %s RETURNING id", diff --git a/backend/routers/clubs.py b/backend/routers/clubs.py index 841a33d..b949ff0 100644 --- a/backend/routers/clubs.py +++ b/backend/routers/clubs.py @@ -7,7 +7,7 @@ from typing import Any, List, Optional from fastapi import APIRouter, HTTPException, Depends, Query 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 ( assert_club_member, can_manage_club_org, @@ -23,13 +23,13 @@ router = APIRouter(prefix="/api", tags=["clubs"]) @router.get("/clubs") def list_clubs( 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. """ - role = session.get("role") - profile_id = session["profile_id"] + role = tenant.global_role + profile_id = tenant.profile_id with get_db() as conn: cur = get_cursor(conn) query = "SELECT * FROM clubs" @@ -76,9 +76,9 @@ def public_club_directory(): # ── Aktive Mitglieder für Trainer-/Co-Trainer-Auswahl (jeder Vereinsmitglied) ── @router.get("/clubs/{club_id}/members/directory") -def club_members_directory(club_id: int, session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def club_members_directory(club_id: int, tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) if not is_platform_admin(role): @@ -101,10 +101,10 @@ def club_members_directory(club_id: int, session=Depends(require_auth)): # ── Get Club ────────────────────────────────────────────────────────── @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.""" - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) if not is_platform_admin(role): @@ -149,9 +149,9 @@ def get_club(club_id: int, session=Depends(require_auth)): # ── Create Club ─────────────────────────────────────────────────────── @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).""" - role = session.get("role") + role = tenant.global_role if not is_platform_admin(role): 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() - return get_club(club_id, session) + return get_club(club_id, tenant) # ── Update Club ─────────────────────────────────────────────────────── @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.""" - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) @@ -263,14 +263,14 @@ def update_club(club_id: int, data: dict, session=Depends(require_auth)): conn.commit() - return get_club(club_id, session) + return get_club(club_id, tenant) # ── Delete Club ─────────────────────────────────────────────────────── @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).""" - role = session.get('role') + role = tenant.global_role if role != 'superadmin': 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") def list_divisions( 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.""" - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) @@ -338,10 +338,10 @@ def list_divisions( # ── Create Division ─────────────────────────────────────────────────── @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.""" - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role club_id = data.get("club_id") name = data.get("name") @@ -392,10 +392,10 @@ def create_division(data: dict, session=Depends(require_auth)): # ── Update Division ─────────────────────────────────────────────────── @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.""" - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) @@ -439,10 +439,10 @@ def update_division(division_id: int, data: dict, session=Depends(require_auth)) # ── Delete Division ─────────────────────────────────────────────────── @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.""" - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) @@ -467,13 +467,13 @@ def list_training_groups( club_id: Optional[int] = Query(default=None), division_id: Optional[int] = 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. """ - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) @@ -526,10 +526,10 @@ def list_training_groups( # ── Get Training Group ──────────────────────────────────────────────── @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.""" - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) @@ -562,11 +562,11 @@ def get_training_group(group_id: int, session=Depends(require_auth)): # ── Create Training Group ───────────────────────────────────────────── @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.""" - profile_id = session["profile_id"] - role = session.get("role") - if role not in ["admin", "superadmin", "trainer", "user"]: + profile_id = tenant.profile_id + role = tenant.global_role + if role not in ("admin", "superadmin", "trainer", "user"): raise HTTPException(403, "Keine Berechtigung, Trainingsgruppen anzulegen") 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"] conn.commit() - return get_training_group(group_id, session) + return get_training_group(group_id, tenant) # ── Update Training Group ───────────────────────────────────────────── @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.""" - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as 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 [] club_id = row["club_id"] - allowed = role in ["admin", "superadmin"] + allowed = role in ("admin", "superadmin") if not allowed: allowed = can_manage_club_org(cur, profile_id, club_id, role) if not allowed: @@ -693,15 +693,15 @@ def update_training_group(group_id: int, data: dict, session=Depends(require_aut conn.commit() - return get_training_group(group_id, session) + return get_training_group(group_id, tenant) # ── Delete Training Group ───────────────────────────────────────────── @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.""" - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) diff --git a/backend/routers/exercise_progression_graphs.py b/backend/routers/exercise_progression_graphs.py index 13326aa..683e63c 100644 --- a/backend/routers/exercise_progression_graphs.py +++ b/backend/routers/exercise_progression_graphs.py @@ -9,8 +9,13 @@ from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field, model_validator from psycopg2 import IntegrityError -from auth import require_auth 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 @@ -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( "SELECT * FROM exercise_progression_graphs WHERE id = %s", (graph_id,), @@ -90,11 +95,40 @@ def _graph_access(cur, graph_id: int, profile_id: int, role: str) -> dict: r = cur.fetchone() if not r: raise HTTPException(status_code=404, detail="Progressionsgraph nicht gefunden") - row = r2d(r) - if role in ("admin", "superadmin"): - return row - if row.get("created_by") != profile_id: + return r2d(r) + + +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") + + +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 @@ -167,12 +201,12 @@ def _insert_edge_row( @router.get("/exercise-progression-graphs") -def list_progression_graphs(session: dict = Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def list_progression_graphs(tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) - if role in ("admin", "superadmin"): + if is_platform_admin(role): cur.execute( """ SELECT g.*, @@ -182,15 +216,21 @@ def list_progression_graphs(session: dict = Depends(require_auth)): """ ) 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( - """ + f""" SELECT g.*, (SELECT COUNT(*) FROM exercise_progression_edges e WHERE e.graph_id = g.id) AS edges_count FROM exercise_progression_graphs g - WHERE g.created_by = %s + WHERE ({vis_sql}) ORDER BY g.updated_at DESC NULLS LAST, g.name """, - (profile_id,), + vis_params, ) 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( graph_id: int, include_edges: bool = Query(default=False), - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as 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: cur.execute( _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) def create_progression_graph( body: ProgressionGraphCreate, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role if not _has_planning_role(role): raise HTTPException(status_code=403, detail="Keine Berechtigung zum Anlegen von Progressionsgraphen") @@ -229,55 +269,105 @@ def create_progression_graph( if not name: 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: cur = get_cursor(conn) + assert_valid_governance_visibility(cur, profile_id, role, vis, gov_club) cur.execute( """ INSERT INTO exercise_progression_graphs (name, description, visibility, club_id, created_by) VALUES (%s, %s, %s, %s, %s) 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"] 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}") def update_progression_graph( graph_id: int, body: ProgressionGraphUpdate, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): - profile_id = session["profile_id"] - role = session.get("role") - data = body.model_dump(exclude_unset=True) - if not data: + profile_id = tenant.profile_id + role = tenant.global_role + original = body.model_dump(exclude_unset=True) + if not original: raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren") with get_db() as 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] = [] params: List[Any] = [] - if "name" in data: - n = (data["name"] or "").strip() + if "name" in original: + n = (original["name"] or "").strip() if not n: raise HTTPException(status_code=400, detail="name ist Pflicht") fields.append("name = %s") params.append(n) - if "description" in data: + if "description" in original: fields.append("description = %s") - params.append(data["description"]) - if "visibility" in data: + params.append(original["description"]) + + vis_changed = next_vis != ex_vis + if "visibility" in original or vis_changed: fields.append("visibility = %s") - params.append(data["visibility"]) - if "club_id" in data: + params.append(next_vis) + + if "club_id" in original or vis_changed: 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()") params.append(graph_id) @@ -287,16 +377,16 @@ def update_progression_graph( ) 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}") -def delete_progression_graph(graph_id: int, session: dict = Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def delete_progression_graph(graph_id: int, tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as 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,)) conn.commit() return {"ok": True} @@ -307,13 +397,13 @@ def list_progression_edges( graph_id: int, from_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"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as 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" params: List[Any] = [graph_id] if from_exercise_id is not None: @@ -331,14 +421,14 @@ def list_progression_edges( def create_progression_edge( graph_id: int, body: ProgressionEdgeCreate, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as 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) fv = body.from_exercise_variant_id tv = body.to_exercise_variant_id @@ -372,11 +462,11 @@ def create_progression_edge( def create_progression_sequence( graph_id: int, body: ProgressionSequenceCreate, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): """Legt n−1 Nachfolger-Kanten (next_exercise) für eine geordnete Schrittliste an.""" - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role steps = body.steps n_seg = len(steps) - 1 seg_notes = body.segment_notes @@ -384,7 +474,7 @@ def create_progression_sequence( created: List[dict] = [] with get_db() as 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] _assert_exercises_exist(cur, *ex_ids) @@ -425,17 +515,17 @@ def update_progression_edge( graph_id: int, edge_id: int, body: ProgressionEdgeUpdate, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role data = body.model_dump(exclude_unset=True) if not data: raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren") with get_db() as conn: cur = get_cursor(conn) - _graph_access(cur, graph_id, profile_id, role) + _require_graph_write(cur, graph_id, profile_id, role) cur.execute( "SELECT id FROM exercise_progression_edges WHERE id = %s AND graph_id = %s", (edge_id, graph_id), @@ -459,13 +549,13 @@ def update_progression_edge( def delete_progression_edge( graph_id: int, edge_id: int, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as 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_edges @@ -484,17 +574,17 @@ def delete_progression_edge( def delete_progression_edges_batch( graph_id: int, 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).""" - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role ids = body.edge_ids clean_ids = list(dict.fromkeys(ids)) with get_db() as conn: cur = get_cursor(conn) - _graph_access(cur, graph_id, profile_id, role) + _require_graph_write(cur, graph_id, profile_id, role) cur.execute( f""" DELETE FROM exercise_progression_edges diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py index 8831a79..b0d8f82 100644 --- a/backend/routers/profiles.py +++ b/backend/routers/profiles.py @@ -12,7 +12,7 @@ from fastapi import APIRouter, HTTPException, Header, Depends from db import get_db, get_cursor, r2d from auth import require_auth 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 router = APIRouter(prefix="/api", tags=["profiles"]) @@ -100,26 +100,29 @@ def create_profile(p: ProfileCreate, session=Depends(require_auth)): return r2d(cur.fetchone()) -@router.get("/profiles/{pid}") -def get_profile(pid: str, session=Depends(require_auth)): - """Get profile by ID.""" +def profile_document(pid: str) -> dict: + """Profil ohne PIN — für Routen und interne Aufrufe (z. B. /auth/me).""" with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) 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.pop("pin_hash", None) return d -@router.put("/profiles/{pid}") -def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)): - """Update profile — nur eigenes Profil oder Admin.""" - sess_pid = session.get('profile_id') - role = (session.get('role') or '').lower() - if str(sess_pid) != str(pid) and role not in ('admin', 'superadmin'): - raise HTTPException(403, 'Keine Berechtigung für dieses Profil') +@router.get("/profiles/{pid}") +def get_profile(pid: str, _session=Depends(require_auth)): + """Get profile by ID.""" + return profile_document(pid) +def _run_profile_update(pid: str, p: ProfileUpdate, tenant: TenantContext) -> dict: + """Gemeinsame PUT-Logik für /profiles/{id} und Legacy /profile.""" + 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: cur = get_cursor(conn) @@ -229,13 +232,19 @@ def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)): data[k] = v if not data: - return get_profile(pid, session) + return profile_document(pid) data["updated_at"] = datetime.now() cols = ", ".join(f"{k}=%s" for k in data) vals = list(data.values()) + [pid] 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}") @@ -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)): """Legacy endpoint – returns active profile.""" pid = get_pid(x_profile_id) - return get_profile(pid, session) + return profile_document(pid) @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.""" pid = get_pid(x_profile_id) - return update_profile(pid, p, session) + return _run_profile_update(pid, p, tenant) diff --git a/backend/version.py b/backend/version.py index 2bfe0fb..5dad9f4 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,21 +1,21 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.25" +APP_VERSION = "0.8.26" BUILD_DATE = "2026-05-05" DB_SCHEMA_VERSION = "20260505041" MODULE_VERSIONS = { "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 - "tenant_context": "1.0.0", # resolve/get_depends; library_content_visibility_sql - "clubs": "0.4.0", # public-directory, members/directory; Vereins-GUI verwendet Endpoints - "club_memberships": "1.0.0", - "club_join_requests": "1.0.0", + "profiles": "1.4.1", # PUT /profiles*, Legacy /profile: Depends(get_tenant_context); profile_document für internes Laden + "tenant_context": "1.0.1", # Vereine/Mitglieder/Vereinsanträge/Profil-PUT nutzen get_tenant_context (Header-Membership) + "clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext + "club_memberships": "1.0.1", # Depends(get_tenant_context) + "club_join_requests": "1.0.1", # Depends(get_tenant_context) "admin_users": "1.0.0", # GET /api/admin/users "groups": "0.1.0", "skills": "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_programs": "0.1.0", "planning": "0.8.0", # TenantContext auf allen Planungs-Endpunkten; Vorlagen-Liste wie Übungen nach aktivem Verein @@ -27,6 +27,15 @@ MODULE_VERSIONS = { } 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", "date": "2026-05-05", diff --git a/frontend/src/version.js b/frontend/src/version.js index fd7a553..56180ae 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.25" +export const APP_VERSION = "0.8.26" export const BUILD_DATE = "2026-05-05" export const PAGE_VERSIONS = {