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 |
|------------------|-------------------|-----------------|----------------------------------------|-------------------------------------|---------|
| 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.
---

View File

@ -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")

View File

@ -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(
"""

View File

@ -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",

View File

@ -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)

View File

@ -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 n1 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

View File

@ -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)

View File

@ -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",

View File

@ -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 = {