feat: implement multi-tenancy governance and enhance club member management
- Bumped application version to 0.8.17, reflecting updates in both backend and frontend. - Introduced governance visibility for training plan templates and framework programs, allowing access based on visibility settings (private, club, official). - Added API endpoints for managing club members, including listing, adding, updating, and removing members. - Updated changelog to document the new features and changes made in this release.
This commit is contained in:
parent
c7bf7dcd9d
commit
7d476268b8
|
|
@ -172,14 +172,16 @@ Ziel: **vereinszentrierte** Vertrags- und Limitlogik, analog zur bestehenden Tie
|
|||
|
||||
### Phase 3 – Effektive Berechtigungen (RBAC)
|
||||
|
||||
- **Mitgliederverwaltung per API (ohne UI):** `GET/POST /api/clubs/{club_id}/members`, `GET/PUT/DELETE /api/clubs/{club_id}/members/{profile_id}` — nur Plattform-Admin oder `club_admin` im Zielverein (Stand Code **0.8.16**).
|
||||
- Zentrale Modulfunktion z. B. `authorization/club_permissions.py`:
|
||||
`can(club_id, profile_id, permission, division_id=None)`.
|
||||
`can(club_id, profile_id, permission, division_id=None)` — optional später; aktuell `club_tenancy.can_manage_club_org` / `has_club_role`.
|
||||
- Router schrittweise umbinden: Sparten/Gruppen CRUD nach Rolle `club_admin` im Kontext; Systemadmin unverändert Vollzugriff.
|
||||
|
||||
### Phase 4 – Sichtbarkeit & Leaks schließen
|
||||
|
||||
- **Übungen:** `club`-Sichtbarkeit nur bei Übereinstimmung `exercise.club_id` mit Mitgliedschaft (und später `division`).
|
||||
- Gleiches Muster für `training_plan_templates`, `training_framework_programs`, Progressionsgraphen, ggf. Medien.
|
||||
- **Trainingsplan-Vorlagen** (`training_plan_templates`) und **Rahmenprogramme** (`training_framework_programs`): gleiches Muster für Listen/GET (Stand **0.8.17**); Schreiben weiterhin nur Ersteller oder Plattform-Admin.
|
||||
- Gleiches Muster für Progressionsgraphen, ggf. Medien (offen).
|
||||
- Tests: zwei Vereine, zwei Nutzer, keine Kreuzzugriffe.
|
||||
|
||||
### Phase 5 – Mitgliedschaft / Limits
|
||||
|
|
|
|||
|
|
@ -96,6 +96,30 @@ def memberships_with_roles(cur, profile_id: int) -> List[Dict[str, Any]]:
|
|||
return out
|
||||
|
||||
|
||||
_GOVERNANCE_VISIBILITY = frozenset({"private", "club", "official"})
|
||||
|
||||
|
||||
def assert_valid_governance_visibility(
|
||||
cur,
|
||||
profile_id: int,
|
||||
role: Optional[str],
|
||||
visibility: str,
|
||||
club_id: Optional[int],
|
||||
) -> None:
|
||||
"""Pflicht club_id bei visibility=club + Mitgliedschaft; official nur Plattform-Admin."""
|
||||
if visibility not in _GOVERNANCE_VISIBILITY:
|
||||
raise HTTPException(status_code=400, detail="Ungültige visibility")
|
||||
if visibility == "official" and not is_platform_admin(role):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Nur Plattform-Admins dürfen offizielle Inhalte setzen",
|
||||
)
|
||||
if visibility == "club":
|
||||
if club_id is None:
|
||||
raise HTTPException(status_code=400, detail="club_id ist bei visibility=club erforderlich")
|
||||
assert_club_member(cur, profile_id, club_id)
|
||||
|
||||
|
||||
def exercise_visible_to_profile(
|
||||
cur,
|
||||
profile_id: int,
|
||||
|
|
|
|||
|
|
@ -154,13 +154,14 @@ def read_root():
|
|||
}
|
||||
|
||||
# Register routers
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(profiles.router)
|
||||
app.include_router(exercises.router)
|
||||
app.include_router(exercise_progression_graphs.router)
|
||||
app.include_router(clubs.router)
|
||||
app.include_router(club_memberships.router)
|
||||
app.include_router(skills.router)
|
||||
app.include_router(training_planning.router)
|
||||
app.include_router(training_framework_programs.router)
|
||||
|
|
|
|||
268
backend/routers/club_memberships.py
Normal file
268
backend/routers/club_memberships.py
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
"""
|
||||
Vereins-Mitgliedschaften und Rollen (ohne UI nutzbar für Admin/Automatisierung).
|
||||
|
||||
Berechtigung: Plattform-Admin (admin/superadmin) oder Vereinsadmin (club_admin) im Zielverein.
|
||||
"""
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from auth import require_auth
|
||||
from club_tenancy import can_manage_club_org
|
||||
from db import get_db, get_cursor, r2d
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["club_memberships"])
|
||||
|
||||
_ALLOWED_ROLES = frozenset({"club_admin", "trainer", "division_lead", "content_editor"})
|
||||
_ALLOWED_STATUS = frozenset({"active", "inactive"})
|
||||
|
||||
|
||||
def _normalize_roles(raw: List[str]) -> List[str]:
|
||||
out: List[str] = []
|
||||
seen = set()
|
||||
for r in raw:
|
||||
if not isinstance(r, str):
|
||||
raise HTTPException(status_code=400, detail="Rollen müssen Strings sein")
|
||||
code = r.strip().lower()
|
||||
if not code:
|
||||
continue
|
||||
if code not in _ALLOWED_ROLES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unbekannte Rolle: {code}. Erlaubt: {', '.join(sorted(_ALLOWED_ROLES))}",
|
||||
)
|
||||
if code not in seen:
|
||||
seen.add(code)
|
||||
out.append(code)
|
||||
return out
|
||||
|
||||
|
||||
def _assert_manage(cur, session: dict, club_id: int) -> None:
|
||||
pid = session["profile_id"]
|
||||
role = session.get("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")
|
||||
|
||||
|
||||
def _club_exists(cur, club_id: int) -> bool:
|
||||
cur.execute("SELECT 1 FROM clubs WHERE id = %s", (club_id,))
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
class ClubMemberUpsert(BaseModel):
|
||||
profile_id: int = Field(..., ge=1)
|
||||
roles: List[str] = Field(default_factory=list, description="Mindestens eine Vereinsrolle")
|
||||
|
||||
|
||||
class ClubMemberPatch(BaseModel):
|
||||
roles: Optional[List[str]] = None
|
||||
status: Optional[str] = Field(None, description="active oder inactive")
|
||||
|
||||
|
||||
@router.get("/clubs/{club_id}/members")
|
||||
def list_club_members(
|
||||
club_id: int,
|
||||
include_inactive: bool = Query(False),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
"""Alle Mitglieder eines Vereins mit Rollen (nur Vereins-/Plattform-Admin)."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
if not _club_exists(cur, club_id):
|
||||
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
|
||||
_assert_manage(cur, session, club_id)
|
||||
|
||||
status_clause = "" if include_inactive else "AND cm.status = 'active'"
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT cm.id AS membership_id, cm.profile_id, cm.status, cm.created_at, cm.updated_at,
|
||||
p.email, p.name,
|
||||
COALESCE(
|
||||
ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL),
|
||||
ARRAY[]::varchar[]
|
||||
) AS roles
|
||||
FROM club_members cm
|
||||
INNER JOIN profiles p ON p.id = cm.profile_id
|
||||
LEFT JOIN club_member_roles r ON r.club_member_id = cm.id
|
||||
WHERE cm.club_id = %s {status_clause}
|
||||
GROUP BY cm.id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name
|
||||
ORDER BY p.name NULLS LAST, p.email
|
||||
""",
|
||||
(club_id,),
|
||||
)
|
||||
rows = []
|
||||
for row in cur.fetchall():
|
||||
d = r2d(row)
|
||||
roles = d.get("roles") or []
|
||||
if hasattr(roles, "tolist"):
|
||||
roles = roles.tolist()
|
||||
d["roles"] = list(roles)
|
||||
rows.append(d)
|
||||
return rows
|
||||
|
||||
|
||||
@router.post("/clubs/{club_id}/members", status_code=201)
|
||||
def upsert_club_member(
|
||||
club_id: int,
|
||||
body: ClubMemberUpsert,
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
"""Mitglied anlegen oder aktivieren; Rollen werden vollständig ersetzt."""
|
||||
roles = _normalize_roles(body.roles)
|
||||
if not roles:
|
||||
raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
if not _club_exists(cur, club_id):
|
||||
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
|
||||
_assert_manage(cur, session, club_id)
|
||||
|
||||
cur.execute("SELECT id FROM profiles WHERE id = %s", (body.profile_id,))
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO club_members (profile_id, club_id, status)
|
||||
VALUES (%s, %s, 'active')
|
||||
ON CONFLICT (profile_id, club_id)
|
||||
DO UPDATE SET status = 'active', updated_at = NOW()
|
||||
RETURNING id
|
||||
""",
|
||||
(body.profile_id, club_id),
|
||||
)
|
||||
cm_id = cur.fetchone()["id"]
|
||||
|
||||
cur.execute("DELETE FROM club_member_roles WHERE club_member_id = %s", (cm_id,))
|
||||
for rc in roles:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO club_member_roles (club_member_id, role_code)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (club_member_id, role_code) DO NOTHING
|
||||
""",
|
||||
(cm_id, rc),
|
||||
)
|
||||
conn.commit()
|
||||
return _one_member(cur, club_id, body.profile_id)
|
||||
|
||||
|
||||
def _one_member(cur, club_id: int, profile_id: int) -> Dict[str, Any]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT cm.id AS membership_id, cm.profile_id, cm.status, cm.created_at, cm.updated_at,
|
||||
p.email, p.name,
|
||||
COALESCE(
|
||||
ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL),
|
||||
ARRAY[]::varchar[]
|
||||
) AS roles
|
||||
FROM club_members cm
|
||||
INNER JOIN profiles p ON p.id = cm.profile_id
|
||||
LEFT JOIN club_member_roles r ON r.club_member_id = cm.id
|
||||
WHERE cm.club_id = %s AND cm.profile_id = %s
|
||||
GROUP BY cm.id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name
|
||||
""",
|
||||
(club_id, profile_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Mitgliedschaft nicht gefunden")
|
||||
d = r2d(row)
|
||||
roles = d.get("roles") or []
|
||||
if hasattr(roles, "tolist"):
|
||||
roles = roles.tolist()
|
||||
d["roles"] = list(roles)
|
||||
return d
|
||||
|
||||
|
||||
@router.get("/clubs/{club_id}/members/{profile_id}")
|
||||
def get_club_member(
|
||||
club_id: int,
|
||||
profile_id: int,
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
if not _club_exists(cur, club_id):
|
||||
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
|
||||
_assert_manage(cur, session, club_id)
|
||||
return _one_member(cur, club_id, profile_id)
|
||||
|
||||
|
||||
@router.put("/clubs/{club_id}/members/{profile_id}")
|
||||
def update_club_member(
|
||||
club_id: int,
|
||||
profile_id: int,
|
||||
body: ClubMemberPatch,
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
"""Rollen ersetzen und/oder Status setzen."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
if not _club_exists(cur, club_id):
|
||||
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
|
||||
_assert_manage(cur, session, club_id)
|
||||
|
||||
cur.execute(
|
||||
"SELECT id FROM club_members WHERE club_id = %s AND profile_id = %s",
|
||||
(club_id, profile_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Mitgliedschaft nicht gefunden")
|
||||
cm_id = row["id"]
|
||||
|
||||
if body.roles is None and body.status is None:
|
||||
return _one_member(cur, club_id, profile_id)
|
||||
|
||||
if body.status is not None:
|
||||
st = body.status.strip().lower()
|
||||
if st not in _ALLOWED_STATUS:
|
||||
raise HTTPException(status_code=400, detail="status muss active oder inactive sein")
|
||||
cur.execute(
|
||||
"UPDATE club_members SET status = %s, updated_at = NOW() WHERE id = %s",
|
||||
(st, cm_id),
|
||||
)
|
||||
|
||||
if body.roles is not None:
|
||||
roles = _normalize_roles(body.roles)
|
||||
if not roles:
|
||||
raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben")
|
||||
cur.execute("DELETE FROM club_member_roles WHERE club_member_id = %s", (cm_id,))
|
||||
for rc in roles:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO club_member_roles (club_member_id, role_code)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (club_member_id, role_code) DO NOTHING
|
||||
""",
|
||||
(cm_id, rc),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return _one_member(cur, club_id, profile_id)
|
||||
|
||||
|
||||
@router.delete("/clubs/{club_id}/members/{profile_id}")
|
||||
def delete_club_member(
|
||||
club_id: int,
|
||||
profile_id: int,
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
"""Mitgliedschaft löschen (Rollen per CASCADE)."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
if not _club_exists(cur, club_id):
|
||||
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
|
||||
_assert_manage(cur, session, club_id)
|
||||
|
||||
cur.execute(
|
||||
"DELETE FROM club_members WHERE club_id = %s AND profile_id = %s RETURNING id",
|
||||
(club_id, profile_id),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Mitgliedschaft nicht gefunden")
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
|
@ -3,13 +3,18 @@ Trainingsrahmenprogramm — wiederverwendbare Vorlage (Bibliothek) über mehrere
|
|||
|
||||
Zuordnung zu Trainingsgruppen / konkreten Einheiten erfolgt aus der Planung (Kopie + Lineage),
|
||||
nicht über group_id oder training_unit_id am Rahmen.
|
||||
AuthZ wie Planungs-Vorlagen: Admin sieht alle, sonst nur eigene Artefakte; Schreibzugriff mit Planungsrolle.
|
||||
Lesen wie Übungen (official / private / club); Schreiben nur Ersteller oder Plattform-Admin.
|
||||
"""
|
||||
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,
|
||||
is_platform_admin,
|
||||
)
|
||||
from db import get_db, get_cursor, r2d
|
||||
|
||||
from routers.training_planning import (
|
||||
|
|
@ -25,16 +30,40 @@ router = APIRouter(prefix="/api", tags=["training_framework_programs"])
|
|||
_VALID_VISIBILITY = frozenset({"private", "club", "official"})
|
||||
|
||||
|
||||
def _framework_access(cur, framework_id: int, profile_id: int, role: str) -> Dict[str, Any]:
|
||||
def _fetch_framework_row(cur, framework_id: int) -> Dict[str, Any]:
|
||||
cur.execute("SELECT * FROM training_framework_programs WHERE id = %s", (framework_id,))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(status_code=404, detail="Trainingsrahmen nicht gefunden")
|
||||
row = r2d(r)
|
||||
if role in ("admin", "superadmin"):
|
||||
return row
|
||||
return r2d(r)
|
||||
|
||||
|
||||
def _framework_assert_readable(
|
||||
cur, row: Dict[str, Any], profile_id: int, role: Optional[str]
|
||||
) -> None:
|
||||
if is_platform_admin(role):
|
||||
return
|
||||
if not exercise_visible_to_profile(
|
||||
cur,
|
||||
profile_id,
|
||||
row.get("visibility") or "private",
|
||||
row.get("club_id"),
|
||||
row.get("created_by"),
|
||||
role,
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Trainingsrahmen")
|
||||
|
||||
|
||||
def _framework_assert_writable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None:
|
||||
if is_platform_admin(role):
|
||||
return
|
||||
if row.get("created_by") != profile_id:
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Trainingsrahmen")
|
||||
|
||||
|
||||
def _framework_access(cur, framework_id: int, profile_id: int, role: str) -> Dict[str, Any]:
|
||||
row = _fetch_framework_row(cur, framework_id)
|
||||
_framework_assert_readable(cur, row, profile_id, role)
|
||||
return row
|
||||
|
||||
|
||||
|
|
@ -312,12 +341,25 @@ def list_training_framework_programs(session=Depends(require_auth)):
|
|||
LEFT JOIN focus_areas fa ON fa.id = fp.focus_area_id
|
||||
LEFT JOIN style_directions sd ON sd.id = fp.style_direction_id
|
||||
"""
|
||||
if role in ("admin", "superadmin"):
|
||||
if is_platform_admin(role):
|
||||
cur.execute(base_sel + " ORDER BY fp.updated_at DESC NULLS LAST, fp.title")
|
||||
else:
|
||||
cur.execute(
|
||||
base_sel + " WHERE fp.created_by = %s ORDER BY fp.updated_at DESC NULLS LAST, fp.title",
|
||||
(profile_id,),
|
||||
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'
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY fp.updated_at DESC NULLS LAST, fp.title""",
|
||||
(profile_id, profile_id),
|
||||
)
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
|
@ -346,6 +388,8 @@ def create_training_framework_program(data: dict, session=Depends(require_auth))
|
|||
vis = data.get("visibility") or "private"
|
||||
vis = _assert_visibility(vis)
|
||||
club_id = data.get("club_id")
|
||||
if club_id in ("", []):
|
||||
club_id = None
|
||||
goals_in = data.get("goals")
|
||||
slots_in = data.get("slots")
|
||||
if not isinstance(goals_in, list) or not goals_in:
|
||||
|
|
@ -358,6 +402,7 @@ def create_training_framework_program(data: dict, session=Depends(require_auth))
|
|||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
assert_valid_governance_visibility(cur, profile_id, role, vis, club_id)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_framework_programs (
|
||||
|
|
@ -399,7 +444,22 @@ def update_training_framework_program(framework_id: int, data: dict, session=Dep
|
|||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
_framework_access(cur, framework_id, profile_id, role)
|
||||
row_prev = _fetch_framework_row(cur, framework_id)
|
||||
_framework_assert_writable(cur, row_prev, profile_id, role)
|
||||
|
||||
merged_vis = row_prev.get("visibility") or "private"
|
||||
merged_club = row_prev.get("club_id")
|
||||
if "visibility" in data:
|
||||
v_m = _assert_visibility(data.get("visibility"))
|
||||
if v_m is None:
|
||||
raise HTTPException(status_code=400, detail="visibility fehlt")
|
||||
merged_vis = v_m
|
||||
if "club_id" in data:
|
||||
merged_club = data.get("club_id")
|
||||
if merged_club in ("", []):
|
||||
merged_club = None
|
||||
if "visibility" in data or "club_id" in data:
|
||||
assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club)
|
||||
|
||||
header_fields = []
|
||||
header_params: List[Any] = []
|
||||
|
|
@ -422,14 +482,11 @@ def update_training_framework_program(framework_id: int, data: dict, session=Dep
|
|||
header_params.append(data.get("planned_period_end"))
|
||||
|
||||
if "visibility" in data:
|
||||
v = _assert_visibility(data.get("visibility"))
|
||||
if v is None:
|
||||
raise HTTPException(status_code=400, detail="visibility fehlt")
|
||||
header_fields.append("visibility = %s")
|
||||
header_params.append(v)
|
||||
header_params.append(merged_vis)
|
||||
if "club_id" in data:
|
||||
header_fields.append("club_id = %s")
|
||||
header_params.append(data.get("club_id"))
|
||||
header_params.append(merged_club)
|
||||
|
||||
if "focus_area_id" in data:
|
||||
fidv = data.get("focus_area_id")
|
||||
|
|
@ -498,7 +555,8 @@ def delete_training_framework_program(framework_id: int, session=Depends(require
|
|||
role = session.get("role")
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
_framework_access(cur, framework_id, profile_id, role)
|
||||
row_fw = _fetch_framework_row(cur, framework_id)
|
||||
_framework_assert_writable(cur, row_fw, profile_id, role)
|
||||
cur.execute(
|
||||
"DELETE FROM training_framework_programs WHERE id = %s",
|
||||
(framework_id,),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
Training Planning – Trainingseinheiten, strukturierter Ablauf (Sektionen + Übungen/Notizen)
|
||||
und wiederverwendbare Trainingsvorlagen (Sektions-Gliederung).
|
||||
|
||||
Governance (Vorlagen-rechte über Vereine/„offiziell“) kann später nachgezogen werden.
|
||||
Governance: Sichtbarkeit wie Übungen (private / club / official); Schreiben nur Ersteller oder Plattform-Admin.
|
||||
"""
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
|
@ -10,6 +10,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
|||
|
||||
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,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["training_planning"])
|
||||
|
||||
|
|
@ -515,23 +520,39 @@ def _instantiate_from_template(cur, unit_id: int, template_id: int):
|
|||
)
|
||||
|
||||
|
||||
def _template_access(cur, tid: int, profile_id: int, role: str) -> Dict[str, Any]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM training_plan_templates
|
||||
WHERE id = %s
|
||||
""",
|
||||
(tid,),
|
||||
)
|
||||
def _fetch_training_plan_template_row(cur, tid: int) -> Dict[str, Any]:
|
||||
cur.execute("SELECT * FROM training_plan_templates WHERE id = %s", (tid,))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(status_code=404, detail="Trainingsvorlage nicht gefunden")
|
||||
row = r2d(r)
|
||||
if role in ["admin", "superadmin"]:
|
||||
return row
|
||||
if row["created_by"] != profile_id:
|
||||
return r2d(r)
|
||||
|
||||
|
||||
def _template_assert_readable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None:
|
||||
if is_platform_admin(role):
|
||||
return
|
||||
if not exercise_visible_to_profile(
|
||||
cur,
|
||||
profile_id,
|
||||
row.get("visibility") or "club",
|
||||
row.get("club_id"),
|
||||
row.get("created_by"),
|
||||
role,
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Vorlage")
|
||||
|
||||
|
||||
def _template_assert_writable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None:
|
||||
if is_platform_admin(role):
|
||||
return
|
||||
if row.get("created_by") != profile_id:
|
||||
raise HTTPException(status_code=403, detail="Nur der Ersteller darf diese Vorlage ändern")
|
||||
|
||||
|
||||
def _template_access(cur, tid: int, profile_id: int, role: str) -> Dict[str, Any]:
|
||||
"""Lesender Zugriff (Liste der Vorlage für Einheit); Schreiben: _template_assert_writable."""
|
||||
row = _fetch_training_plan_template_row(cur, tid)
|
||||
_template_assert_readable(cur, row, profile_id, role)
|
||||
return row
|
||||
|
||||
|
||||
|
|
@ -544,7 +565,7 @@ def list_training_plan_templates(session=Depends(require_auth)):
|
|||
role = session.get("role")
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
if role in ["admin", "superadmin"]:
|
||||
if is_platform_admin(role):
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT t.*,
|
||||
|
|
@ -561,10 +582,21 @@ def list_training_plan_templates(session=Depends(require_auth)):
|
|||
(SELECT COUNT(*) FROM training_plan_template_sections s WHERE s.template_id = t.id)
|
||||
AS sections_count
|
||||
FROM training_plan_templates t
|
||||
WHERE t.created_by = %s
|
||||
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'
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY t.updated_at DESC NULLS LAST, t.name
|
||||
""",
|
||||
(profile_id,),
|
||||
(profile_id, profile_id),
|
||||
)
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
|
@ -598,18 +630,23 @@ def create_training_plan_template(data: dict, session=Depends(require_auth)):
|
|||
name = (data.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name ist Pflicht")
|
||||
vis_raw = data.get("visibility")
|
||||
visibility = (vis_raw if isinstance(vis_raw, str) else "club").strip() or "club"
|
||||
club_id = data.get("club_id")
|
||||
if club_id in ("", []):
|
||||
club_id = None
|
||||
sections_in = data.get("sections") or []
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
assert_valid_governance_visibility(cur, profile_id, role, visibility, club_id)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_plan_templates (club_id, created_by, name, description)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
INSERT INTO training_plan_templates (club_id, created_by, name, description, visibility)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(club_id, profile_id, name, data.get("description")),
|
||||
(club_id, profile_id, name, data.get("description"), visibility),
|
||||
)
|
||||
tid = cur.fetchone()["id"]
|
||||
for si, sec in enumerate(sections_in):
|
||||
|
|
@ -634,7 +671,21 @@ def update_training_plan_template(template_id: int, data: dict, session=Depends(
|
|||
role = session.get("role")
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
_template_access(cur, template_id, profile_id, role)
|
||||
row_prev = _fetch_training_plan_template_row(cur, template_id)
|
||||
_template_assert_writable(cur, row_prev, profile_id, role)
|
||||
merged_vis = row_prev.get("visibility") or "club"
|
||||
merged_club = row_prev.get("club_id")
|
||||
if "visibility" in data:
|
||||
v_in = data.get("visibility")
|
||||
if not isinstance(v_in, str) or v_in not in ("private", "club", "official"):
|
||||
raise HTTPException(status_code=400, detail="visibility ungültig")
|
||||
merged_vis = v_in
|
||||
if "club_id" in data:
|
||||
merged_club = data.get("club_id")
|
||||
if merged_club in ("", []):
|
||||
merged_club = None
|
||||
if "visibility" in data or "club_id" in data:
|
||||
assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club)
|
||||
fields = []
|
||||
params: List[Any] = []
|
||||
if "name" in data:
|
||||
|
|
@ -649,7 +700,10 @@ def update_training_plan_template(template_id: int, data: dict, session=Depends(
|
|||
params.append(data.get("description"))
|
||||
if "club_id" in data:
|
||||
fields.append("club_id = %s")
|
||||
params.append(data.get("club_id"))
|
||||
params.append(merged_club)
|
||||
if "visibility" in data:
|
||||
fields.append("visibility = %s")
|
||||
params.append(merged_vis)
|
||||
fields.append("updated_at = NOW()")
|
||||
params.append(template_id)
|
||||
cur.execute(
|
||||
|
|
@ -686,7 +740,8 @@ def delete_training_plan_template(template_id: int, session=Depends(require_auth
|
|||
role = session.get("role")
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
_template_access(cur, template_id, profile_id, role)
|
||||
row_del = _fetch_training_plan_template_row(cur, template_id)
|
||||
_template_assert_writable(cur, row_del, profile_id, role)
|
||||
cur.execute("DELETE FROM training_plan_templates WHERE id = %s", (template_id,))
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,21 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.15"
|
||||
APP_VERSION = "0.8.17"
|
||||
BUILD_DATE = "2026-05-05"
|
||||
DB_SCHEMA_VERSION = "20260505039"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"auth": "1.0.0",
|
||||
"profiles": "1.1.0", # /profiles/me: clubs[], pin_hash ausgeblendet, active_club_id
|
||||
"clubs": "0.2.0",
|
||||
"clubs": "0.3.0",
|
||||
"club_memberships": "1.0.0",
|
||||
"groups": "0.1.0",
|
||||
"skills": "0.1.0",
|
||||
"methods": "0.1.0",
|
||||
"exercises": "2.4.0", # Multi-Tenancy: club-Sichtbarkeit nur im eigenen Verein
|
||||
"training_units": "0.1.0",
|
||||
"training_programs": "0.1.0",
|
||||
"planning": "0.6.0",
|
||||
"planning": "0.7.0", # Vorlagen + Rahmenprogramme: Listen/GET wie Übungen (visibility/club); Governance-Validierung
|
||||
"import_wiki": "1.0.0",
|
||||
"admin": "1.0.0",
|
||||
"membership": "1.0.0",
|
||||
|
|
@ -23,6 +24,20 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.17",
|
||||
"date": "2026-05-05",
|
||||
"changes": [
|
||||
"Multi-Tenancy Phase 4: training_plan_templates + training_framework_programs Listen und Lesen nach visibility/club wie Übungen; Schreiben nur Ersteller oder Plattform-Admin; club_tenancy.assert_valid_governance_visibility",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.16",
|
||||
"date": "2026-05-05",
|
||||
"changes": [
|
||||
"API Vereinsmitglieder: GET/POST/GET-one/PUT/DELETE /api/clubs/{id}/members (Plattform- oder Vereinsadmin); Frontend api.js Hooks",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.15",
|
||||
"date": "2026-05-05",
|
||||
|
|
|
|||
|
|
@ -174,6 +174,34 @@ export async function deleteClub(id) {
|
|||
return request(`/api/clubs/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Vereinsmitglieder (API für Admin ohne eigene UI) */
|
||||
export async function listClubMembers(clubId, { includeInactive = false } = {}) {
|
||||
const q = includeInactive ? '?include_inactive=true' : ''
|
||||
return request(`/api/clubs/${clubId}/members${q}`)
|
||||
}
|
||||
|
||||
export async function getClubMember(clubId, profileId) {
|
||||
return request(`/api/clubs/${clubId}/members/${profileId}`)
|
||||
}
|
||||
|
||||
export async function addClubMember(clubId, payload) {
|
||||
return request(`/api/clubs/${clubId}/members`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateClubMember(clubId, profileId, payload) {
|
||||
return request(`/api/clubs/${clubId}/members/${profileId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function removeClubMember(clubId, profileId) {
|
||||
return request(`/api/clubs/${clubId}/members/${profileId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function listDivisions(clubId) {
|
||||
const query = clubId ? `?club_id=${clubId}` : ''
|
||||
return request(`/api/divisions${query}`)
|
||||
|
|
@ -1036,6 +1064,11 @@ export const api = {
|
|||
createClub,
|
||||
updateClub,
|
||||
deleteClub,
|
||||
listClubMembers,
|
||||
getClubMember,
|
||||
addClubMember,
|
||||
updateClubMember,
|
||||
removeClubMember,
|
||||
listDivisions,
|
||||
createDivision,
|
||||
updateDivision,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Shinkan Jinkendo Frontend Version
|
||||
|
||||
export const APP_VERSION = "0.8.15"
|
||||
export const APP_VERSION = "0.8.17"
|
||||
export const BUILD_DATE = "2026-05-05"
|
||||
|
||||
export const PAGE_VERSIONS = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user