shinkan-jinkendo/backend/routers/club_join_requests.py
Lars 58a38702b9
All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 30s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 24s
feat(org-inbox): implement join request inbox for platform and club admins
- Added new API endpoint to retrieve join requests accessible by platform admins and club admins.
- Implemented frontend components to display join requests in the inbox, including navigation updates and badge notifications.
- Enhanced sidebar and navigation to conditionally show inbox based on user permissions.
- Updated styles for inbox components and added responsive design for dashboard integration.
- Introduced context management for inbox state and notifications on join request actions.
2026-05-09 09:13:38 +02:00

348 lines
12 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, 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_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)
def _can_access_org_inbox(cur, profile_id: int, global_role: Optional[str]) -> bool:
"""Posteingang (Beitrittsanträge bearbeiten): Plattform-Admin oder Vereinsadmin in mind. einem Verein."""
if is_platform_admin(global_role):
return True
cur.execute(
"""
SELECT 1
FROM club_members cm
INNER JOIN club_member_roles r ON r.club_member_id = cm.id AND r.role_code = 'club_admin'
WHERE cm.profile_id = %s AND cm.status = 'active'
LIMIT 1
""",
(profile_id,),
)
return cur.fetchone() is not None
def _club_ids_manageable_by_user(cur, profile_id: int, global_role: Optional[str]) -> List[int]:
"""Club-IDs, für die der Nutzer Beitrittsanträge sehen darf."""
if is_platform_admin(global_role):
cur.execute("SELECT id FROM clubs WHERE status = 'active' ORDER BY name")
return [int(r["id"]) for r in cur.fetchall()]
cur.execute(
"""
SELECT DISTINCT cm.club_id
FROM club_members cm
INNER JOIN club_member_roles r ON r.club_member_id = cm.id AND r.role_code = 'club_admin'
WHERE cm.profile_id = %s AND cm.status = 'active'
""",
(profile_id,),
)
return [int(r["club_id"]) for r in cur.fetchall()]
@router.get("/me/inbox/join-requests")
def list_inbox_join_requests(tenant: TenantContext = Depends(get_tenant_context)):
"""
Alle offenen Beitrittsanträge, die der Nutzer bearbeiten darf:
Plattform-Admin: alle Vereine; sonst nur Vereine mit Rolle club_admin.
"""
pid = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
if not _can_access_org_inbox(cur, pid, role):
raise HTTPException(status_code=403, detail="Kein Zugriff auf den Organisations-Posteingang")
club_ids = _club_ids_manageable_by_user(cur, pid, role)
if not club_ids:
return []
cur.execute(
"""
SELECT r.id, r.profile_id, r.club_id, r.status, r.message, r.created_at,
c.name AS club_name, c.abbreviation AS club_abbreviation,
p.name AS applicant_name, p.email AS applicant_email
FROM club_membership_requests r
INNER JOIN clubs c ON c.id = r.club_id
INNER JOIN profiles p ON p.id = r.profile_id
WHERE r.status = 'pending'
AND r.club_id = ANY(%s)
ORDER BY r.created_at ASC
""",
(club_ids,),
)
return [r2d(row) for row in cur.fetchall()]
@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}