267 lines
10 KiB
Python
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)"}
|