""" 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 club_tenancy import can_manage_club_org from db import get_db, get_cursor, r2d from tenant_context import TenantContext, get_tenant_context 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, tenant: TenantContext, club_id: int) -> None: pid = tenant.profile_id role = tenant.global_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 def _count_other_active_club_admins(cur, club_id: int, exclude_profile_id: int) -> int: """Aktive Vereinsadmins im Verein, außer exclude_profile_id.""" cur.execute( """ SELECT COUNT(DISTINCT cm.profile_id)::int AS n FROM club_members cm INNER JOIN club_member_roles r ON r.club_member_id = cm.id AND r.role_code = 'club_admin' WHERE cm.club_id = %s AND cm.status = 'active' AND cm.profile_id <> %s """, (club_id, exclude_profile_id), ) row = cur.fetchone() try: return int(row["n"]) if row is not None else 0 except (KeyError, TypeError, ValueError): return 0 def _member_is_active_club_admin(cur, club_id: int, profile_id: int) -> bool: cur.execute( """ SELECT 1 FROM club_members cm INNER JOIN club_member_roles r ON r.club_member_id = cm.id AND r.role_code = 'club_admin' WHERE cm.club_id = %s AND cm.profile_id = %s AND cm.status = 'active' LIMIT 1 """, (club_id, profile_id), ) return cur.fetchone() is not None _LAST_CLUB_ADMIN_MSG = ( "Mindestens ein aktiver Vereinsadmin muss im Verein verbleiben. " "Weise die Rolle zuerst einem anderen Mitglied zu oder aktiviere einen anderen Vereinsadmin." ) 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), tenant: TenantContext = Depends(get_tenant_context), ): """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, tenant, 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(p.email_verified, false) AS email_verified, lower(trim(COALESCE(p.role, 'user'))) AS portal_role, 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, p.email_verified, p.role 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, tenant: TenantContext = Depends(get_tenant_context), ): """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, tenant, 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(p.email_verified, false) AS email_verified, lower(trim(COALESCE(p.role, 'user'))) AS portal_role, 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, p.email_verified, p.role """, (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, tenant: TenantContext = Depends(get_tenant_context), ): 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, tenant, 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, tenant: TenantContext = Depends(get_tenant_context), ): """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, tenant, 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) cur.execute( """ SELECT cm.status, COALESCE( ARRAY_AGG(r.role_code) FILTER (WHERE r.role_code IS NOT NULL), ARRAY[]::varchar[] ) AS roles FROM club_members cm LEFT JOIN club_member_roles r ON r.club_member_id = cm.id WHERE cm.id = %s GROUP BY cm.status """, (cm_id,), ) cur_row = cur.fetchone() current_status = (cur_row["status"] or "active").strip().lower() if cur_row else "active" cr = cur_row.get("roles") if cur_row else [] if hasattr(cr, "tolist"): cr = cr.tolist() current_roles = list(cr) new_status = body.status.strip().lower() if body.status is not None else current_status if body.status is not None and new_status not in _ALLOWED_STATUS: raise HTTPException(status_code=400, detail="status muss active oder inactive sein") new_roles = _normalize_roles(body.roles) if body.roles is not None else current_roles if not new_roles: raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben") effective_admin = new_status == "active" and "club_admin" in set(new_roles) if not effective_admin: others = _count_other_active_club_admins(cur, club_id, profile_id) if others < 1: raise HTTPException(status_code=400, detail=_LAST_CLUB_ADMIN_MSG) if body.status is not None: cur.execute( "UPDATE club_members SET status = %s, updated_at = NOW() WHERE id = %s", (new_status, cm_id), ) if body.roles is not None: cur.execute("DELETE FROM club_member_roles WHERE club_member_id = %s", (cm_id,)) for rc in new_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, tenant: TenantContext = Depends(get_tenant_context), ): """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, tenant, club_id) if _member_is_active_club_admin(cur, club_id, profile_id): if _count_other_active_club_admins(cur, club_id, profile_id) < 1: raise HTTPException(status_code=400, detail=_LAST_CLUB_ADMIN_MSG) 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}