shinkan-jinkendo/backend/routers/club_creation_requests.py
Lars fa10450315
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 39s
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) Successful in 1m20s
Update Version and Enhance Club Creation Request Management
- Incremented application version to 0.8.192 and database schema version to 20260606081.
- Updated club module versions for 'clubs' and 'club_creation_requests' to reflect recent changes.
- Implemented logic to mark approved club creation requests as 'superseded' when the associated club is deleted.
- Refactored frontend components to clear session storage for coach-related keys upon logout and during login checks.
- Enhanced onboarding page to accurately display the status of club creation requests based on their validity.
2026-06-07 07:31:05 +02:00

399 lines
13 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 _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}