shinkan-jinkendo/backend/routers/club_memberships.py
Lars 624c19dcba
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
feat(auth, profiles, club_memberships): enhance password reset and club admin management
- 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.
2026-05-09 10:32:33 +02:00

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}