groupe-meditation/backend/app/routers/membres.py
Daniel Allaire 69309acd13 Intégrer rencontres et assemblées pilotées par tuiles
Refond les pages Rencontres et Assemblées autour de tuiles ouvrables avec actions locales, PDF serveur, ventes, collectes, présences, décisions, postes et contributions.

Ajoute les invitations publiques d'assemblée en lecture seule avec lien expirant, l'adoption formelle du rapport du trésorier avec proposeur/secondeur, et bloque les contributions tant que ce rapport n'est pas adopté.

Uniformise la navigation et les entêtes, retire les modules redondants des contextes, ajoute la création de comptes membres par exécutif et les données démo reliées aux nouveaux processus.

Ce commit capture un lot fonctionnel important; une refactorisation ciblée du module Assemblées, puis Trésorerie/Gouvernance, est maintenant pertinente pour isoler les règles métier et faciliter les tests.
2026-05-31 09:02:02 -04:00

201 lines
7.6 KiB
Python

"""Routes membres — profil et gestion."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.database import get_db
from app.core.security import get_current_membre, require_executif, hash_pin, verify_pin, is_sysadmin
from app.models.membre import Membre
from app.schemas.schemas import MembreCreateAdmin, MembrePublic, MembreComplet, MembreUpdate, ChangerPinRequest
router = APIRouter(prefix="/membres", tags=["Membres"])
@router.get("/moi", response_model=MembreComplet)
async def mon_profil(membre=Depends(get_current_membre)):
"""C7 — Mon profil."""
return MembreComplet.model_validate(membre)
@router.patch("/moi", response_model=MembreComplet)
async def modifier_profil(
updates: MembreUpdate,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""C7 — Modifier mon profil."""
if updates.prenom is not None:
prenom = updates.prenom.strip()
if not prenom:
raise HTTPException(status_code=400, detail="Le prénom est obligatoire")
membre.prenom = prenom.title()
if updates.nom is not None:
membre.nom = updates.nom.strip().title() if updates.nom.strip() else None
if updates.telephone is not None:
telephone = updates.telephone.strip()
if not telephone:
raise HTTPException(status_code=400, detail="Le téléphone est obligatoire")
membre.telephone = telephone
if updates.courriel is not None:
courriel = updates.courriel.strip().lower()
if not courriel:
raise HTTPException(status_code=400, detail="Identifiant invalide")
if is_sysadmin(membre) and courriel != "sysadmin":
raise HTTPException(status_code=400, detail="L'identifiant Sysadmin est protégé")
if not is_sysadmin(membre) and "@" not in courriel:
raise HTTPException(status_code=400, detail="Courriel invalide")
result = await db.execute(
select(Membre).where(
func.lower(Membre.courriel) == courriel,
Membre.groupe_id == membre.groupe_id,
Membre.id != membre.id,
Membre.actif == True,
)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Cet identifiant est déjà utilisé dans ce groupe")
membre.courriel = courriel
if updates.date_abstinence is not None:
membre.date_abstinence = updates.date_abstinence
await db.flush()
return MembreComplet.model_validate(membre)
@router.post("/moi/pin")
async def changer_pin(
req: ChangerPinRequest,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""C7 — Changer mon PIN."""
if not verify_pin(req.ancien_pin, membre.pin_hash):
raise HTTPException(status_code=400, detail="Ancien PIN incorrect")
if not req.nouveau_pin.isdigit() or len(req.nouveau_pin) != 4:
raise HTTPException(status_code=400, detail="Le PIN doit être composé de 4 chiffres")
membre.pin_hash = hash_pin(req.nouveau_pin)
await db.flush()
return {"message": "PIN modifié avec succès"}
@router.get("/actifs", response_model=list[MembrePublic])
async def liste_membres_actifs(
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Liste simplifiée des membres actifs (prénom + id) — pour les dropdowns."""
result = await db.execute(
select(Membre).where(
Membre.groupe_id == membre.groupe_id,
Membre.actif == True,
).order_by(Membre.prenom)
)
return [MembrePublic.model_validate(m) for m in result.scalars().all()]
@router.get("/", response_model=list[MembreComplet])
async def liste_membres(
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""G2 — Liste des membres (exécutif seulement)."""
result = await db.execute(
select(Membre).where(
Membre.groupe_id == executif.groupe_id,
Membre.actif == True,
).order_by(Membre.prenom)
)
return [MembreComplet.model_validate(m) for m in result.scalars().all()]
@router.post("/", response_model=MembreComplet)
async def creer_membre(
req: MembreCreateAdmin,
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Créer un compte membre sans invitation."""
prenom = req.prenom.strip()
telephone = req.telephone.strip()
courriel = req.courriel.strip().lower()
if not prenom:
raise HTTPException(status_code=400, detail="Le prénom est obligatoire")
if not telephone:
raise HTTPException(status_code=400, detail="Le téléphone est obligatoire")
if not courriel or "@" not in courriel:
raise HTTPException(status_code=400, detail="Courriel invalide")
if not req.pin.isdigit() or len(req.pin) != 4:
raise HTTPException(status_code=400, detail="Le PIN doit être composé de 4 chiffres")
result = await db.execute(
select(Membre).where(
func.lower(Membre.courriel) == courriel,
Membre.groupe_id == executif.groupe_id,
Membre.actif == True,
)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Ce courriel est déjà utilisé dans ce groupe")
membre = Membre(
groupe_id=executif.groupe_id,
prenom=prenom.title(),
nom=req.nom.strip().title() if req.nom and req.nom.strip() else None,
telephone=telephone,
courriel=courriel,
date_abstinence=req.date_abstinence,
pin_hash=hash_pin(req.pin),
actif=True,
)
db.add(membre)
await db.flush()
return MembreComplet.model_validate(membre)
@router.patch("/{membre_id}/desactiver")
async def desactiver_membre(
membre_id: str,
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""G2 — Désactiver un membre."""
result = await db.execute(
select(Membre).where(Membre.id == membre_id, Membre.groupe_id == executif.groupe_id)
)
membre = result.scalar_one_or_none()
if not membre:
raise HTTPException(status_code=404, detail="Membre introuvable")
if is_sysadmin(membre):
raise HTTPException(status_code=400, detail="Le compte Sysadmin est protégé")
membre.actif = False
await db.flush()
return {"message": f"{membre.prenom} a été désactivé"}
@router.delete("/{membre_id}/oubli")
async def droit_oubli(
membre_id: str,
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""G2 — Droit à l'oubli : anonymise les données personnelles."""
result = await db.execute(
select(Membre).where(Membre.id == membre_id, Membre.groupe_id == executif.groupe_id)
)
membre = result.scalar_one_or_none()
if not membre:
raise HTTPException(status_code=404, detail="Membre introuvable")
if str(membre.id) == str(executif.id):
raise HTTPException(status_code=400, detail="Impossible de supprimer votre propre compte")
if is_sysadmin(membre):
raise HTTPException(status_code=400, detail="Le compte Sysadmin est protégé")
prenom_original = membre.prenom
# Anonymiser
membre.prenom = "Ancien membre"
membre.nom = None
membre.telephone = "000-000-0000"
membre.courriel = f"supprime_{membre.id}@anon.local"
membre.pin_hash = "SUPPRIME"
membre.actif = False
membre.push_sub = None
membre.date_abstinence = None
await db.flush()
return {"message": f"Données de {prenom_original} anonymisées (droit à l'oubli)"}