groupe-meditation/backend/app/routers/contributions.py
Daniel Allaire 6677b0f57b Ajouter historique métier et protection concurrente
Ajoute modifie_le aux objets sensibles et fait transmettre la version affichée par le frontend pour refuser les écritures périmées avec un message HTTP 409.

Ajoute une table historique_modifications et un service d'historique métier afin de journaliser automatiquement les raisons des changements importants sans intervention de l'utilisateur.

Expose l'historique métier dans le module Journal, distinct du journal technique, et documente les processus utilisateurs ainsi qu'une présentation sobre aux membres.
2026-05-31 10:59:41 -04:00

379 lines
15 KiB
Python

"""Routes M4 — Envoi de contributions aux organismes AA."""
from datetime import date
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, func
from app.core.database import get_db
from app.core.decisions import prochain_numero_decision
from app.core.roles import require_tresorier
from app.core.security import is_sysadmin
from app.core.security import get_current_membre
from app.models.envoi_contribution import EnvoiContribution
from app.models.config_repartition import ConfigRepartition
from app.models.membre import Membre
from app.models.destinataire_contribution import DestinataireContribution
from app.services.finance import position_tresorerie, transaction
from app.services.historique import enregistrer_historique, verifier_version_historisee
from app.schemas.phase7 import (
EnvoiContributionCreate, EnvoiContributionOut,
CalculRepartitionIn, CalculRepartitionOut,
DESTINATIONS, DESTINATIONS_LABELS, METHODES_CONTRIBUTION,
DestinataireContributionCreate, DestinataireContributionUpdate, DestinataireContributionOut,
)
router = APIRouter(prefix="/contributions", tags=["M4 — Donner au suivant"])
# Répartition par défaut selon le Guide du groupe
DEFAUT_REPARTITION = {
"district": 30.0,
"region": 30.0,
"bsg": 30.0,
"intergroupe": 10.0,
}
async def _get_repartition(groupe_id, db: AsyncSession) -> dict[str, float]:
"""Récupère la répartition active ou utilise les défauts."""
result = await db.execute(
select(ConfigRepartition)
.where(ConfigRepartition.groupe_id == groupe_id)
.order_by(desc(ConfigRepartition.actif_depuis))
)
configs = result.scalars().all()
# Garder la plus récente par destination
repartition = {}
for c in configs:
if c.destination not in repartition:
repartition[c.destination] = float(c.pourcentage)
# Compléter avec les défauts
for dest in DESTINATIONS:
if dest not in repartition:
repartition[dest] = DEFAUT_REPARTITION[dest]
return repartition
async def _ensure_destinataire_defaut(groupe_id, db: AsyncSession) -> DestinataireContribution:
result = await db.execute(
select(DestinataireContribution).where(
DestinataireContribution.groupe_id == groupe_id,
func.lower(DestinataireContribution.nom) == "district 87-16",
)
)
dest = result.scalar_one_or_none()
if dest:
if not dest.protege or not dest.actif:
dest.protege = True
dest.actif = True
await db.flush()
return dest
dest = DestinataireContribution(groupe_id=groupe_id, nom="District 87-16", protege=True, actif=True)
db.add(dest)
await db.flush()
return dest
async def _get_destinataire(groupe_id, nom: str, db: AsyncSession) -> DestinataireContribution:
nom = (nom or "").strip()
if not nom:
raise HTTPException(status_code=400, detail="Destinataire requis")
await _ensure_destinataire_defaut(groupe_id, db)
result = await db.execute(
select(DestinataireContribution).where(
DestinataireContribution.groupe_id == groupe_id,
func.lower(DestinataireContribution.nom) == nom.lower(),
DestinataireContribution.actif == True,
)
)
dest = result.scalar_one_or_none()
if not dest:
raise HTTPException(status_code=404, detail="Destinataire introuvable")
return dest
@router.get("/destinataires", response_model=list[DestinataireContributionOut])
async def liste_destinataires(
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
await _ensure_destinataire_defaut(membre.groupe_id, db)
result = await db.execute(
select(DestinataireContribution).where(
DestinataireContribution.groupe_id == membre.groupe_id,
DestinataireContribution.actif == True,
).order_by(DestinataireContribution.protege.desc(), DestinataireContribution.nom)
)
return [DestinataireContributionOut.model_validate(d) for d in result.scalars().all()]
@router.post("/destinataires", response_model=DestinataireContributionOut)
async def creer_destinataire(
req: DestinataireContributionCreate,
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
nom = req.nom.strip()
if not nom:
raise HTTPException(status_code=400, detail="Nom requis")
result = await db.execute(
select(DestinataireContribution).where(
DestinataireContribution.groupe_id == membre.groupe_id,
func.lower(DestinataireContribution.nom) == nom.lower(),
)
)
existant = result.scalar_one_or_none()
if existant:
existant.actif = True
await db.flush()
return DestinataireContributionOut.model_validate(existant)
dest = DestinataireContribution(groupe_id=membre.groupe_id, nom=nom, protege=nom.lower() == "district 87-16")
db.add(dest)
await db.flush()
return DestinataireContributionOut.model_validate(dest)
@router.patch("/destinataires/{destinataire_id}", response_model=DestinataireContributionOut)
async def modifier_destinataire(
destinataire_id: str,
req: DestinataireContributionUpdate,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
dest = await db.get(DestinataireContribution, destinataire_id)
if not dest or dest.groupe_id != membre.groupe_id or not dest.actif:
raise HTTPException(status_code=404, detail="Destinataire introuvable")
await verifier_version_historisee(db, dest, version_attendue, table_cible="destinataires_contributions")
if dest.protege:
raise HTTPException(status_code=400, detail="Ce destinataire est protégé")
nom = req.nom.strip()
if not nom:
raise HTTPException(status_code=400, detail="Nom requis")
dest.nom = nom
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="destinataires_contributions",
objet_id=dest.id,
action="destinataire.modifie",
raison="Destinataire de contribution renommé.",
membre_id=membre.id,
)
await db.flush()
return DestinataireContributionOut.model_validate(dest)
@router.delete("/destinataires/{destinataire_id}")
async def supprimer_destinataire(
destinataire_id: str,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
dest = await db.get(DestinataireContribution, destinataire_id)
if not dest or dest.groupe_id != membre.groupe_id or not dest.actif:
raise HTTPException(status_code=404, detail="Destinataire introuvable")
await verifier_version_historisee(db, dest, version_attendue, table_cible="destinataires_contributions")
if dest.protege:
raise HTTPException(status_code=400, detail="Ce destinataire est protégé")
dest.actif = False
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="destinataires_contributions",
objet_id=dest.id,
action="destinataire.supprime",
raison="Destinataire de contribution désactivé.",
membre_id=membre.id,
)
await db.flush()
return {"message": "Destinataire supprimé"}
@router.post("/calculer", response_model=list[CalculRepartitionOut])
async def calculer_repartition(
req: CalculRepartitionIn,
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Calculer la répartition d'un montant selon les % configurés."""
if req.montant_total <= 0:
raise HTTPException(status_code=400, detail="Le montant doit être positif")
repartition = await _get_repartition(membre.groupe_id, db)
return [
CalculRepartitionOut(
destination=dest,
destination_label=DESTINATIONS_LABELS[dest],
pourcentage=pct,
montant=round(req.montant_total * pct / 100, 2),
)
for dest, pct in repartition.items()
]
@router.post("/", response_model=EnvoiContributionOut)
async def enregistrer_envoi(
req: EnvoiContributionCreate,
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""M4 — Enregistrer un envoi de contribution et créer sa résolution."""
from app.models.proposition import Proposition
destination = await _get_destinataire(membre.groupe_id, req.destination, db)
if req.methode.upper() not in METHODES_CONTRIBUTION:
raise HTTPException(status_code=400, detail=f"Méthode invalide. Choix: {METHODES_CONTRIBUTION}")
if req.proposeur_id == req.secondeur_id:
raise HTTPException(status_code=400, detail="Le proposeur et le secondeur doivent être différents")
proposeur = await db.get(Membre, req.proposeur_id)
secondeur = await db.get(Membre, req.secondeur_id)
if not proposeur or proposeur.groupe_id != membre.groupe_id or not proposeur.actif:
raise HTTPException(status_code=404, detail="Proposeur introuvable ou inactif")
if not secondeur or secondeur.groupe_id != membre.groupe_id or not secondeur.actif:
raise HTTPException(status_code=404, detail="Secondeur introuvable ou inactif")
date_decision = req.date_envoi if is_sysadmin(membre) else date.today()
numero_resolution = await prochain_numero_decision(membre.groupe_id, "contribution", date_decision.year, db)
resolution = Proposition(
groupe_id=membre.groupe_id,
sujet=f"Envoi de {req.montant:.2f} $ à {destination.nom}",
description=(
f"Contribution enregistrée le {req.date_envoi} par le module Donner au suivant. "
f"Méthode prévue : {req.methode.upper()}."
),
proposeur_id=req.proposeur_id,
secondeur_id=req.secondeur_id,
statut="adoptee",
numero_resolution=numero_resolution,
date_vote=date_decision,
date_adoption=date_decision,
groupe_a_decide_de=f"Envoyer {req.montant:.2f} $ à {destination.nom}.",
)
db.add(resolution)
await db.flush()
envoi = EnvoiContribution(
groupe_id=membre.groupe_id,
date_envoi=req.date_envoi,
destination=destination.nom,
montant=req.montant,
methode=req.methode.upper(),
resolution_id=resolution.id,
engagee=True,
debitee=False,
saisi_par=membre.id,
)
db.add(envoi)
await db.flush()
return EnvoiContributionOut(
id=envoi.id,
date_envoi=envoi.date_envoi,
destination=envoi.destination,
destination_label=DESTINATIONS_LABELS.get(envoi.destination, envoi.destination),
montant=float(envoi.montant),
methode=envoi.methode,
resolution_id=envoi.resolution_id,
engagee=envoi.engagee,
debitee=envoi.debitee,
saisi_par_prenom=membre.prenom,
cree_le=envoi.cree_le,
modifie_le=envoi.modifie_le,
)
@router.post("/{envoi_id}/confirmer-debit", response_model=EnvoiContributionOut)
async def confirmer_debit_contribution(
envoi_id: str,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Le trésorier confirme que la contribution a été débitée du compte."""
result = await db.execute(
select(EnvoiContribution).where(
EnvoiContribution.id == envoi_id,
EnvoiContribution.groupe_id == membre.groupe_id,
).with_for_update()
)
envoi = result.scalar_one_or_none()
if not envoi:
raise HTTPException(status_code=404, detail="Envoi introuvable")
await verifier_version_historisee(db, envoi, version_attendue, table_cible="envois_contributions")
if envoi.debitee:
raise HTTPException(status_code=400, detail="Contribution déjà débitée")
position = await position_tresorerie(membre.groupe_id, db)
if float(envoi.montant) > position.solde_banque:
raise HTTPException(status_code=400, detail="Le solde en banque est insuffisant pour confirmer ce débit")
if position.solde_banque - float(envoi.montant) < position.total_reserves:
raise HTTPException(status_code=400, detail="Ce débit ferait dépasser les réserves par rapport au solde en banque")
envoi.debitee = True
envoi.engagee = True
db.add(transaction(membre.groupe_id, membre.id, date.today(), "contribution", "banque", envoi.montant, "credit", "envois_contributions", envoi.id, DESTINATIONS_LABELS.get(envoi.destination, envoi.destination)))
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="envois_contributions",
objet_id=envoi.id,
action="contribution.debit_confirme",
raison="Débit bancaire de la contribution confirmé.",
membre_id=membre.id,
)
await db.flush()
saisi = await db.get(Membre, envoi.saisi_par) if envoi.saisi_par else None
return EnvoiContributionOut(
id=envoi.id,
date_envoi=envoi.date_envoi,
destination=envoi.destination,
destination_label=DESTINATIONS_LABELS.get(envoi.destination, envoi.destination),
montant=float(envoi.montant),
methode=envoi.methode,
resolution_id=envoi.resolution_id,
engagee=envoi.engagee,
debitee=envoi.debitee,
saisi_par_prenom=saisi.prenom if saisi else None,
cree_le=envoi.cree_le,
modifie_le=envoi.modifie_le,
)
@router.get("/", response_model=list[EnvoiContributionOut])
async def liste_envois(
limite: int = 30,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Lister les envois de contributions récents."""
result = await db.execute(
select(EnvoiContribution)
.where(EnvoiContribution.groupe_id == membre.groupe_id)
.order_by(desc(EnvoiContribution.date_envoi))
.limit(limite)
)
envois = result.scalars().all()
out = []
for e in envois:
saisi = await db.get(Membre, e.saisi_par) if e.saisi_par else None
out.append(EnvoiContributionOut(
id=e.id,
date_envoi=e.date_envoi,
destination=e.destination,
destination_label=DESTINATIONS_LABELS.get(e.destination, e.destination),
montant=float(e.montant),
methode=e.methode,
resolution_id=e.resolution_id,
engagee=e.engagee,
debitee=e.debitee,
saisi_par_prenom=saisi.prenom if saisi else None,
cree_le=e.cree_le,
modifie_le=e.modifie_le,
))
return out