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.
201 lines
7.6 KiB
Python
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)"}
|