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.
This commit is contained in:
parent
870a7611dc
commit
5aee9c52fc
|
|
@ -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 |
|
| 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 n−1 Nachfolger-Kanten (next_exercise) für eine geordnete Schrittliste an."""
|
"""Legt n−1 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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user