""" Anträge auf Vereinsbeitritt: Nutzer stellt Antrag, Vereins-/Plattform-Admin nimmt an oder lehnt ab. """ from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException 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_join_requests"]) _ALLOWED_MEMBER_ROLES = frozenset({"club_admin", "trainer", "division_lead", "content_editor"}) 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 or code not in _ALLOWED_MEMBER_ROLES: raise HTTPException(status_code=400, detail=f"Unbekannte Rolle: {code}") if code not in seen: seen.add(code) out.append(code) return out def _club_active(cur, club_id: int) -> bool: cur.execute("SELECT 1 FROM clubs WHERE id = %s AND status = 'active'", (club_id,)) return cur.fetchone() is not None def _assert_manage_club(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 für Mitglieder-Verwaltung in diesem Verein") def _is_active_member(cur, profile_id: int, club_id: int) -> bool: cur.execute( """ SELECT 1 FROM club_members WHERE profile_id = %s AND club_id = %s AND status = 'active' LIMIT 1 """, (profile_id, club_id), ) return cur.fetchone() is not None def _upsert_active_member_with_roles(cur, club_id: int, profile_id: int, roles: List[str]) -> None: roles_n = _normalize_roles(roles) if not roles_n: raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben") 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 """, (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_n: 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), ) class JoinRequestCreate(BaseModel): club_id: int = Field(..., ge=1) message: Optional[str] = Field(None, max_length=2000) class JoinRequestAccept(BaseModel): roles: List[str] = Field(default_factory=lambda: ["trainer"]) def _response_one(cur, req_id: int, viewer_profile_id: int) -> Dict[str, Any]: cur.execute( """ SELECT r.*, c.name AS club_name, c.abbreviation AS club_abbreviation FROM club_membership_requests r INNER JOIN clubs c ON c.id = r.club_id WHERE r.id = %s AND r.profile_id = %s """, (req_id, viewer_profile_id), ) row = cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Antrag nicht gefunden") return r2d(row) @router.get("/me/club-join-requests") def get_my_join_requests(session: dict = Depends(require_auth)): pid = session["profile_id"] with get_db() as conn: cur = get_cursor(conn) cur.execute( """ SELECT r.*, c.name AS club_name, c.abbreviation AS club_abbreviation FROM club_membership_requests r INNER JOIN clubs c ON c.id = r.club_id WHERE r.profile_id = %s ORDER BY r.created_at DESC LIMIT 100 """, (pid,), ) return [r2d(r) for r in cur.fetchall()] @router.post("/me/club-join-requests", status_code=201) def create_my_join_request(body: JoinRequestCreate, session: dict = Depends(require_auth)): """Antrag stellen (nicht möglich wenn bereits aktives Mitglied).""" pid = session["profile_id"] msg = (body.message or "").strip() or None cid = body.club_id with get_db() as conn: cur = get_cursor(conn) if not _club_active(cur, cid): raise HTTPException(status_code=404, detail="Verein nicht gefunden oder nicht aktiv") if _is_active_member(cur, pid, cid): raise HTTPException(status_code=400, detail="Du bist bereits Mitglied in diesem Verein") cur.execute( """ SELECT id FROM club_membership_requests WHERE profile_id = %s AND club_id = %s AND status = 'pending' LIMIT 1 """, (pid, cid), ) if cur.fetchone(): raise HTTPException(status_code=409, detail="Für diesen Verein liegt bereits ein offener Antrag vor") cur.execute( """ INSERT INTO club_membership_requests (profile_id, club_id, status, message) VALUES (%s, %s, 'pending', %s) RETURNING id """, (pid, cid, msg), ) rid = cur.fetchone()["id"] conn.commit() with get_db() as conn: cur = get_cursor(conn) return _response_one(cur, rid, pid) @router.delete("/me/club-join-requests/{request_id}") def withdraw_my_join_request(request_id: int, session: dict = Depends(require_auth)): pid = session["profile_id"] with get_db() as conn: cur = get_cursor(conn) cur.execute( """ UPDATE club_membership_requests SET status = 'withdrawn', updated_at = NOW() WHERE id = %s AND profile_id = %s AND status = 'pending' RETURNING id """, (request_id, pid), ) if not cur.fetchone(): raise HTTPException(status_code=404, detail="Offener Antrag nicht gefunden") conn.commit() return {"ok": True} @router.get("/clubs/{club_id}/join-requests") def list_club_join_requests(club_id: int, session: dict = Depends(require_auth)): """Offene Anträge für einen Verein (Vereins-/Plattform-Admin).""" with get_db() as conn: cur = get_cursor(conn) _assert_manage_club(cur, session, club_id) cur.execute( """ SELECT r.*, p.email AS applicant_email, p.name AS applicant_name FROM club_membership_requests r INNER JOIN profiles p ON p.id = r.profile_id WHERE r.club_id = %s AND r.status = 'pending' ORDER BY r.created_at ASC """, (club_id,), ) return [r2d(r) for r in cur.fetchall()] @router.post("/clubs/{club_id}/join-requests/{request_id}/accept") def accept_club_join_request( club_id: int, request_id: int, body: JoinRequestAccept, session: dict = Depends(require_auth), ): admin_pid = session["profile_id"] roles = _normalize_roles(body.roles) with get_db() as conn: cur = get_cursor(conn) _assert_manage_club(cur, session, club_id) cur.execute( """ SELECT id, profile_id, status FROM club_membership_requests WHERE id = %s AND club_id = %s """, (request_id, club_id), ) row = cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Antrag nicht gefunden") if row["status"] != "pending": raise HTTPException(status_code=400, detail="Antrag ist nicht mehr offen") applicant_id = row["profile_id"] cur.execute( """ UPDATE club_membership_requests SET status = 'accepted', decided_by_profile_id = %s, decided_at = NOW(), updated_at = NOW() WHERE id = %s AND club_id = %s AND status = 'pending' RETURNING id """, (admin_pid, request_id, club_id), ) if not cur.fetchone(): raise HTTPException(status_code=409, detail="Antrag konnte nicht angenommen werden") _upsert_active_member_with_roles(cur, club_id, applicant_id, roles) conn.commit() return {"ok": True, "profile_id": applicant_id, "club_id": club_id} @router.post("/clubs/{club_id}/join-requests/{request_id}/reject") def reject_club_join_request(club_id: int, request_id: int, session: dict = Depends(require_auth)): admin_pid = session["profile_id"] with get_db() as conn: cur = get_cursor(conn) _assert_manage_club(cur, session, club_id) cur.execute( """ UPDATE club_membership_requests SET status = 'rejected', decided_by_profile_id = %s, decided_at = NOW(), updated_at = NOW() WHERE id = %s AND club_id = %s AND status = 'pending' RETURNING id """, (admin_pid, request_id, club_id), ) if not cur.fetchone(): raise HTTPException(status_code=404, detail="Offener Antrag nicht gefunden") conn.commit() return {"ok": True}