All checks were successful
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 23s
- Integrated a new password reset mechanism for user accounts, allowing admins to send reset links via email. - Updated the management password reset functionality to differentiate between direct password setting and email link requests. - Added validation to ensure at least one active club admin remains when modifying club member roles. - Improved the user interface for password management in the admin panel, providing clearer feedback and options for password resets.
342 lines
12 KiB
Python
342 lines
12 KiB
Python
"""
|
|
Vereins-Mitgliedschaften und Rollen (ohne UI nutzbar für Admin/Automatisierung).
|
|
|
|
Berechtigung: Plattform-Admin (admin/superadmin) oder Vereinsadmin (club_admin) im Zielverein.
|
|
"""
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
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_memberships"])
|
|
|
|
_ALLOWED_ROLES = frozenset({"club_admin", "trainer", "division_lead", "content_editor"})
|
|
_ALLOWED_STATUS = frozenset({"active", "inactive"})
|
|
|
|
|
|
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:
|
|
continue
|
|
if code not in _ALLOWED_ROLES:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Unbekannte Rolle: {code}. Erlaubt: {', '.join(sorted(_ALLOWED_ROLES))}",
|
|
)
|
|
if code not in seen:
|
|
seen.add(code)
|
|
out.append(code)
|
|
return out
|
|
|
|
|
|
def _assert_manage(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 zur Mitglieder-Verwaltung in diesem Verein")
|
|
|
|
|
|
def _club_exists(cur, club_id: int) -> bool:
|
|
cur.execute("SELECT 1 FROM clubs WHERE id = %s", (club_id,))
|
|
return cur.fetchone() is not None
|
|
|
|
|
|
def _count_other_active_club_admins(cur, club_id: int, exclude_profile_id: int) -> int:
|
|
"""Aktive Vereinsadmins im Verein, außer exclude_profile_id."""
|
|
cur.execute(
|
|
"""
|
|
SELECT COUNT(DISTINCT cm.profile_id)::int AS n
|
|
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.club_id = %s AND cm.status = 'active' AND cm.profile_id <> %s
|
|
""",
|
|
(club_id, exclude_profile_id),
|
|
)
|
|
row = cur.fetchone()
|
|
try:
|
|
return int(row["n"]) if row is not None else 0
|
|
except (KeyError, TypeError, ValueError):
|
|
return 0
|
|
|
|
|
|
def _member_is_active_club_admin(cur, club_id: int, profile_id: int) -> bool:
|
|
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.club_id = %s AND cm.profile_id = %s AND cm.status = 'active'
|
|
LIMIT 1
|
|
""",
|
|
(club_id, profile_id),
|
|
)
|
|
return cur.fetchone() is not None
|
|
|
|
|
|
_LAST_CLUB_ADMIN_MSG = (
|
|
"Mindestens ein aktiver Vereinsadmin muss im Verein verbleiben. "
|
|
"Weise die Rolle zuerst einem anderen Mitglied zu oder aktiviere einen anderen Vereinsadmin."
|
|
)
|
|
|
|
|
|
class ClubMemberUpsert(BaseModel):
|
|
profile_id: int = Field(..., ge=1)
|
|
roles: List[str] = Field(default_factory=list, description="Mindestens eine Vereinsrolle")
|
|
|
|
|
|
class ClubMemberPatch(BaseModel):
|
|
roles: Optional[List[str]] = None
|
|
status: Optional[str] = Field(None, description="active oder inactive")
|
|
|
|
|
|
@router.get("/clubs/{club_id}/members")
|
|
def list_club_members(
|
|
club_id: int,
|
|
include_inactive: bool = Query(False),
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
|
):
|
|
"""Alle Mitglieder eines Vereins mit Rollen (nur Vereins-/Plattform-Admin)."""
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
if not _club_exists(cur, club_id):
|
|
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
|
|
_assert_manage(cur, tenant, club_id)
|
|
|
|
status_clause = "" if include_inactive else "AND cm.status = 'active'"
|
|
cur.execute(
|
|
f"""
|
|
SELECT cm.id AS membership_id, cm.profile_id, cm.status, cm.created_at, cm.updated_at,
|
|
p.email, p.name, COALESCE(p.email_verified, false) AS email_verified,
|
|
lower(trim(COALESCE(p.role, 'user'))) AS portal_role,
|
|
COALESCE(
|
|
ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL),
|
|
ARRAY[]::varchar[]
|
|
) AS roles
|
|
FROM club_members cm
|
|
INNER JOIN profiles p ON p.id = cm.profile_id
|
|
LEFT JOIN club_member_roles r ON r.club_member_id = cm.id
|
|
WHERE cm.club_id = %s {status_clause}
|
|
GROUP BY cm.id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name, p.email_verified, p.role
|
|
ORDER BY p.name NULLS LAST, p.email
|
|
""",
|
|
(club_id,),
|
|
)
|
|
rows = []
|
|
for row in cur.fetchall():
|
|
d = r2d(row)
|
|
roles = d.get("roles") or []
|
|
if hasattr(roles, "tolist"):
|
|
roles = roles.tolist()
|
|
d["roles"] = list(roles)
|
|
rows.append(d)
|
|
return rows
|
|
|
|
|
|
@router.post("/clubs/{club_id}/members", status_code=201)
|
|
def upsert_club_member(
|
|
club_id: int,
|
|
body: ClubMemberUpsert,
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
|
):
|
|
"""Mitglied anlegen oder aktivieren; Rollen werden vollständig ersetzt."""
|
|
roles = _normalize_roles(body.roles)
|
|
if not roles:
|
|
raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben")
|
|
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
if not _club_exists(cur, club_id):
|
|
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
|
|
_assert_manage(cur, tenant, club_id)
|
|
|
|
cur.execute("SELECT id FROM profiles WHERE id = %s", (body.profile_id,))
|
|
if not cur.fetchone():
|
|
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
|
|
|
|
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
|
|
""",
|
|
(body.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:
|
|
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),
|
|
)
|
|
conn.commit()
|
|
return _one_member(cur, club_id, body.profile_id)
|
|
|
|
|
|
def _one_member(cur, club_id: int, profile_id: int) -> Dict[str, Any]:
|
|
cur.execute(
|
|
"""
|
|
SELECT cm.id AS membership_id, cm.profile_id, cm.status, cm.created_at, cm.updated_at,
|
|
p.email, p.name, COALESCE(p.email_verified, false) AS email_verified,
|
|
lower(trim(COALESCE(p.role, 'user'))) AS portal_role,
|
|
COALESCE(
|
|
ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL),
|
|
ARRAY[]::varchar[]
|
|
) AS roles
|
|
FROM club_members cm
|
|
INNER JOIN profiles p ON p.id = cm.profile_id
|
|
LEFT JOIN club_member_roles r ON r.club_member_id = cm.id
|
|
WHERE cm.club_id = %s AND cm.profile_id = %s
|
|
GROUP BY cm.id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name, p.email_verified, p.role
|
|
""",
|
|
(club_id, profile_id),
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Mitgliedschaft nicht gefunden")
|
|
d = r2d(row)
|
|
roles = d.get("roles") or []
|
|
if hasattr(roles, "tolist"):
|
|
roles = roles.tolist()
|
|
d["roles"] = list(roles)
|
|
return d
|
|
|
|
|
|
@router.get("/clubs/{club_id}/members/{profile_id}")
|
|
def get_club_member(
|
|
club_id: int,
|
|
profile_id: int,
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
|
):
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
if not _club_exists(cur, club_id):
|
|
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
|
|
_assert_manage(cur, tenant, club_id)
|
|
return _one_member(cur, club_id, profile_id)
|
|
|
|
|
|
@router.put("/clubs/{club_id}/members/{profile_id}")
|
|
def update_club_member(
|
|
club_id: int,
|
|
profile_id: int,
|
|
body: ClubMemberPatch,
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
|
):
|
|
"""Rollen ersetzen und/oder Status setzen."""
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
if not _club_exists(cur, club_id):
|
|
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
|
|
_assert_manage(cur, tenant, club_id)
|
|
|
|
cur.execute(
|
|
"SELECT id FROM club_members WHERE club_id = %s AND profile_id = %s",
|
|
(club_id, profile_id),
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Mitgliedschaft nicht gefunden")
|
|
cm_id = row["id"]
|
|
|
|
if body.roles is None and body.status is None:
|
|
return _one_member(cur, club_id, profile_id)
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT cm.status,
|
|
COALESCE(
|
|
ARRAY_AGG(r.role_code) FILTER (WHERE r.role_code IS NOT NULL),
|
|
ARRAY[]::varchar[]
|
|
) AS roles
|
|
FROM club_members cm
|
|
LEFT JOIN club_member_roles r ON r.club_member_id = cm.id
|
|
WHERE cm.id = %s
|
|
GROUP BY cm.status
|
|
""",
|
|
(cm_id,),
|
|
)
|
|
cur_row = cur.fetchone()
|
|
current_status = (cur_row["status"] or "active").strip().lower() if cur_row else "active"
|
|
cr = cur_row.get("roles") if cur_row else []
|
|
if hasattr(cr, "tolist"):
|
|
cr = cr.tolist()
|
|
current_roles = list(cr)
|
|
|
|
new_status = body.status.strip().lower() if body.status is not None else current_status
|
|
if body.status is not None and new_status not in _ALLOWED_STATUS:
|
|
raise HTTPException(status_code=400, detail="status muss active oder inactive sein")
|
|
|
|
new_roles = _normalize_roles(body.roles) if body.roles is not None else current_roles
|
|
if not new_roles:
|
|
raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben")
|
|
|
|
effective_admin = new_status == "active" and "club_admin" in set(new_roles)
|
|
if not effective_admin:
|
|
others = _count_other_active_club_admins(cur, club_id, profile_id)
|
|
if others < 1:
|
|
raise HTTPException(status_code=400, detail=_LAST_CLUB_ADMIN_MSG)
|
|
|
|
if body.status is not None:
|
|
cur.execute(
|
|
"UPDATE club_members SET status = %s, updated_at = NOW() WHERE id = %s",
|
|
(new_status, cm_id),
|
|
)
|
|
|
|
if body.roles is not None:
|
|
cur.execute("DELETE FROM club_member_roles WHERE club_member_id = %s", (cm_id,))
|
|
for rc in new_roles:
|
|
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),
|
|
)
|
|
|
|
conn.commit()
|
|
return _one_member(cur, club_id, profile_id)
|
|
|
|
|
|
@router.delete("/clubs/{club_id}/members/{profile_id}")
|
|
def delete_club_member(
|
|
club_id: int,
|
|
profile_id: int,
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
|
):
|
|
"""Mitgliedschaft löschen (Rollen per CASCADE)."""
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
if not _club_exists(cur, club_id):
|
|
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
|
|
_assert_manage(cur, tenant, club_id)
|
|
|
|
if _member_is_active_club_admin(cur, club_id, profile_id):
|
|
if _count_other_active_club_admins(cur, club_id, profile_id) < 1:
|
|
raise HTTPException(status_code=400, detail=_LAST_CLUB_ADMIN_MSG)
|
|
|
|
cur.execute(
|
|
"DELETE FROM club_members WHERE club_id = %s AND profile_id = %s RETURNING id",
|
|
(club_id, profile_id),
|
|
)
|
|
if not cur.fetchone():
|
|
raise HTTPException(status_code=404, detail="Mitgliedschaft nicht gefunden")
|
|
conn.commit()
|
|
return {"ok": True}
|