shinkan-jinkendo/backend/routers/club_join_requests.py
Lars 5aee9c52fc
Some checks failed
Deploy Development / deploy (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 34s
feat: integrate tenant context across club-related APIs
- Refactored club join requests, memberships, and clubs routers to utilize TenantContext for authentication and authorization, enhancing security and consistency.
- Updated session handling to replace direct session dictionary access with TenantContext, improving code clarity and maintainability.
- Ensured proper role and profile ID retrieval from TenantContext in various endpoints, streamlining access control for club management functionalities.
2026-05-05 22:05:10 +02:00

282 lines
9.3 KiB
Python

"""
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 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_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, 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 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(tenant: TenantContext = Depends(get_tenant_context)):
pid = tenant.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, tenant: TenantContext = Depends(get_tenant_context)):
"""Antrag stellen (nicht möglich wenn bereits aktives Mitglied)."""
pid = tenant.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, tenant: TenantContext = Depends(get_tenant_context)):
pid = tenant.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, tenant: TenantContext = Depends(get_tenant_context)):
"""Offene Anträge für einen Verein (Vereins-/Plattform-Admin)."""
with get_db() as conn:
cur = get_cursor(conn)
_assert_manage_club(cur, tenant, 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,
tenant: TenantContext = Depends(get_tenant_context),
):
admin_pid = tenant.profile_id
roles = _normalize_roles(body.roles)
with get_db() as conn:
cur = get_cursor(conn)
_assert_manage_club(cur, tenant, 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, tenant: TenantContext = Depends(get_tenant_context)):
admin_pid = tenant.profile_id
with get_db() as conn:
cur = get_cursor(conn)
_assert_manage_club(cur, tenant, 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}