shinkan-jinkendo/backend/routers/clubs.py

721 lines
24 KiB
Python
Raw Normal View History

"""
Club & Organization Management Endpoints for Shinkan Jinkendo
Handles CRUD operations for clubs, divisions, and training groups.
"""
2026-05-05 16:12:17 +02:00
from typing import Any, List, Optional
from fastapi import APIRouter, HTTPException, Depends, Query
from db import get_db, get_cursor, r2d
from auth import require_auth
2026-05-05 16:12:17 +02:00
from club_tenancy import (
assert_club_member,
can_manage_club_org,
can_plan_in_club,
club_ids_for_profile,
is_platform_admin,
)
router = APIRouter(prefix="/api", tags=["clubs"])
# ── List Clubs ────────────────────────────────────────────────────────
@router.get("/clubs")
def list_clubs(
status: Optional[str] = Query(default=None),
2026-05-05 16:12:17 +02:00
session=Depends(require_auth),
):
"""
2026-05-05 16:12:17 +02:00
Vereine: für normale Nutzer nur Mitgliedschaft-Vereine; Plattform-Admins sehen alle.
"""
2026-05-05 16:12:17 +02:00
role = session.get("role")
profile_id = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
query = "SELECT * FROM clubs"
2026-05-05 16:12:17 +02:00
params: List[Any] = []
conds = []
if not is_platform_admin(role):
cids = club_ids_for_profile(cur, profile_id)
if not cids:
return []
conds.append("id IN (" + ",".join(["%s"] * len(cids)) + ")")
params.extend(sorted(cids))
if status:
2026-05-05 16:12:17 +02:00
conds.append("status = %s")
params.append(status)
2026-05-05 16:12:17 +02:00
if conds:
query += " WHERE " + " AND ".join(conds)
query += " ORDER BY name"
cur.execute(query, params)
rows = cur.fetchall()
return [r2d(r) for r in rows]
# ── Öffentliches Vereinsverzeichnis (Registrierung / Antrag ohne Mitgliedschaft) ──
@router.get("/clubs/public-directory")
def public_club_directory():
"""Aktive Vereine zur Auswahl bei Registrierung oder Beitrittsantrag (nur id, name, Kürzel)."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, name, abbreviation
FROM clubs
WHERE status = 'active'
ORDER BY name
"""
)
return [r2d(r) for r in cur.fetchall()]
# ── Aktive Mitglieder für Trainer-/Co-Trainer-Auswahl (jeder Vereinsmitglied) ──
@router.get("/clubs/{club_id}/members/directory")
def club_members_directory(club_id: int, session=Depends(require_auth)):
profile_id = session["profile_id"]
role = session.get("role")
with get_db() as conn:
cur = get_cursor(conn)
if not is_platform_admin(role):
assert_club_member(cur, profile_id, club_id)
cur.execute("SELECT 1 FROM clubs WHERE id = %s", (club_id,))
if not cur.fetchone():
raise HTTPException(404, "Verein nicht gefunden")
cur.execute(
"""
SELECT p.id, p.name, p.email
FROM club_members cm
INNER JOIN profiles p ON p.id = cm.profile_id
WHERE cm.club_id = %s AND cm.status = 'active'
ORDER BY COALESCE(p.name, ''), p.email
""",
(club_id,),
)
return [r2d(r) for r in cur.fetchall()]
# ── Get Club ──────────────────────────────────────────────────────────
@router.get("/clubs/{club_id}")
def get_club(club_id: int, session=Depends(require_auth)):
2026-05-05 16:12:17 +02:00
"""Get club by ID with divisions and groups – nur Mitglied oder Plattform-Admin."""
profile_id = session["profile_id"]
role = session.get("role")
with get_db() as conn:
cur = get_cursor(conn)
2026-05-05 16:12:17 +02:00
if not is_platform_admin(role):
assert_club_member(cur, profile_id, club_id)
# Get club
cur.execute("SELECT * FROM clubs WHERE id = %s", (club_id,))
club = cur.fetchone()
if not club:
raise HTTPException(404, "Verein nicht gefunden")
club = r2d(club)
# Get divisions
2026-05-05 16:12:17 +02:00
cur.execute(
"""
SELECT * FROM divisions
WHERE club_id = %s
ORDER BY name
2026-05-05 16:12:17 +02:00
""",
(club_id,),
)
club["divisions"] = [r2d(r) for r in cur.fetchall()]
# Get training groups
2026-05-05 16:12:17 +02:00
cur.execute(
"""
SELECT g.*,
p.name as trainer_name
FROM training_groups g
LEFT JOIN profiles p ON g.trainer_id = p.id
WHERE g.club_id = %s
ORDER BY g.weekday, g.time_start
2026-05-05 16:12:17 +02:00
""",
(club_id,),
)
club["training_groups"] = [r2d(r) for r in cur.fetchall()]
return club
# ── Create Club ───────────────────────────────────────────────────────
@router.post("/clubs")
def create_club(data: dict, session=Depends(require_auth)):
2026-05-05 16:12:17 +02:00
"""Neuen Verein anlegen – nur Plattform-Admin; Pflicht: primary_admin_profile_id (Hauptverwalter:in)."""
role = session.get("role")
if not is_platform_admin(role):
raise HTTPException(403, "Nur Plattform-Administratoren dürfen neue Vereine anlegen")
2026-05-05 16:12:17 +02:00
name = data.get("name")
primary_admin_profile_id = data.get("primary_admin_profile_id")
if not name:
raise HTTPException(400, "Name ist Pflichtfeld")
2026-05-05 16:12:17 +02:00
if not primary_admin_profile_id:
raise HTTPException(400, "primary_admin_profile_id ist Pflichtfeld (Hauptverwalter:in)")
try:
aid = int(primary_admin_profile_id)
except (TypeError, ValueError):
raise HTTPException(400, "primary_admin_profile_id ungültig")
with get_db() as conn:
cur = get_cursor(conn)
2026-05-05 16:12:17 +02:00
cur.execute("SELECT id FROM profiles WHERE id = %s", (aid,))
if not cur.fetchone():
raise HTTPException(404, "Profil für Hauptverwalter nicht gefunden")
2026-05-05 16:12:17 +02:00
cur.execute(
"""
INSERT INTO clubs (name, abbreviation, description, status, primary_admin_profile_id)
VALUES (%s, %s, %s, %s, %s)
RETURNING id
""",
(
name,
data.get("abbreviation"),
data.get("description"),
data.get("status", "active"),
aid,
),
)
club_id = cur.fetchone()["id"]
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
2026-05-05 16:12:17 +02:00
""",
(aid, club_id),
)
cm_id = cur.fetchone()["id"]
for rc in ("club_admin", "trainer"):
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 get_club(club_id, session)
# ── Update Club ───────────────────────────────────────────────────────
@router.put("/clubs/{club_id}")
def update_club(club_id: int, data: dict, session=Depends(require_auth)):
2026-05-05 16:12:17 +02:00
"""Verein bearbeiten – Plattform-Admin oder Vereinsadmin."""
profile_id = session["profile_id"]
role = session.get("role")
with get_db() as conn:
cur = get_cursor(conn)
# Check existence
cur.execute("SELECT id FROM clubs WHERE id = %s", (club_id,))
if not cur.fetchone():
raise HTTPException(404, "Verein nicht gefunden")
2026-05-05 16:12:17 +02:00
if not can_manage_club_org(cur, profile_id, club_id, role):
raise HTTPException(403, "Keine Berechtigung für diesen Verein")
# Nur Plattform-Admin darf primary_admin_profile_id ändern
if "primary_admin_profile_id" in data and data["primary_admin_profile_id"] is not None:
if not is_platform_admin(role):
raise HTTPException(403, "Nur Plattform-Admins dürfen den Hauptverwalter ändern")
# Update
2026-05-05 16:12:17 +02:00
cur.execute(
"""
UPDATE clubs SET
2026-05-05 16:12:17 +02:00
name = COALESCE(%s, name),
abbreviation = COALESCE(%s, abbreviation),
description = COALESCE(%s, description),
status = COALESCE(%s, status),
primary_admin_profile_id = COALESCE(%s, primary_admin_profile_id),
updated_at = NOW()
WHERE id = %s
2026-05-05 16:12:17 +02:00
""",
(
data.get("name"),
data.get("abbreviation"),
data.get("description"),
data.get("status"),
data.get("primary_admin_profile_id"),
club_id,
),
)
conn.commit()
return get_club(club_id, session)
# ── Delete Club ───────────────────────────────────────────────────────
@router.delete("/clubs/{club_id}")
def delete_club(club_id: int, session=Depends(require_auth)):
"""Delete club (superadmin only)."""
role = session.get('role')
if role != 'superadmin':
raise HTTPException(403, "Nur Superadmins dürfen Vereine löschen")
with get_db() as conn:
cur = get_cursor(conn)
# Check existence
cur.execute("SELECT id FROM clubs WHERE id = %s", (club_id,))
if not cur.fetchone():
raise HTTPException(404, "Verein nicht gefunden")
# Delete (CASCADE handles divisions and groups)
cur.execute("DELETE FROM clubs WHERE id = %s", (club_id,))
conn.commit()
return {"ok": True}
# ── List Divisions ────────────────────────────────────────────────────
@router.get("/divisions")
def list_divisions(
club_id: Optional[int] = Query(default=None),
2026-05-05 16:12:17 +02:00
session=Depends(require_auth),
):
2026-05-05 16:12:17 +02:00
"""Sparten – ohne Admin-Rechte nur in eigenen Vereinen sichtbar."""
profile_id = session["profile_id"]
role = session.get("role")
with get_db() as conn:
cur = get_cursor(conn)
2026-05-05 16:12:17 +02:00
mine = club_ids_for_profile(cur, profile_id)
if not is_platform_admin(role) and not mine:
return []
query = """
SELECT d.*, c.name as club_name
FROM divisions d
LEFT JOIN clubs c ON d.club_id = c.id
"""
2026-05-05 16:12:17 +02:00
where = []
params = []
2026-05-05 16:12:17 +02:00
if club_id is not None:
where.append("d.club_id = %s")
params.append(club_id)
2026-05-05 16:12:17 +02:00
if not is_platform_admin(role) and club_id not in mine:
return []
if not is_platform_admin(role):
where.append(
"d.club_id IN (" + ",".join(["%s"] * len(mine)) + ")"
)
params.extend(sorted(mine))
if where:
query += " WHERE " + " AND ".join(where)
query += " ORDER BY d.name"
cur.execute(query, params)
rows = cur.fetchall()
return [r2d(r) for r in rows]
# ── Create Division ───────────────────────────────────────────────────
@router.post("/divisions")
def create_division(data: dict, session=Depends(require_auth)):
2026-05-05 16:12:17 +02:00
"""Create new division – Vereinsadmin / Plattform-Admin."""
profile_id = session["profile_id"]
role = session.get("role")
2026-05-05 16:12:17 +02:00
club_id = data.get("club_id")
name = data.get("name")
if not club_id or not name:
raise HTTPException(400, "club_id und name sind Pflichtfelder")
with get_db() as conn:
cur = get_cursor(conn)
2026-05-05 16:12:17 +02:00
if not can_manage_club_org(cur, profile_id, int(club_id), role):
raise HTTPException(403, "Keine Berechtigung, Sparten in diesem Verein anzulegen")
# Check club exists
cur.execute("SELECT id FROM clubs WHERE id = %s", (club_id,))
if not cur.fetchone():
raise HTTPException(404, "Verein nicht gefunden")
# Insert
2026-05-05 16:12:17 +02:00
cur.execute(
"""
INSERT INTO divisions (club_id, name, focus_area)
VALUES (%s, %s, %s)
RETURNING id
2026-05-05 16:12:17 +02:00
""",
(
club_id,
name,
data.get("focus_area"),
),
)
division_id = cur.fetchone()["id"]
conn.commit()
# Return created division
2026-05-05 16:12:17 +02:00
cur.execute(
"""
SELECT d.*, c.name as club_name
FROM divisions d
LEFT JOIN clubs c ON d.club_id = c.id
WHERE d.id = %s
2026-05-05 16:12:17 +02:00
""",
(division_id,),
)
return r2d(cur.fetchone())
# ── Update Division ───────────────────────────────────────────────────
@router.put("/divisions/{division_id}")
def update_division(division_id: int, data: dict, session=Depends(require_auth)):
2026-05-05 16:12:17 +02:00
"""Update division – Vereinsadmin / Plattform-Admin."""
profile_id = session["profile_id"]
role = session.get("role")
with get_db() as conn:
cur = get_cursor(conn)
2026-05-05 16:12:17 +02:00
cur.execute("SELECT id, club_id FROM divisions WHERE id = %s", (division_id,))
div = cur.fetchone()
if not div:
raise HTTPException(404, "Sparte nicht gefunden")
2026-05-05 16:12:17 +02:00
if not can_manage_club_org(cur, profile_id, div["club_id"], role):
raise HTTPException(403, "Keine Berechtigung")
cur.execute(
"""
UPDATE divisions SET
name = %s,
focus_area = %s,
updated_at = NOW()
WHERE id = %s
2026-05-05 16:12:17 +02:00
""",
(
data.get("name"),
data.get("focus_area"),
division_id,
),
)
conn.commit()
2026-05-05 16:12:17 +02:00
cur.execute(
"""
SELECT d.*, c.name as club_name
FROM divisions d
LEFT JOIN clubs c ON d.club_id = c.id
WHERE d.id = %s
2026-05-05 16:12:17 +02:00
""",
(division_id,),
)
return r2d(cur.fetchone())
# ── Delete Division ───────────────────────────────────────────────────
@router.delete("/divisions/{division_id}")
def delete_division(division_id: int, session=Depends(require_auth)):
2026-05-05 16:12:17 +02:00
"""Delete division – Vereinsadmin / Plattform-Admin."""
profile_id = session["profile_id"]
role = session.get("role")
with get_db() as conn:
cur = get_cursor(conn)
2026-05-05 16:12:17 +02:00
cur.execute("SELECT id, club_id FROM divisions WHERE id = %s", (division_id,))
div = cur.fetchone()
if not div:
raise HTTPException(404, "Sparte nicht gefunden")
2026-05-05 16:12:17 +02:00
if not can_manage_club_org(cur, profile_id, div["club_id"], role):
raise HTTPException(403, "Keine Berechtigung")
cur.execute("DELETE FROM divisions WHERE id = %s", (division_id,))
conn.commit()
return {"ok": True}
# ── List Training Groups ──────────────────────────────────────────────
@router.get("/groups")
def list_training_groups(
club_id: Optional[int] = Query(default=None),
division_id: Optional[int] = Query(default=None),
status: Optional[str] = Query(default=None),
2026-05-05 16:12:17 +02:00
session=Depends(require_auth),
):
"""
2026-05-05 16:12:17 +02:00
Trainingsgruppen – ohne Plattform-Admin nur in eigenen Vereinen.
"""
2026-05-05 16:12:17 +02:00
profile_id = session["profile_id"]
role = session.get("role")
with get_db() as conn:
cur = get_cursor(conn)
2026-05-05 16:12:17 +02:00
mine = club_ids_for_profile(cur, profile_id)
if not is_platform_admin(role) and not mine:
return []
query = """
SELECT g.*,
c.name as club_name,
d.name as division_name,
p.name as trainer_name
FROM training_groups g
LEFT JOIN clubs c ON g.club_id = c.id
LEFT JOIN divisions d ON g.division_id = d.id
LEFT JOIN profiles p ON g.trainer_id = p.id
"""
where = []
params = []
2026-05-05 16:12:17 +02:00
if club_id is not None:
where.append("g.club_id = %s")
params.append(club_id)
2026-05-05 16:12:17 +02:00
if not is_platform_admin(role) and club_id not in mine:
return []
2026-05-05 16:12:17 +02:00
if division_id is not None:
where.append("g.division_id = %s")
params.append(division_id)
if status:
where.append("g.status = %s")
params.append(status)
2026-05-05 16:12:17 +02:00
if not is_platform_admin(role):
where.append("g.club_id IN (" + ",".join(["%s"] * len(mine)) + ")")
params.extend(sorted(mine))
if where:
query += " WHERE " + " AND ".join(where)
query += " ORDER BY g.weekday, g.time_start"
cur.execute(query, params)
rows = cur.fetchall()
return [r2d(r) for r in rows]
# ── Get Training Group ────────────────────────────────────────────────
@router.get("/groups/{group_id}")
def get_training_group(group_id: int, session=Depends(require_auth)):
2026-05-05 16:12:17 +02:00
"""Trainingsgruppe – nur mit Vereinszugriff."""
profile_id = session["profile_id"]
role = session.get("role")
with get_db() as conn:
cur = get_cursor(conn)
2026-05-05 16:12:17 +02:00
cur.execute(
"""
SELECT g.*,
c.name as club_name,
d.name as division_name,
p.name as trainer_name
FROM training_groups g
LEFT JOIN clubs c ON g.club_id = c.id
LEFT JOIN divisions d ON g.division_id = d.id
LEFT JOIN profiles p ON g.trainer_id = p.id
WHERE g.id = %s
2026-05-05 16:12:17 +02:00
""",
(group_id,),
)
group = cur.fetchone()
if not group:
raise HTTPException(404, "Trainingsgruppe nicht gefunden")
2026-05-05 16:12:17 +02:00
cid = group["club_id"]
if not is_platform_admin(role):
assert_club_member(cur, profile_id, cid)
return r2d(group)
# ── Create Training Group ─────────────────────────────────────────────
@router.post("/groups")
def create_training_group(data: dict, session=Depends(require_auth)):
2026-05-05 16:12:17 +02:00
"""Trainingsgruppe anlegen – Mitglied mit Planungs-/Admin-Rolle im Verein."""
profile_id = session["profile_id"]
2026-05-05 16:12:17 +02:00
role = session.get("role")
if role not in ["admin", "superadmin", "trainer", "user"]:
raise HTTPException(403, "Keine Berechtigung, Trainingsgruppen anzulegen")
2026-05-05 16:12:17 +02:00
club_id = data.get("club_id")
name = data.get("name")
if not club_id or not name:
raise HTTPException(400, "club_id und name sind Pflichtfelder")
2026-05-05 16:12:17 +02:00
trainer_id = data.get("trainer_id")
if trainer_id in (None, "", 0) and role in ("trainer", "user"):
trainer_id = profile_id
with get_db() as conn:
cur = get_cursor(conn)
2026-05-05 16:12:17 +02:00
if not can_plan_in_club(cur, profile_id, int(club_id), role):
raise HTTPException(403, "Keine Berechtigung für diesen Verein")
# Check club exists
cur.execute("SELECT id FROM clubs WHERE id = %s", (club_id,))
if not cur.fetchone():
raise HTTPException(404, "Verein nicht gefunden")
# Insert
2026-05-05 16:12:17 +02:00
cur.execute(
"""
INSERT INTO training_groups (
club_id, division_id, name, focus, level, age_group,
weekday, time_start, time_end, location,
trainer_id, co_trainer_ids, status
) VALUES (
%s, %s, %s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s
) RETURNING id
2026-05-05 16:12:17 +02:00
""",
(
club_id,
data.get("division_id"),
name,
data.get("focus"),
data.get("level"),
data.get("age_group"),
data.get("weekday"),
data.get("time_start"),
data.get("time_end"),
data.get("location"),
trainer_id,
data.get("co_trainer_ids"),
data.get("status", "active"),
),
)
group_id = cur.fetchone()["id"]
conn.commit()
return get_training_group(group_id, session)
# ── Update Training Group ─────────────────────────────────────────────
@router.put("/groups/{group_id}")
def update_training_group(group_id: int, data: dict, session=Depends(require_auth)):
2026-05-05 16:12:17 +02:00
"""Update training group – Vereinsadmin, Plattform-Admin oder zugewiesene Trainer."""
profile_id = session["profile_id"]
role = session.get("role")
with get_db() as conn:
cur = get_cursor(conn)
2026-05-05 16:12:17 +02:00
cur.execute(
"SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s",
(group_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(404, "Trainingsgruppe nicht gefunden")
2026-05-05 16:12:17 +02:00
co_trainers = row["co_trainer_ids"] or []
club_id = row["club_id"]
allowed = role in ["admin", "superadmin"]
if not allowed:
allowed = can_manage_club_org(cur, profile_id, club_id, role)
if not allowed:
allowed = row["trainer_id"] == profile_id or profile_id in co_trainers
if not allowed:
raise HTTPException(403, "Keine Berechtigung")
2026-05-05 16:12:17 +02:00
cur.execute(
"""
UPDATE training_groups SET
name = %s,
division_id = %s,
focus = %s,
level = %s,
age_group = %s,
weekday = %s,
time_start = %s,
time_end = %s,
location = %s,
trainer_id = %s,
co_trainer_ids = %s,
status = %s,
updated_at = NOW()
WHERE id = %s
2026-05-05 16:12:17 +02:00
""",
(
data.get("name"),
data.get("division_id"),
data.get("focus"),
data.get("level"),
data.get("age_group"),
data.get("weekday"),
data.get("time_start"),
data.get("time_end"),
data.get("location"),
data.get("trainer_id"),
data.get("co_trainer_ids"),
data.get("status"),
group_id,
),
)
conn.commit()
return get_training_group(group_id, session)
# ── Delete Training Group ─────────────────────────────────────────────
@router.delete("/groups/{group_id}")
def delete_training_group(group_id: int, session=Depends(require_auth)):
2026-05-05 16:12:17 +02:00
"""Delete training group – Vereinsadmin oder Plattform-Admin."""
profile_id = session["profile_id"]
role = session.get("role")
with get_db() as conn:
cur = get_cursor(conn)
2026-05-05 16:12:17 +02:00
cur.execute("SELECT id, club_id FROM training_groups WHERE id = %s", (group_id,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Trainingsgruppe nicht gefunden")
2026-05-05 16:12:17 +02:00
if not can_manage_club_org(cur, profile_id, row["club_id"], role):
raise HTTPException(403, "Keine Berechtigung")
cur.execute("DELETE FROM training_groups WHERE id = %s", (group_id,))
conn.commit()
return {"ok": True}