feat: update exercises API to fully integrate tenant context and bump version to 0.8.24
Some checks failed
Deploy Development / deploy (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 43s

- Refactored exercises API endpoints to utilize tenant context for authentication and authorization, enhancing security and governance.
- Updated access layer documentation to reflect the complete integration of tenant context for exercises.
- Bumped application version to 0.8.24 in both backend and frontend files.
- Enhanced changelog to document the new version and changes made in this release.
This commit is contained in:
Lars 2026-05-05 21:51:53 +02:00
parent c919e02441
commit 0181575962
4 changed files with 43 additions and 35 deletions

View File

@ -10,8 +10,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
| 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 | `GET /api/exercises`, `POST /api/exercises` | ja | `get_tenant_context` | ja | Liste club nach aktivem Verein; POST Governance + Default club_id |
| exercises | Detail/PATCH (Übriges) | teils | `require_auth` | Owner/Admin | später Tenant optional |
| 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 |
| 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 | |
@ -19,7 +18,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
**Legende:** „geplant“ = beim nächsten Umbau dieser Router `get_tenant_context` verwenden bzw. zentrale Governance-Helfer.
Letzte Änderung: 2026-05-05 — Stufe B/C partiell (Bibliothekslisten + Planung); `GET /training-units` ohne automatischen club_id-Filter (Kompatibilität).
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).
---

View File

@ -15,7 +15,6 @@ from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File,
from pydantic import BaseModel, Field, model_validator
from db import get_db, get_cursor, r2d
from auth import require_auth
from club_tenancy import assert_valid_governance_visibility, exercise_visible_to_profile, is_platform_admin
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
@ -82,8 +81,8 @@ ALLOWED_UPLOAD_MIMES = frozenset(
)
def _upload_limit_bytes(session: dict) -> int:
role = session.get("role") or ""
def _upload_limit_bytes(tenant: TenantContext) -> int:
role = tenant.global_role or ""
if role in ("admin", "superadmin"):
return MAX_UPLOAD_BYTES_ADMIN
return MAX_UPLOAD_BYTES_USER
@ -752,12 +751,12 @@ def list_exercises(
@router.get("/exercises/{exercise_id}")
def get_exercise(
exercise_id: int,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Exercise Detail mit allen M:N Relations (vollständig enriched).
"""
profile_id = session["profile_id"]
profile_id = tenant.profile_id
with get_db() as conn:
cur = get_cursor(conn)
@ -772,7 +771,7 @@ def get_exercise(
exercise["visibility"],
exercise.get("club_id"),
exercise.get("created_by"),
session.get("role"),
tenant.global_role,
):
raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Übung")
@ -843,13 +842,13 @@ def create_exercise(
def update_exercise(
exercise_id: int,
body: ExerciseUpdate,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Aktualisiert eine Übung (Partial Update).
Nur Owner darf editieren.
"""
profile_id = session["profile_id"]
profile_id = tenant.profile_id
with get_db() as conn:
cur = get_cursor(conn)
@ -903,14 +902,14 @@ def update_exercise(
@router.delete("/exercises/{exercise_id}")
def delete_exercise(
exercise_id: int,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Löscht eine Übung.
Nur Owner oder Admin darf löschen.
"""
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)
@ -922,7 +921,7 @@ def delete_exercise(
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
# Permission Check
if _row_created_by(row) != profile_id and role not in ("admin", "superadmin"):
if _row_created_by(row) != profile_id and not is_platform_admin(role):
raise HTTPException(status_code=403, detail="Nur Ersteller oder Admin darf löschen")
# Prüfen ob Übung in Block-Items verwendet wird
@ -952,9 +951,9 @@ def delete_exercise(
def reorder_exercise_variants(
exercise_id: int,
body: ExerciseVariantsReorder,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = session["profile_id"]
profile_id = tenant.profile_id
if len(body.variant_ids) != len(set(body.variant_ids)):
raise HTTPException(status_code=400, detail="variant_ids dürfen keine Duplikate enthalten")
@ -989,9 +988,9 @@ def reorder_exercise_variants(
def create_exercise_variant(
exercise_id: int,
body: ExerciseVariantCreate,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = session["profile_id"]
profile_id = tenant.profile_id
with get_db() as conn:
cur = get_cursor(conn)
@ -1045,9 +1044,9 @@ def update_exercise_variant(
exercise_id: int,
variant_id: int,
body: ExerciseVariantUpdate,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = session["profile_id"]
profile_id = tenant.profile_id
data = body.dict(exclude_unset=True)
if not data:
@ -1118,12 +1117,15 @@ def update_exercise_variant(
conn.commit()
return row
@router.delete("/exercises/{exercise_id}/variants/{variant_id}")
def delete_exercise_variant(
exercise_id: int,
variant_id: int,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = session["profile_id"]
profile_id = tenant.profile_id
with get_db() as conn:
cur = get_cursor(conn)
@ -1167,7 +1169,7 @@ def _fetch_media_row(cur, exercise_id: int, media_id: int) -> Optional[dict]:
@router.post("/exercises/{exercise_id}/media", status_code=201)
async def upload_exercise_media(
exercise_id: int,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
file: Optional[UploadFile] = File(None),
embed_url: Optional[str] = Form(None),
media_type: str = Form(...),
@ -1176,7 +1178,7 @@ async def upload_exercise_media(
context: str = Form("ablauf"),
is_primary: bool = Form(False),
):
profile_id = session["profile_id"]
profile_id = tenant.profile_id
if media_type not in ("image", "video", "document", "sketch"):
raise HTTPException(status_code=400, detail="Ungültiger media_type")
if context not in ("ablauf", "detail", "trainer_hint"):
@ -1234,7 +1236,7 @@ async def upload_exercise_media(
)
else:
raw = await file.read()
max_upload = _upload_limit_bytes(session)
max_upload = _upload_limit_bytes(tenant)
if len(raw) > max_upload:
raise HTTPException(
status_code=413,
@ -1289,9 +1291,9 @@ async def upload_exercise_media(
def reorder_exercise_media(
exercise_id: int,
body: ExerciseMediaReorder,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = session["profile_id"]
profile_id = tenant.profile_id
ids = body.media_ids
with get_db() as conn:
cur = get_cursor(conn)
@ -1320,9 +1322,9 @@ def update_exercise_media(
exercise_id: int,
media_id: int,
body: ExerciseMediaUpdate,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = session["profile_id"]
profile_id = tenant.profile_id
data = body.dict(exclude_unset=True)
if not data:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
@ -1362,9 +1364,9 @@ def update_exercise_media(
def delete_exercise_media(
exercise_id: int,
media_id: int,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = session["profile_id"]
profile_id = tenant.profile_id
with get_db() as conn:
cur = get_cursor(conn)
_assert_can_edit_exercise(cur, exercise_id, profile_id)

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.23"
APP_VERSION = "0.8.24"
BUILD_DATE = "2026-05-05"
DB_SCHEMA_VERSION = "20260505041"
@ -15,7 +15,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
"exercises": "2.5.0", # list/create: TenantContext + Governance; club-Liste nach aktivem Verein
"exercises": "2.6.0", # Alle geschützten Endpoints: TenantContext (Detail, Mutations, Medien, Varianten)
"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,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.24",
"date": "2026-05-05",
"changes": [
"Übungen-Router: get/update/delete, Varianten, Medien — Depends(get_tenant_context); Upload-Limits via TenantContext; fehlenden DELETE decorator variants gefixt",
],
},
{
"version": "0.8.23",
"date": "2026-05-05",

View File

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