groupe-meditation/backend/app/routers/pv.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

197 lines
6.7 KiB
Python

"""Routes M1 — Rédiger un PV de réunion d'affaires."""
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.core.database import get_db
from app.core.roles import require_secretaire
from app.core.security import get_current_membre
from app.models.pv_reunion import PvReunion
from app.models.reunion import Reunion
from app.models.membre import Membre
from app.schemas.phase4 import PvAdoption, PvCreate, PvOut, PvUpdate
from app.services.historique import enregistrer_historique, verifier_version_historisee
router = APIRouter(prefix="/pv", tags=["M1 — Procès-verbal"])
async def _to_out(pv: PvReunion, db: AsyncSession) -> PvOut:
redacteur = await db.get(Membre, pv.redacteur_id)
reunion = await db.get(Reunion, pv.reunion_id) if pv.reunion_id else None
return PvOut(
id=pv.id,
reunion_id=pv.reunion_id,
redacteur_prenom=redacteur.prenom if redacteur else "?",
contenu=pv.contenu,
decisions=pv.decisions,
approuve=pv.approuve,
cree_le=pv.cree_le,
modifie_le=pv.modifie_le,
date_reunion=reunion.date_reunion if reunion else None,
)
@router.get("/", response_model=list[PvOut])
async def liste_pv(
limite: int = 12,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Lister les PV des réunions d'affaires."""
# Récupérer les PV via les réunions du groupe
result = await db.execute(
select(PvReunion)
.join(Reunion, PvReunion.reunion_id == Reunion.id)
.where(Reunion.groupe_id == membre.groupe_id)
.order_by(desc(PvReunion.cree_le))
.limit(limite)
)
return [await _to_out(pv, db) for pv in result.scalars().all()]
@router.get("/{pv_id}", response_model=PvOut)
async def detail_pv(
pv_id: str,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(PvReunion)
.join(Reunion, PvReunion.reunion_id == Reunion.id)
.where(PvReunion.id == pv_id, Reunion.groupe_id == membre.groupe_id)
)
pv = result.scalar_one_or_none()
if not pv:
raise HTTPException(status_code=404, detail="PV introuvable")
return await _to_out(pv, db)
@router.post("/", response_model=PvOut)
async def creer_pv(
req: PvCreate,
membre=Depends(require_secretaire),
db: AsyncSession = Depends(get_db),
):
"""M1 — Rédiger un procès-verbal."""
# Vérifier la réunion
result = await db.execute(
select(Reunion).where(
Reunion.id == req.reunion_id,
Reunion.groupe_id == membre.groupe_id,
)
)
reunion = result.scalar_one_or_none()
if not reunion:
raise HTTPException(status_code=404, detail="Réunion introuvable")
decisions_data = None
if req.decisions:
decisions_data = [d.model_dump() for d in req.decisions]
pv = PvReunion(
reunion_id=req.reunion_id,
redacteur_id=membre.id,
contenu=req.contenu.strip(),
decisions=decisions_data,
)
db.add(pv)
await db.flush()
return await _to_out(pv, db)
@router.patch("/{pv_id}", response_model=PvOut)
async def modifier_pv(
pv_id: str,
req: PvUpdate,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_secretaire),
db: AsyncSession = Depends(get_db),
):
"""Modifier un PV existant."""
result = await db.execute(
select(PvReunion)
.join(Reunion, PvReunion.reunion_id == Reunion.id)
.where(PvReunion.id == pv_id, Reunion.groupe_id == membre.groupe_id)
)
pv = result.scalar_one_or_none()
if not pv:
raise HTTPException(status_code=404, detail="PV introuvable")
await verifier_version_historisee(db, pv, version_attendue, table_cible="pv_reunions")
if req.contenu is not None:
pv.contenu = req.contenu.strip()
if req.decisions is not None:
pv.decisions = [d.model_dump() for d in req.decisions]
if req.approuve is not None:
pv.approuve = req.approuve
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="pv_reunions",
objet_id=pv.id,
action="pv.modifie",
raison="Procès-verbal modifié.",
membre_id=membre.id,
)
await db.flush()
return await _to_out(pv, db)
@router.patch("/{pv_id}/adopter", response_model=PvOut)
async def adopter_pv(
pv_id: str,
req: PvAdoption,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_secretaire),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(PvReunion)
.join(Reunion, PvReunion.reunion_id == Reunion.id)
.where(PvReunion.id == pv_id, Reunion.groupe_id == membre.groupe_id)
)
pv = result.scalar_one_or_none()
if not pv:
raise HTTPException(status_code=404, detail="PV introuvable")
await verifier_version_historisee(db, pv, version_attendue, table_cible="pv_reunions")
if pv.approuve:
raise HTTPException(status_code=400, detail="PV déjà adopté")
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")
if req.contenu is not None:
contenu = req.contenu.strip()
if not contenu:
raise HTTPException(status_code=400, detail="Le contenu du PV ne peut pas être vide")
pv.contenu = contenu
decisions = list(pv.decisions or [])
decisions.append({
"sujet": "Adoption du procès-verbal précédent",
"decision": "Le procès-verbal précédent est adopté.",
"proposeur": proposeur.prenom,
"secondeur": secondeur.prenom,
"proposeur_id": str(proposeur.id),
"secondeur_id": str(secondeur.id),
"statut": "complete",
})
pv.decisions = decisions
pv.approuve = True
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="pv_reunions",
objet_id=pv.id,
action="pv.adopte",
raison="Procès-verbal adopté.",
membre_id=membre.id,
)
await db.flush()
return await _to_out(pv, db)