"""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)"}