feat: enhance tenant context integration and update access layer endpoints
Some checks failed
Deploy Development / deploy (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 40s

- Implemented `library_content_visibility_sql` for managing visibility of exercises, training planning, and framework programs based on tenant context.
- Updated access layer documentation to reflect changes in endpoint visibility and governance requirements.
- Bumped application version to 0.8.23 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:46:41 +02:00
parent 4b6fd49940
commit c919e02441
7 changed files with 186 additions and 122 deletions

View File

@ -10,12 +10,19 @@ 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`, Detail | ja | geplant | `visibility` + Mitgliedschaft | |
| training_planning | diverse | ja | geplant | `exercise_visible` / Gruppe | |
| training_framework_programs | diverse | ja | geplant | analog Übungen | |
| 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 |
| 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.
Letzte Änderung: 2026-05-05 (Initial)
Letzte Änderung: 2026-05-05 — Stufe B/C partiell (Bibliothekslisten + Planung); `GET /training-units` ohne automatischen club_id-Filter (Kompatibilität).
---
### Hinweis `GET /training-units`
Kein impliziter Filter nach `effective_club_id` (Multi-Verein-Kalender); bei Bedarf `club_id` Query setzen.

View File

@ -16,7 +16,8 @@ from pydantic import BaseModel, Field, model_validator
from db import get_db, get_cursor, r2d
from auth import require_auth
from club_tenancy import 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
logger = logging.getLogger(__name__)
@ -577,14 +578,14 @@ def list_exercises(
default=False,
description="Wenn true: Feld variants[] pro Übung (id, variant_name, sequence_order) für Planung/UI",
),
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Liste aller Übungen mit Filtern.
Lightweight Response (ohne M:N Details, nur IDs und Namen).
Optional include_variants für Variantenauswahl in der Trainingsplanung.
"""
profile_id = session["profile_id"]
profile_id = tenant.profile_id
with get_db() as conn:
cur = get_cursor(conn)
@ -593,24 +594,16 @@ def list_exercises(
where = ["1=1"]
params = []
# Mandanten-Sichtbarkeit: official / eigene private / club nur eigene Vereine (Plattform-Admin: alles)
role = session.get("role")
role = tenant.global_role
if not is_platform_admin(role):
where.append(
"""(
e.visibility = 'official'
OR (e.visibility = 'private' AND e.created_by = %s)
OR (
e.visibility = 'club'
AND e.club_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM club_members cm
WHERE cm.profile_id = %s AND cm.club_id = e.club_id AND cm.status = 'active'
)
)
)"""
vis_sql, vis_params = library_content_visibility_sql(
alias="e",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
params.extend([profile_id, profile_id])
where.append(vis_sql)
params.extend(vis_params)
vis_list = _merge_str_any(visibility_any, visibility)
if vis_list:
@ -789,12 +782,12 @@ def get_exercise(
@router.post("/exercises", status_code=201)
def create_exercise(
body: ExerciseCreate,
session: dict = Depends(require_auth),
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Erstellt eine neue Übung mit allen M:N Relations.
"""
profile_id = session["profile_id"]
profile_id = tenant.profile_id
# Validierung
if body.status not in ("draft", "in_review", "approved", "archived"):
@ -802,8 +795,15 @@ def create_exercise(
if body.visibility not in ("private", "club", "official"):
raise HTTPException(status_code=400, detail="Ungültige Visibility")
club_id = body.club_id
if body.visibility == "club" and club_id is None:
club_id = tenant.effective_club_id
with get_db() as conn:
cur = get_cursor(conn)
assert_valid_governance_visibility(
cur, profile_id, tenant.global_role, body.visibility, club_id
)
# Equipment als JSONB
equipment_json = json.dumps(body.equipment) if body.equipment else None
@ -822,7 +822,7 @@ def create_exercise(
body.duration_min, body.duration_max,
body.group_size_min, body.group_size_max,
equipment_json,
body.visibility, body.status, profile_id, body.club_id,
body.visibility, body.status, profile_id, club_id,
)
)
row = cur.fetchone()

View File

@ -9,7 +9,6 @@ from typing import Any, Dict, List, Optional, Sequence
from fastapi import APIRouter, Depends, HTTPException
from auth import require_auth
from club_tenancy import (
assert_valid_governance_visibility,
exercise_visible_to_profile,
@ -26,6 +25,8 @@ from routers.training_planning import (
_validate_variant_for_exercise,
)
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
router = APIRouter(prefix="/api", tags=["training_framework_programs"])
_VALID_VISIBILITY = frozenset({"private", "club", "official"})
@ -67,6 +68,14 @@ def _framework_access(cur, framework_id: int, profile_id: int, role: str) -> Dic
return row
def _response_framework_detail(framework_id: int, profile_id: int, role: str) -> Dict[str, Any]:
"""Einzelabruf nach Schreiboperation (ohne FastAPI-Depends-Schleife)."""
with get_db() as conn:
cur = get_cursor(conn)
row = _framework_access(cur, framework_id, profile_id, role)
return _hydrate_framework(cur, row)
def _training_type_ids(cur, framework_id: int) -> List[int]:
cur.execute(
"""
@ -308,9 +317,9 @@ def _insert_slots_and_blueprints(
@router.get("/training-framework-programs")
def list_training_framework_programs(session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def list_training_framework_programs(tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
base_sel = """
@ -344,40 +353,36 @@ def list_training_framework_programs(session=Depends(require_auth)):
if is_platform_admin(role):
cur.execute(base_sel + " ORDER BY fp.updated_at DESC NULLS LAST, fp.title")
else:
vis_clause, vis_params = library_content_visibility_sql(
alias="fp",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
cur.execute(
base_sel
+ """ WHERE (
fp.visibility = 'official'
OR (fp.visibility = 'private' AND fp.created_by = %s)
OR (
fp.visibility = 'club'
AND fp.club_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM club_members cm
WHERE cm.profile_id = %s AND cm.club_id = fp.club_id AND cm.status = 'active'
)
)
)
+ f""" WHERE ({vis_clause})
ORDER BY fp.updated_at DESC NULLS LAST, fp.title""",
(profile_id, profile_id),
vis_params,
)
return [r2d(r) for r in cur.fetchall()]
@router.get("/training-framework-programs/{framework_id}")
def get_training_framework_program(framework_id: int, session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
with get_db() as conn:
cur = get_cursor(conn)
row = _framework_access(cur, framework_id, profile_id, role)
return _hydrate_framework(cur, row)
def get_training_framework_program(
framework_id: int, tenant: TenantContext = Depends(get_tenant_context)
):
profile_id = tenant.profile_id
role = tenant.global_role
return _response_framework_detail(framework_id, profile_id, role)
@router.post("/training-framework-programs")
def create_training_framework_program(data: dict, session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def create_training_framework_program(
data: dict, tenant: TenantContext = Depends(get_tenant_context)
):
profile_id = tenant.profile_id
role = tenant.global_role
if not _has_planning_role(role):
raise HTTPException(status_code=403, detail="Nur Planungsberechtigte dürfen Rahmenprogramme anlegen")
@ -390,6 +395,9 @@ def create_training_framework_program(data: dict, session=Depends(require_auth))
club_id = data.get("club_id")
if club_id in ("", []):
club_id = None
if vis == "club" and club_id is None:
club_id = tenant.effective_club_id
goals_in = data.get("goals")
slots_in = data.get("slots")
if not isinstance(goals_in, list) or not goals_in:
@ -432,13 +440,15 @@ def create_training_framework_program(data: dict, session=Depends(require_auth))
_replace_target_groups(cur, fid, tg_ids)
conn.commit()
return get_training_framework_program(fid, session)
return _response_framework_detail(fid, profile_id, role)
@router.put("/training-framework-programs/{framework_id}")
def update_training_framework_program(framework_id: int, data: dict, session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def update_training_framework_program(
framework_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)
):
profile_id = tenant.profile_id
role = tenant.global_role
if not _has_planning_role(role):
raise HTTPException(status_code=403, detail="Keine Berechtigung")
@ -546,13 +556,15 @@ def update_training_framework_program(framework_id: int, data: dict, session=Dep
conn.commit()
return get_training_framework_program(framework_id, session)
return _response_framework_detail(framework_id, profile_id, role)
@router.delete("/training-framework-programs/{framework_id}")
def delete_training_framework_program(framework_id: int, session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def delete_training_framework_program(
framework_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)
row_fw = _fetch_framework_row(cur, framework_id)

View File

@ -9,7 +9,7 @@ from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from db import get_db, get_cursor, r2d
from auth import require_auth
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
from club_tenancy import (
assert_valid_governance_visibility,
exercise_visible_to_profile,
@ -560,9 +560,9 @@ def _template_access(cur, tid: int, profile_id: int, role: str) -> Dict[str, Any
@router.get("/training-plan-templates")
def list_training_plan_templates(session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def list_training_plan_templates(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 is_platform_admin(role):
@ -576,35 +576,30 @@ def list_training_plan_templates(session=Depends(require_auth)):
"""
)
else:
vis_clause, vis_params = library_content_visibility_sql(
alias="t",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
cur.execute(
"""
f"""
SELECT t.*,
(SELECT COUNT(*) FROM training_plan_template_sections s WHERE s.template_id = t.id)
AS sections_count
FROM training_plan_templates t
WHERE (
t.visibility = 'official'
OR (t.visibility = 'private' AND t.created_by = %s)
OR (
t.visibility = 'club'
AND t.club_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM club_members cm
WHERE cm.profile_id = %s AND cm.club_id = t.club_id AND cm.status = 'active'
)
)
)
WHERE ({vis_clause})
ORDER BY t.updated_at DESC NULLS LAST, t.name
""",
(profile_id, profile_id),
vis_params,
)
return [r2d(r) for r in cur.fetchall()]
@router.get("/training-plan-templates/{template_id}")
def get_training_plan_template(template_id: int, session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def get_training_plan_template(template_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)
row = _template_access(cur, template_id, profile_id, role)
@ -622,9 +617,9 @@ def get_training_plan_template(template_id: int, session=Depends(require_auth)):
@router.post("/training-plan-templates")
def create_training_plan_template(data: dict, session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def create_training_plan_template(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
role = tenant.global_role
if not _has_planning_role(role):
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Vorlagen anlegen")
name = (data.get("name") or "").strip()
@ -635,6 +630,8 @@ def create_training_plan_template(data: dict, session=Depends(require_auth)):
club_id = data.get("club_id")
if club_id in ("", []):
club_id = None
if visibility == "club" and club_id is None:
club_id = tenant.effective_club_id
sections_in = data.get("sections") or []
with get_db() as conn:
@ -662,13 +659,13 @@ def create_training_plan_template(data: dict, session=Depends(require_auth)):
(tid, order_ix, title, sec.get("guidance_text")),
)
conn.commit()
return get_training_plan_template(tid, session)
return get_training_plan_template(tid, tenant)
@router.put("/training-plan-templates/{template_id}")
def update_training_plan_template(template_id: int, data: dict, session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def update_training_plan_template(template_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
row_prev = _fetch_training_plan_template_row(cur, template_id)
@ -731,13 +728,13 @@ def update_training_plan_template(template_id: int, data: dict, session=Depends(
(template_id, order_ix, title, sec.get("guidance_text")),
)
conn.commit()
return get_training_plan_template(template_id, session)
return get_training_plan_template(template_id, tenant)
@router.delete("/training-plan-templates/{template_id}")
def delete_training_plan_template(template_id: int, session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def delete_training_plan_template(template_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)
row_del = _fetch_training_plan_template_row(cur, template_id)
@ -760,10 +757,10 @@ def list_training_units(
assigned_to_me: bool = Query(default=False),
sort: str = Query(default="desc"),
limit: Optional[int] = Query(default=None),
session=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
gid = _optional_positive_int(group_id, "group_id") if group_id else None
cid = _optional_positive_int(club_id, "club_id") if club_id else None
@ -873,9 +870,9 @@ def list_training_units(
@router.get("/training-units/{unit_id}")
def get_training_unit(unit_id: int, session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def get_training_unit(unit_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)
@ -937,9 +934,9 @@ def get_training_unit(unit_id: int, session=Depends(require_auth)):
@router.post("/training-units")
def create_training_unit(data: dict, session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
role = tenant.global_role
group_id = data.get("group_id")
planned_date = data.get("planned_date")
@ -996,13 +993,13 @@ def create_training_unit(data: dict, session=Depends(require_auth)):
conn.commit()
return get_training_unit(unit_id, session)
return get_training_unit(unit_id, tenant)
@router.put("/training-units/{unit_id}")
def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
@ -1143,13 +1140,13 @@ def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth)
conn.commit()
return get_training_unit(unit_id, session)
return get_training_unit(unit_id, tenant)
@router.delete("/training-units/{unit_id}")
def delete_training_unit(unit_id: int, session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
def delete_training_unit(unit_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)
@ -1179,10 +1176,10 @@ def delete_training_unit(unit_id: int, session=Depends(require_auth)):
@router.post("/training-units/from-framework-slot")
def create_training_unit_from_framework_slot(data: dict, session=Depends(require_auth)):
def create_training_unit_from_framework_slot(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
"""Geplante Einheit aus Rahmen-Slot-Blueprint kopieren (Lineage über origin_framework_slot_id)."""
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="Nur Planungsberechtigte dürfen Trainingseinheiten erstellen")
@ -1243,12 +1240,12 @@ def create_training_unit_from_framework_slot(data: dict, session=Depends(require
conn.commit()
return get_training_unit(new_id, session)
return get_training_unit(new_id, tenant)
@router.post("/training-units/quick-create")
def quick_create_training_unit(data: dict, session=Depends(require_auth)):
profile_id = session["profile_id"]
def quick_create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
group_id = data.get("group_id")
planned_date = data.get("planned_date")
@ -1274,7 +1271,7 @@ def quick_create_training_unit(data: dict, session=Depends(require_auth)):
if not group:
raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden")
role = session.get("role")
role = tenant.global_role
co_trainers = group["co_trainer_ids"] or []
if not _has_planning_role(role):
@ -1316,5 +1313,5 @@ def quick_create_training_unit(data: dict, session=Depends(require_auth)):
conn.commit()
return get_training_unit(unit_id, session)
return get_training_unit(unit_id, tenant)

View File

@ -37,6 +37,45 @@ def parse_active_club_header(raw: Optional[str]) -> Optional[int]:
return v
def library_content_visibility_sql(
*,
alias: str,
profile_id: int,
role: str,
effective_club_id: Optional[int],
) -> tuple[str, List[Any]]:
"""
WHERE-Baustein für Bibliothekslisten (Übungen, Vorlagen, Rahmenprogramme):
official, eigene private, club nur im aktiven Vereinskontext (effective_club_id).
Plattform-Admin: keine Einschränkung (TRUE).
Ohne effective_club_id: kein club-Zweig (nur official + private).
"""
if is_platform_admin(role):
return "TRUE", []
parts: List[str] = [
f"{alias}.visibility = 'official'",
f"({alias}.visibility = 'private' AND {alias}.created_by = %s)",
]
params: List[Any] = [profile_id]
if effective_club_id is not None:
parts.append(
f"""(
{alias}.visibility = 'club'
AND {alias}.club_id IS NOT NULL
AND {alias}.club_id = %s
AND EXISTS (
SELECT 1 FROM club_members cm
WHERE cm.profile_id = %s AND cm.club_id = {alias}.club_id AND cm.status = 'active'
)
)"""
)
params.extend([effective_club_id, profile_id])
return "(" + " OR ".join(parts) + ")", params
@dataclass
class TenantContext:
profile_id: int

View File

@ -1,12 +1,13 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.22"
APP_VERSION = "0.8.23"
BUILD_DATE = "2026-05-05"
DB_SCHEMA_VERSION = "20260505041"
MODULE_VERSIONS = {
"auth": "1.2.0", # Erster/bootstrap Nutzer und ADMIN_BOOTSTRAP_EMAILS → superadmin (nicht mehr admin)
"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",
@ -14,10 +15,10 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
"exercises": "2.4.0", # Multi-Tenancy: club-Sichtbarkeit nur im eigenen Verein
"exercises": "2.5.0", # list/create: TenantContext + Governance; club-Liste nach aktivem Verein
"training_units": "0.1.0",
"training_programs": "0.1.0",
"planning": "0.7.0", # Vorlagen + Rahmenprogramme: Listen/GET wie Übungen (visibility/club); Governance-Validierung
"planning": "0.8.0", # TenantContext auf allen Planungs-Endpunkten; Vorlagen-Liste wie Übungen nach aktivem Verein
"import_wiki": "1.0.0",
"admin": "1.0.0",
"membership": "1.0.0",
@ -26,6 +27,14 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.23",
"date": "2026-05-05",
"changes": [
"ACCESS_LAYER: library_content_visibility_sql + TenantContext an Übungen-Liste, Rahmenprogramme, Trainingsplanung",
"POST Übung/Vorlage/Rahmenprogramm: visibility club ohne club_id → effective_club_id; POST Übung mit assert_valid_governance_visibility",
],
},
{
"version": "0.8.22",
"date": "2026-05-05",

View File

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