feat: enhance tenant context integration and update access layer endpoints
- 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:
parent
4b6fd49940
commit
c919e02441
|
|
@ -10,12 +10,19 @@ 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`, Detail | ja | geplant | `visibility` + Mitgliedschaft | |
|
| exercises | `GET /api/exercises`, `POST /api/exercises` | ja | `get_tenant_context` | ja | Liste club nach aktivem Verein; POST Governance + Default club_id |
|
||||||
| training_planning | diverse | ja | geplant | `exercise_visible` / Gruppe | |
|
| exercises | Detail/PATCH (Übriges) | teils | `require_auth` | Owner/Admin | später Tenant optional |
|
||||||
| training_framework_programs | diverse | ja | geplant | analog Übungen | |
|
| 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 | |
|
| 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:** „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.
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@ 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 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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -577,14 +578,14 @@ def list_exercises(
|
||||||
default=False,
|
default=False,
|
||||||
description="Wenn true: Feld variants[] pro Übung (id, variant_name, sequence_order) für Planung/UI",
|
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.
|
Liste aller Übungen mit Filtern.
|
||||||
Lightweight Response (ohne M:N Details, nur IDs und Namen).
|
Lightweight Response (ohne M:N Details, nur IDs und Namen).
|
||||||
Optional include_variants für Variantenauswahl in der Trainingsplanung.
|
Optional include_variants für Variantenauswahl in der Trainingsplanung.
|
||||||
"""
|
"""
|
||||||
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)
|
||||||
|
|
@ -593,24 +594,16 @@ def list_exercises(
|
||||||
where = ["1=1"]
|
where = ["1=1"]
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
# Mandanten-Sichtbarkeit: official / eigene private / club nur eigene Vereine (Plattform-Admin: alles)
|
role = tenant.global_role
|
||||||
role = session.get("role")
|
|
||||||
if not is_platform_admin(role):
|
if not is_platform_admin(role):
|
||||||
where.append(
|
vis_sql, vis_params = library_content_visibility_sql(
|
||||||
"""(
|
alias="e",
|
||||||
e.visibility = 'official'
|
profile_id=profile_id,
|
||||||
OR (e.visibility = 'private' AND e.created_by = %s)
|
role=role,
|
||||||
OR (
|
effective_club_id=tenant.effective_club_id,
|
||||||
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'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)"""
|
|
||||||
)
|
)
|
||||||
params.extend([profile_id, profile_id])
|
where.append(vis_sql)
|
||||||
|
params.extend(vis_params)
|
||||||
|
|
||||||
vis_list = _merge_str_any(visibility_any, visibility)
|
vis_list = _merge_str_any(visibility_any, visibility)
|
||||||
if vis_list:
|
if vis_list:
|
||||||
|
|
@ -789,12 +782,12 @@ def get_exercise(
|
||||||
@router.post("/exercises", status_code=201)
|
@router.post("/exercises", status_code=201)
|
||||||
def create_exercise(
|
def create_exercise(
|
||||||
body: ExerciseCreate,
|
body: ExerciseCreate,
|
||||||
session: dict = Depends(require_auth),
|
tenant: TenantContext = Depends(get_tenant_context),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Erstellt eine neue Übung mit allen M:N Relations.
|
Erstellt eine neue Übung mit allen M:N Relations.
|
||||||
"""
|
"""
|
||||||
profile_id = session["profile_id"]
|
profile_id = tenant.profile_id
|
||||||
|
|
||||||
# Validierung
|
# Validierung
|
||||||
if body.status not in ("draft", "in_review", "approved", "archived"):
|
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"):
|
if body.visibility not in ("private", "club", "official"):
|
||||||
raise HTTPException(status_code=400, detail="Ungültige Visibility")
|
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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
assert_valid_governance_visibility(
|
||||||
|
cur, profile_id, tenant.global_role, body.visibility, club_id
|
||||||
|
)
|
||||||
|
|
||||||
# Equipment als JSONB
|
# Equipment als JSONB
|
||||||
equipment_json = json.dumps(body.equipment) if body.equipment else None
|
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.duration_min, body.duration_max,
|
||||||
body.group_size_min, body.group_size_max,
|
body.group_size_min, body.group_size_max,
|
||||||
equipment_json,
|
equipment_json,
|
||||||
body.visibility, body.status, profile_id, body.club_id,
|
body.visibility, body.status, profile_id, club_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ from typing import Any, Dict, List, Optional, Sequence
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from auth import require_auth
|
|
||||||
from club_tenancy import (
|
from club_tenancy import (
|
||||||
assert_valid_governance_visibility,
|
assert_valid_governance_visibility,
|
||||||
exercise_visible_to_profile,
|
exercise_visible_to_profile,
|
||||||
|
|
@ -26,6 +25,8 @@ from routers.training_planning import (
|
||||||
_validate_variant_for_exercise,
|
_validate_variant_for_exercise,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["training_framework_programs"])
|
router = APIRouter(prefix="/api", tags=["training_framework_programs"])
|
||||||
_VALID_VISIBILITY = frozenset({"private", "club", "official"})
|
_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
|
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]:
|
def _training_type_ids(cur, framework_id: int) -> List[int]:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
|
|
@ -308,9 +317,9 @@ def _insert_slots_and_blueprints(
|
||||||
|
|
||||||
|
|
||||||
@router.get("/training-framework-programs")
|
@router.get("/training-framework-programs")
|
||||||
def list_training_framework_programs(session=Depends(require_auth)):
|
def list_training_framework_programs(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)
|
||||||
base_sel = """
|
base_sel = """
|
||||||
|
|
@ -344,40 +353,36 @@ def list_training_framework_programs(session=Depends(require_auth)):
|
||||||
if is_platform_admin(role):
|
if is_platform_admin(role):
|
||||||
cur.execute(base_sel + " ORDER BY fp.updated_at DESC NULLS LAST, fp.title")
|
cur.execute(base_sel + " ORDER BY fp.updated_at DESC NULLS LAST, fp.title")
|
||||||
else:
|
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(
|
cur.execute(
|
||||||
base_sel
|
base_sel
|
||||||
+ """ WHERE (
|
+ f""" WHERE ({vis_clause})
|
||||||
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'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
ORDER BY fp.updated_at DESC NULLS LAST, fp.title""",
|
ORDER BY fp.updated_at DESC NULLS LAST, fp.title""",
|
||||||
(profile_id, profile_id),
|
vis_params,
|
||||||
)
|
)
|
||||||
return [r2d(r) for r in cur.fetchall()]
|
return [r2d(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/training-framework-programs/{framework_id}")
|
@router.get("/training-framework-programs/{framework_id}")
|
||||||
def get_training_framework_program(framework_id: int, session=Depends(require_auth)):
|
def get_training_framework_program(
|
||||||
profile_id = session["profile_id"]
|
framework_id: int, tenant: TenantContext = Depends(get_tenant_context)
|
||||||
role = session.get("role")
|
):
|
||||||
with get_db() as conn:
|
profile_id = tenant.profile_id
|
||||||
cur = get_cursor(conn)
|
role = tenant.global_role
|
||||||
row = _framework_access(cur, framework_id, profile_id, role)
|
return _response_framework_detail(framework_id, profile_id, role)
|
||||||
return _hydrate_framework(cur, row)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/training-framework-programs")
|
@router.post("/training-framework-programs")
|
||||||
def create_training_framework_program(data: dict, session=Depends(require_auth)):
|
def create_training_framework_program(
|
||||||
profile_id = session["profile_id"]
|
data: dict, tenant: TenantContext = Depends(get_tenant_context)
|
||||||
role = session.get("role")
|
):
|
||||||
|
profile_id = tenant.profile_id
|
||||||
|
role = tenant.global_role
|
||||||
if not _has_planning_role(role):
|
if not _has_planning_role(role):
|
||||||
raise HTTPException(status_code=403, detail="Nur Planungsberechtigte dürfen Rahmenprogramme anlegen")
|
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")
|
club_id = data.get("club_id")
|
||||||
if club_id in ("", []):
|
if club_id in ("", []):
|
||||||
club_id = None
|
club_id = None
|
||||||
|
if vis == "club" and club_id is None:
|
||||||
|
club_id = tenant.effective_club_id
|
||||||
|
|
||||||
goals_in = data.get("goals")
|
goals_in = data.get("goals")
|
||||||
slots_in = data.get("slots")
|
slots_in = data.get("slots")
|
||||||
if not isinstance(goals_in, list) or not goals_in:
|
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)
|
_replace_target_groups(cur, fid, tg_ids)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
return get_training_framework_program(fid, session)
|
return _response_framework_detail(fid, profile_id, role)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/training-framework-programs/{framework_id}")
|
@router.put("/training-framework-programs/{framework_id}")
|
||||||
def update_training_framework_program(framework_id: int, data: dict, session=Depends(require_auth)):
|
def update_training_framework_program(
|
||||||
profile_id = session["profile_id"]
|
framework_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)
|
||||||
role = session.get("role")
|
):
|
||||||
|
profile_id = tenant.profile_id
|
||||||
|
role = tenant.global_role
|
||||||
if not _has_planning_role(role):
|
if not _has_planning_role(role):
|
||||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
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()
|
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}")
|
@router.delete("/training-framework-programs/{framework_id}")
|
||||||
def delete_training_framework_program(framework_id: int, session=Depends(require_auth)):
|
def delete_training_framework_program(
|
||||||
profile_id = session["profile_id"]
|
framework_id: int, tenant: TenantContext = Depends(get_tenant_context)
|
||||||
role = session.get("role")
|
):
|
||||||
|
profile_id = tenant.profile_id
|
||||||
|
role = tenant.global_role
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
row_fw = _fetch_framework_row(cur, framework_id)
|
row_fw = _fetch_framework_row(cur, framework_id)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from typing import Any, Dict, List, Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, 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, library_content_visibility_sql
|
||||||
from club_tenancy import (
|
from club_tenancy import (
|
||||||
assert_valid_governance_visibility,
|
assert_valid_governance_visibility,
|
||||||
exercise_visible_to_profile,
|
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")
|
@router.get("/training-plan-templates")
|
||||||
def list_training_plan_templates(session=Depends(require_auth)):
|
def list_training_plan_templates(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 is_platform_admin(role):
|
if is_platform_admin(role):
|
||||||
|
|
@ -576,35 +576,30 @@ def list_training_plan_templates(session=Depends(require_auth)):
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
else:
|
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(
|
cur.execute(
|
||||||
"""
|
f"""
|
||||||
SELECT t.*,
|
SELECT t.*,
|
||||||
(SELECT COUNT(*) FROM training_plan_template_sections s WHERE s.template_id = t.id)
|
(SELECT COUNT(*) FROM training_plan_template_sections s WHERE s.template_id = t.id)
|
||||||
AS sections_count
|
AS sections_count
|
||||||
FROM training_plan_templates t
|
FROM training_plan_templates t
|
||||||
WHERE (
|
WHERE ({vis_clause})
|
||||||
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'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
ORDER BY t.updated_at DESC NULLS LAST, t.name
|
ORDER BY t.updated_at DESC NULLS LAST, t.name
|
||||||
""",
|
""",
|
||||||
(profile_id, profile_id),
|
vis_params,
|
||||||
)
|
)
|
||||||
return [r2d(r) for r in cur.fetchall()]
|
return [r2d(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/training-plan-templates/{template_id}")
|
@router.get("/training-plan-templates/{template_id}")
|
||||||
def get_training_plan_template(template_id: int, session=Depends(require_auth)):
|
def get_training_plan_template(template_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)
|
||||||
row = _template_access(cur, template_id, profile_id, role)
|
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")
|
@router.post("/training-plan-templates")
|
||||||
def create_training_plan_template(data: dict, session=Depends(require_auth)):
|
def create_training_plan_template(data: dict, 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="Nur Trainer dürfen Vorlagen anlegen")
|
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Vorlagen anlegen")
|
||||||
name = (data.get("name") or "").strip()
|
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")
|
club_id = data.get("club_id")
|
||||||
if club_id in ("", []):
|
if club_id in ("", []):
|
||||||
club_id = None
|
club_id = None
|
||||||
|
if visibility == "club" and club_id is None:
|
||||||
|
club_id = tenant.effective_club_id
|
||||||
sections_in = data.get("sections") or []
|
sections_in = data.get("sections") or []
|
||||||
|
|
||||||
with get_db() as conn:
|
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")),
|
(tid, order_ix, title, sec.get("guidance_text")),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return get_training_plan_template(tid, session)
|
return get_training_plan_template(tid, tenant)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/training-plan-templates/{template_id}")
|
@router.put("/training-plan-templates/{template_id}")
|
||||||
def update_training_plan_template(template_id: int, data: dict, session=Depends(require_auth)):
|
def update_training_plan_template(template_id: int, data: dict, 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_prev = _fetch_training_plan_template_row(cur, template_id)
|
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")),
|
(template_id, order_ix, title, sec.get("guidance_text")),
|
||||||
)
|
)
|
||||||
conn.commit()
|
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}")
|
@router.delete("/training-plan-templates/{template_id}")
|
||||||
def delete_training_plan_template(template_id: int, session=Depends(require_auth)):
|
def delete_training_plan_template(template_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)
|
||||||
row_del = _fetch_training_plan_template_row(cur, template_id)
|
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),
|
assigned_to_me: bool = Query(default=False),
|
||||||
sort: str = Query(default="desc"),
|
sort: str = Query(default="desc"),
|
||||||
limit: Optional[int] = Query(default=None),
|
limit: Optional[int] = Query(default=None),
|
||||||
session=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
|
||||||
|
|
||||||
gid = _optional_positive_int(group_id, "group_id") if group_id else None
|
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
|
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}")
|
@router.get("/training-units/{unit_id}")
|
||||||
def get_training_unit(unit_id: int, session=Depends(require_auth)):
|
def get_training_unit(unit_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)
|
||||||
|
|
@ -937,9 +934,9 @@ def get_training_unit(unit_id: int, session=Depends(require_auth)):
|
||||||
|
|
||||||
|
|
||||||
@router.post("/training-units")
|
@router.post("/training-units")
|
||||||
def create_training_unit(data: dict, session=Depends(require_auth)):
|
def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
|
||||||
profile_id = session["profile_id"]
|
profile_id = tenant.profile_id
|
||||||
role = session.get("role")
|
role = tenant.global_role
|
||||||
|
|
||||||
group_id = data.get("group_id")
|
group_id = data.get("group_id")
|
||||||
planned_date = data.get("planned_date")
|
planned_date = data.get("planned_date")
|
||||||
|
|
@ -996,13 +993,13 @@ def create_training_unit(data: dict, session=Depends(require_auth)):
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
return get_training_unit(unit_id, session)
|
return get_training_unit(unit_id, tenant)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/training-units/{unit_id}")
|
@router.put("/training-units/{unit_id}")
|
||||||
def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth)):
|
def update_training_unit(unit_id: int, data: dict, 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)
|
||||||
|
|
@ -1143,13 +1140,13 @@ def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
return get_training_unit(unit_id, session)
|
return get_training_unit(unit_id, tenant)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/training-units/{unit_id}")
|
@router.delete("/training-units/{unit_id}")
|
||||||
def delete_training_unit(unit_id: int, session=Depends(require_auth)):
|
def delete_training_unit(unit_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)
|
||||||
|
|
@ -1179,10 +1176,10 @@ def delete_training_unit(unit_id: int, session=Depends(require_auth)):
|
||||||
|
|
||||||
|
|
||||||
@router.post("/training-units/from-framework-slot")
|
@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)."""
|
"""Geplante Einheit aus Rahmen-Slot-Blueprint kopieren (Lineage über origin_framework_slot_id)."""
|
||||||
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="Nur Planungsberechtigte dürfen Trainingseinheiten erstellen")
|
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()
|
conn.commit()
|
||||||
|
|
||||||
return get_training_unit(new_id, session)
|
return get_training_unit(new_id, tenant)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/training-units/quick-create")
|
@router.post("/training-units/quick-create")
|
||||||
def quick_create_training_unit(data: dict, session=Depends(require_auth)):
|
def quick_create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
|
||||||
profile_id = session["profile_id"]
|
profile_id = tenant.profile_id
|
||||||
|
|
||||||
group_id = data.get("group_id")
|
group_id = data.get("group_id")
|
||||||
planned_date = data.get("planned_date")
|
planned_date = data.get("planned_date")
|
||||||
|
|
@ -1274,7 +1271,7 @@ def quick_create_training_unit(data: dict, session=Depends(require_auth)):
|
||||||
if not group:
|
if not group:
|
||||||
raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden")
|
raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden")
|
||||||
|
|
||||||
role = session.get("role")
|
role = tenant.global_role
|
||||||
co_trainers = group["co_trainer_ids"] or []
|
co_trainers = group["co_trainer_ids"] or []
|
||||||
|
|
||||||
if not _has_planning_role(role):
|
if not _has_planning_role(role):
|
||||||
|
|
@ -1316,5 +1313,5 @@ def quick_create_training_unit(data: dict, session=Depends(require_auth)):
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
return get_training_unit(unit_id, session)
|
return get_training_unit(unit_id, tenant)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,45 @@ def parse_active_club_header(raw: Optional[str]) -> Optional[int]:
|
||||||
return v
|
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
|
@dataclass
|
||||||
class TenantContext:
|
class TenantContext:
|
||||||
profile_id: int
|
profile_id: int
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.22"
|
APP_VERSION = "0.8.23"
|
||||||
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.0", # Erster/bootstrap Nutzer und ADMIN_BOOTSTRAP_EMAILS → superadmin (nicht mehr admin)
|
"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
|
"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
|
"clubs": "0.4.0", # public-directory, members/directory; Vereins-GUI verwendet Endpoints
|
||||||
"club_memberships": "1.0.0",
|
"club_memberships": "1.0.0",
|
||||||
"club_join_requests": "1.0.0",
|
"club_join_requests": "1.0.0",
|
||||||
|
|
@ -14,10 +15,10 @@ 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.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_units": "0.1.0",
|
||||||
"training_programs": "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",
|
"import_wiki": "1.0.0",
|
||||||
"admin": "1.0.0",
|
"admin": "1.0.0",
|
||||||
"membership": "1.0.0",
|
"membership": "1.0.0",
|
||||||
|
|
@ -26,6 +27,14 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.22",
|
||||||
"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.22"
|
export const APP_VERSION = "0.8.23"
|
||||||
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