Mandantenfähigkeit V1 #10
|
|
@ -10,8 +10,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
||||||
| clubs | CRUD Organisation | ja | — | `can_manage_club_org` / member | schrittweise auf TenantContext |
|
| clubs | CRUD Organisation | ja | — | `can_manage_club_org` / member | schrittweise auf TenantContext |
|
||||||
| club_memberships | `/clubs/{id}/members*` | ja | geplant | ja | |
|
| club_memberships | `/clubs/{id}/members*` | ja | geplant | ja | |
|
||||||
| club_join_requests | `/clubs/{id}/join-requests*` | 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 | 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 |
|
||||||
| exercises | Detail/PATCH (Übriges) | teils | `require_auth` | Owner/Admin | später Tenant optional |
|
|
||||||
| 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 | |
|
||||||
|
|
@ -19,7 +18,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
||||||
|
|
||||||
**Legende:** „geplant“ = beim nächsten Umbau dieser Router `get_tenant_context` verwenden bzw. zentrale Governance-Helfer.
|
**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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File,
|
||||||
from pydantic import BaseModel, Field, model_validator
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
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 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
|
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:
|
def _upload_limit_bytes(tenant: TenantContext) -> int:
|
||||||
role = session.get("role") or ""
|
role = tenant.global_role or ""
|
||||||
if role in ("admin", "superadmin"):
|
if role in ("admin", "superadmin"):
|
||||||
return MAX_UPLOAD_BYTES_ADMIN
|
return MAX_UPLOAD_BYTES_ADMIN
|
||||||
return MAX_UPLOAD_BYTES_USER
|
return MAX_UPLOAD_BYTES_USER
|
||||||
|
|
@ -752,12 +751,12 @@ def list_exercises(
|
||||||
@router.get("/exercises/{exercise_id}")
|
@router.get("/exercises/{exercise_id}")
|
||||||
def get_exercise(
|
def get_exercise(
|
||||||
exercise_id: int,
|
exercise_id: int,
|
||||||
session: dict = Depends(require_auth),
|
tenant: TenantContext = Depends(get_tenant_context),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Exercise Detail mit allen M:N Relations (vollständig enriched).
|
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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
@ -772,7 +771,7 @@ def get_exercise(
|
||||||
exercise["visibility"],
|
exercise["visibility"],
|
||||||
exercise.get("club_id"),
|
exercise.get("club_id"),
|
||||||
exercise.get("created_by"),
|
exercise.get("created_by"),
|
||||||
session.get("role"),
|
tenant.global_role,
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Übung")
|
raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Übung")
|
||||||
|
|
||||||
|
|
@ -843,13 +842,13 @@ def create_exercise(
|
||||||
def update_exercise(
|
def update_exercise(
|
||||||
exercise_id: int,
|
exercise_id: int,
|
||||||
body: ExerciseUpdate,
|
body: ExerciseUpdate,
|
||||||
session: dict = Depends(require_auth),
|
tenant: TenantContext = Depends(get_tenant_context),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Aktualisiert eine Übung (Partial Update).
|
Aktualisiert eine Übung (Partial Update).
|
||||||
Nur Owner darf editieren.
|
Nur Owner darf editieren.
|
||||||
"""
|
"""
|
||||||
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)
|
||||||
|
|
@ -903,14 +902,14 @@ def update_exercise(
|
||||||
@router.delete("/exercises/{exercise_id}")
|
@router.delete("/exercises/{exercise_id}")
|
||||||
def delete_exercise(
|
def delete_exercise(
|
||||||
exercise_id: int,
|
exercise_id: int,
|
||||||
session: dict = Depends(require_auth),
|
tenant: TenantContext = Depends(get_tenant_context),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Löscht eine Übung.
|
Löscht eine Übung.
|
||||||
Nur Owner oder Admin darf löschen.
|
Nur Owner oder Admin darf löschen.
|
||||||
"""
|
"""
|
||||||
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)
|
||||||
|
|
@ -922,7 +921,7 @@ def delete_exercise(
|
||||||
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
|
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
|
||||||
|
|
||||||
# Permission Check
|
# 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")
|
raise HTTPException(status_code=403, detail="Nur Ersteller oder Admin darf löschen")
|
||||||
|
|
||||||
# Prüfen ob Übung in Block-Items verwendet wird
|
# Prüfen ob Übung in Block-Items verwendet wird
|
||||||
|
|
@ -952,9 +951,9 @@ def delete_exercise(
|
||||||
def reorder_exercise_variants(
|
def reorder_exercise_variants(
|
||||||
exercise_id: int,
|
exercise_id: int,
|
||||||
body: ExerciseVariantsReorder,
|
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)):
|
if len(body.variant_ids) != len(set(body.variant_ids)):
|
||||||
raise HTTPException(status_code=400, detail="variant_ids dürfen keine Duplikate enthalten")
|
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(
|
def create_exercise_variant(
|
||||||
exercise_id: int,
|
exercise_id: int,
|
||||||
body: ExerciseVariantCreate,
|
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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
@ -1045,9 +1044,9 @@ def update_exercise_variant(
|
||||||
exercise_id: int,
|
exercise_id: int,
|
||||||
variant_id: int,
|
variant_id: int,
|
||||||
body: ExerciseVariantUpdate,
|
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)
|
data = body.dict(exclude_unset=True)
|
||||||
if not data:
|
if not data:
|
||||||
|
|
@ -1118,12 +1117,15 @@ def update_exercise_variant(
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/exercises/{exercise_id}/variants/{variant_id}")
|
||||||
def delete_exercise_variant(
|
def delete_exercise_variant(
|
||||||
exercise_id: int,
|
exercise_id: int,
|
||||||
variant_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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(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)
|
@router.post("/exercises/{exercise_id}/media", status_code=201)
|
||||||
async def upload_exercise_media(
|
async def upload_exercise_media(
|
||||||
exercise_id: int,
|
exercise_id: int,
|
||||||
session: dict = Depends(require_auth),
|
tenant: TenantContext = Depends(get_tenant_context),
|
||||||
file: Optional[UploadFile] = File(None),
|
file: Optional[UploadFile] = File(None),
|
||||||
embed_url: Optional[str] = Form(None),
|
embed_url: Optional[str] = Form(None),
|
||||||
media_type: str = Form(...),
|
media_type: str = Form(...),
|
||||||
|
|
@ -1176,7 +1178,7 @@ async def upload_exercise_media(
|
||||||
context: str = Form("ablauf"),
|
context: str = Form("ablauf"),
|
||||||
is_primary: bool = Form(False),
|
is_primary: bool = Form(False),
|
||||||
):
|
):
|
||||||
profile_id = session["profile_id"]
|
profile_id = tenant.profile_id
|
||||||
if media_type not in ("image", "video", "document", "sketch"):
|
if media_type not in ("image", "video", "document", "sketch"):
|
||||||
raise HTTPException(status_code=400, detail="Ungültiger media_type")
|
raise HTTPException(status_code=400, detail="Ungültiger media_type")
|
||||||
if context not in ("ablauf", "detail", "trainer_hint"):
|
if context not in ("ablauf", "detail", "trainer_hint"):
|
||||||
|
|
@ -1234,7 +1236,7 @@ async def upload_exercise_media(
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raw = await file.read()
|
raw = await file.read()
|
||||||
max_upload = _upload_limit_bytes(session)
|
max_upload = _upload_limit_bytes(tenant)
|
||||||
if len(raw) > max_upload:
|
if len(raw) > max_upload:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=413,
|
status_code=413,
|
||||||
|
|
@ -1289,9 +1291,9 @@ async def upload_exercise_media(
|
||||||
def reorder_exercise_media(
|
def reorder_exercise_media(
|
||||||
exercise_id: int,
|
exercise_id: int,
|
||||||
body: ExerciseMediaReorder,
|
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
|
ids = body.media_ids
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
@ -1320,9 +1322,9 @@ def update_exercise_media(
|
||||||
exercise_id: int,
|
exercise_id: int,
|
||||||
media_id: int,
|
media_id: int,
|
||||||
body: ExerciseMediaUpdate,
|
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)
|
data = body.dict(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")
|
||||||
|
|
@ -1362,9 +1364,9 @@ def update_exercise_media(
|
||||||
def delete_exercise_media(
|
def delete_exercise_media(
|
||||||
exercise_id: int,
|
exercise_id: int,
|
||||||
media_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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
_assert_can_edit_exercise(cur, exercise_id, profile_id)
|
_assert_can_edit_exercise(cur, exercise_id, profile_id)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.23"
|
APP_VERSION = "0.8.24"
|
||||||
BUILD_DATE = "2026-05-05"
|
BUILD_DATE = "2026-05-05"
|
||||||
DB_SCHEMA_VERSION = "20260505041"
|
DB_SCHEMA_VERSION = "20260505041"
|
||||||
|
|
||||||
|
|
@ -15,7 +15,7 @@ MODULE_VERSIONS = {
|
||||||
"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.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_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,13 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.23",
|
||||||
"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.23"
|
export const APP_VERSION = "0.8.24"
|
||||||
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