""" Anträge auf Vereinsgründung: Nutzer stellt Antrag, Plattform-Admin legt Verein + Abo an. """ from typing import Any, Dict, Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from account_lifecycle import assert_min_account_state from capabilities import probe_capability from club_tenancy import is_platform_admin from db import get_db, get_cursor, r2d from tenant_context import TenantContext, get_tenant_context router = APIRouter(prefix="/api", tags=["club_creation_requests"]) _FREE_PLAN_ID = "free" def _has_active_membership(cur, profile_id: int) -> bool: cur.execute( """ SELECT 1 FROM club_members WHERE profile_id = %s AND status = 'active' LIMIT 1 """, (profile_id,), ) return cur.fetchone() is not None def _club_name_taken(cur, name: str, *, exclude_club_id: Optional[int] = None) -> bool: n = (name or "").strip() if not n: return False if exclude_club_id is not None: cur.execute( """ SELECT 1 FROM clubs WHERE lower(trim(name)) = lower(trim(%s)) AND id <> %s LIMIT 1 """, (n, exclude_club_id), ) else: cur.execute( """ SELECT 1 FROM clubs WHERE lower(trim(name)) = lower(trim(%s)) LIMIT 1 """, (n,), ) return cur.fetchone() is not None def _provision_club_for_founder( cur, *, founder_profile_id: int, name: str, abbreviation: Optional[str], description: Optional[str], ) -> int: """Legt Verein, Mitgliedschaft (club_admin+trainer) und Free-Abo an.""" cur.execute( """ INSERT INTO clubs (name, abbreviation, description, status, primary_admin_profile_id) VALUES (%s, %s, %s, 'active', %s) RETURNING id """, (name, abbreviation, description, founder_profile_id), ) club_id = int(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 """, (founder_profile_id, 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), ) cur.execute( """ INSERT INTO club_subscriptions (club_id, plan_id, status) VALUES (%s, %s, 'active') ON CONFLICT (club_id) DO NOTHING """, (club_id, _FREE_PLAN_ID), ) return club_id class CreationRequestCreate(BaseModel): proposed_name: str = Field(..., min_length=2, max_length=200) proposed_abbreviation: Optional[str] = Field(None, max_length=50) proposed_description: Optional[str] = Field(None, max_length=5000) message: Optional[str] = Field(None, max_length=2000) def _normalize_creation_request_row(row: Dict[str, Any]) -> Dict[str, Any]: """Approved ohne Verein → superseded (z. B. nach Vereinslöschung, FK SET NULL).""" d = dict(row) if d.get("status") == "approved" and not d.get("created_club_id"): d["status"] = "superseded" return d def _response_one(cur, req_id: int, viewer_profile_id: int) -> Dict[str, Any]: cur.execute( """ SELECT r.*, c.name AS created_club_name FROM club_creation_requests r LEFT JOIN clubs c ON c.id = r.created_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 _normalize_creation_request_row(r2d(row)) def _assert_platform_admin(tenant: TenantContext) -> None: if not is_platform_admin(tenant.global_role): raise HTTPException(status_code=403, detail="Nur Plattform-Administratoren") @router.get("/me/club-creation-requests") def get_my_creation_requests(tenant: TenantContext = Depends(get_tenant_context)): assert_min_account_state(tenant, "verified_pending_club", endpoint="GET /me/club-creation-requests") pid = tenant.profile_id with get_db() as conn: probe_capability( tenant, "club.creation_request.read_own", action="read", endpoint="GET /me/club-creation-requests", conn=conn, ) cur = get_cursor(conn) cur.execute( """ SELECT r.*, c.name AS created_club_name FROM club_creation_requests r LEFT JOIN clubs c ON c.id = r.created_club_id WHERE r.profile_id = %s ORDER BY r.created_at DESC LIMIT 50 """, (pid,), ) return [_normalize_creation_request_row(r2d(r)) for r in cur.fetchall()] @router.post("/me/club-creation-requests", status_code=201) def create_my_creation_request( body: CreationRequestCreate, tenant: TenantContext = Depends(get_tenant_context), ): assert_min_account_state(tenant, "verified_pending_club", endpoint="POST /me/club-creation-requests") pid = tenant.profile_id name = body.proposed_name.strip() abbr = (body.proposed_abbreviation or "").strip() or None desc = (body.proposed_description or "").strip() or None msg = (body.message or "").strip() or None with get_db() as conn: probe_capability( tenant, "club.creation_request.create", action="create", endpoint="POST /me/club-creation-requests", conn=conn, ) cur = get_cursor(conn) if _has_active_membership(cur, pid): raise HTTPException( status_code=400, detail="Du bist bereits Vereinsmitglied — Gründungsantrag nicht möglich", ) cur.execute( """ SELECT id FROM club_creation_requests WHERE profile_id = %s AND status = 'pending' LIMIT 1 """, (pid,), ) if cur.fetchone(): raise HTTPException(status_code=409, detail="Es liegt bereits ein offener Gründungsantrag vor") if _club_name_taken(cur, name): raise HTTPException( status_code=409, detail="Ein Verein mit diesem Namen existiert bereits — bitte anderen Namen wählen", ) cur.execute( """ INSERT INTO club_creation_requests ( profile_id, proposed_name, proposed_abbreviation, proposed_description, message, status ) VALUES (%s, %s, %s, %s, %s, 'pending') RETURNING id """, (pid, name, abbr, desc, 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-creation-requests/{request_id}") def withdraw_my_creation_request(request_id: int, tenant: TenantContext = Depends(get_tenant_context)): assert_min_account_state( tenant, "verified_pending_club", endpoint="DELETE /me/club-creation-requests/{id}" ) pid = tenant.profile_id with get_db() as conn: probe_capability( tenant, "club.creation_request.withdraw", action="withdraw", endpoint="DELETE /me/club-creation-requests/{id}", conn=conn, ) cur = get_cursor(conn) cur.execute( """ UPDATE club_creation_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("/admin/club-creation-requests") def list_admin_creation_requests(tenant: TenantContext = Depends(get_tenant_context)): _assert_platform_admin(tenant) with get_db() as conn: probe_capability( tenant, "platform.club_creation.approve", action="list", endpoint="GET /admin/club-creation-requests", conn=conn, ) cur = get_cursor(conn) cur.execute( """ SELECT r.*, p.name AS applicant_name, p.email AS applicant_email, c.name AS created_club_name FROM club_creation_requests r INNER JOIN profiles p ON p.id = r.profile_id LEFT JOIN clubs c ON c.id = r.created_club_id WHERE r.status = 'pending' ORDER BY r.created_at ASC """ ) return [r2d(row) for row in cur.fetchall()] @router.post("/admin/club-creation-requests/{request_id}/approve") def approve_creation_request(request_id: int, tenant: TenantContext = Depends(get_tenant_context)): _assert_platform_admin(tenant) admin_pid = tenant.profile_id with get_db() as conn: probe_capability( tenant, "platform.club_creation.approve", action="approve", endpoint="POST /admin/club-creation-requests/{id}/approve", conn=conn, ) cur = get_cursor(conn) cur.execute( """ SELECT id, profile_id, proposed_name, proposed_abbreviation, proposed_description, status FROM club_creation_requests WHERE id = %s """, (request_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 = int(row["profile_id"]) name = (row["proposed_name"] or "").strip() if not name: raise HTTPException(status_code=400, detail="Vorgeschlagener Vereinsname fehlt") if _has_active_membership(cur, applicant_id): raise HTTPException( status_code=409, detail="Antragsteller ist bereits Vereinsmitglied — Freigabe nicht möglich", ) if _club_name_taken(cur, name): raise HTTPException( status_code=409, detail="Ein Verein mit diesem Namen existiert bereits", ) club_id = _provision_club_for_founder( cur, founder_profile_id=applicant_id, name=name, abbreviation=row.get("proposed_abbreviation"), description=row.get("proposed_description"), ) cur.execute( """ UPDATE club_creation_requests SET status = 'approved', decided_by_profile_id = %s, decided_at = NOW(), created_club_id = %s, updated_at = NOW() WHERE id = %s AND status = 'pending' RETURNING id """, (admin_pid, club_id, request_id), ) if not cur.fetchone(): raise HTTPException(status_code=409, detail="Antrag konnte nicht freigegeben werden") conn.commit() return {"ok": True, "club_id": club_id, "profile_id": applicant_id} @router.post("/admin/club-creation-requests/{request_id}/reject") def reject_creation_request(request_id: int, tenant: TenantContext = Depends(get_tenant_context)): _assert_platform_admin(tenant) admin_pid = tenant.profile_id with get_db() as conn: probe_capability( tenant, "platform.club_creation.approve", action="reject", endpoint="POST /admin/club-creation-requests/{id}/reject", conn=conn, ) cur = get_cursor(conn) cur.execute( """ UPDATE club_creation_requests SET status = 'rejected', decided_by_profile_id = %s, decided_at = NOW(), updated_at = NOW() WHERE id = %s AND status = 'pending' RETURNING id """, (admin_pid, request_id), ) if not cur.fetchone(): raise HTTPException(status_code=404, detail="Offener Antrag nicht gefunden") conn.commit() return {"ok": True}