Some checks failed
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 42s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Failing after 1m14s
- Introduced endpoints for managing club creation requests, including fetching, creating, and withdrawing requests. - Updated the onboarding page to allow users to submit new club creation requests and view their existing requests. - Enhanced the admin interface with navigation and routing for club creation requests management. - Incremented version to 0.8.191 to reflect these new features and updates in the application.
391 lines
12 KiB
Python
391 lines
12 KiB
Python
"""
|
|
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 _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 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 [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}
|