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
- 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.
348 lines
12 KiB
Python
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}
|