groupe-meditation/backend/app/routers/membres.py

267 lines
10 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
from app.services.historique import enregistrer_historique
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,
).order_by(Membre.actif.desc(), 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é")
if membre.anonymise:
raise HTTPException(status_code=400, detail="Ce membre a exercé son droit à l'oubli")
if not membre.actif:
return {"message": f"{membre.prenom} est déjà désactivé"}
membre.actif = False
await enregistrer_historique(
db,
groupe_id=executif.groupe_id,
table_cible="membres",
objet_id=membre.id,
action="desactivation_membre",
raison=f"Compte de {membre.prenom} désactivé",
membre_id=executif.id,
chemin_api=f"/membres/{membre_id}/desactiver",
avant={"actif": True},
apres={"actif": False},
)
await db.flush()
return {"message": f"{membre.prenom} a été désactivé"}
@router.patch("/{membre_id}/reactiver")
async def reactiver_membre(
membre_id: str,
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""G2 — Réactiver le droit de connexion d'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é")
if membre.anonymise:
raise HTTPException(status_code=400, detail="Ce membre a exercé son droit à l'oubli et ne peut pas être réactivé")
if membre.actif:
return {"message": f"{membre.prenom} est déjà actif"}
membre.actif = True
await enregistrer_historique(
db,
groupe_id=executif.groupe_id,
table_cible="membres",
objet_id=membre.id,
action="reactivation_membre",
raison=f"Compte de {membre.prenom} réactivé",
membre_id=executif.id,
chemin_api=f"/membres/{membre_id}/reactiver",
avant={"actif": False},
apres={"actif": True},
)
await db.flush()
return {"message": f"{membre.prenom} a été réactivé"}
@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é")
if membre.anonymise:
return {"message": f"Données de {membre.prenom} déjà anonymisées"}
prenom_original = membre.prenom
actif_avant = membre.actif
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.notification_preferences = None
membre.date_abstinence = None
await enregistrer_historique(
db,
groupe_id=executif.groupe_id,
table_cible="membres",
objet_id=membre.id,
action="droit_oubli",
raison=f"Données personnelles de {prenom_original} anonymisées; prénom conservé pour l'historique",
membre_id=executif.id,
chemin_api=f"/membres/{membre_id}/oubli",
avant={"actif": actif_avant, "anonymise": False},
apres={"actif": False, "anonymise": True, "prenom_conserve": prenom_original},
)
await db.flush()
return {"message": f"Données de {prenom_original} anonymisées (droit à l'oubli)"}