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

423 lines
16 KiB
Python

"""Routes M2 — Présences en réunion d'affaires."""
import io
import secrets
from html import escape
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, extract
from zoneinfo import ZoneInfo
from app.core.database import get_db
from app.core.roles import require_secretaire
from app.core.reunions import date_assemblee_affaires_du_mois, jour_reunion_attendu
from app.core.security import get_current_membre, require_executif
from app.models.invitation import Invitation
from app.models.reunion import Reunion
from app.models.groupe import Groupe
from app.models.presence import Presence
from app.models.membre import Membre
from app.models.pv_reunion import PvReunion
from app.models.proposition import Proposition
from app.models.poste import Poste
from app.models.affectation import Affectation
from app.schemas.phase4 import (
PresencesBatch, PresenceOut, ReunionAffairesCreate, ReunionAffairesOut,
)
router = APIRouter(prefix="/presences", tags=["M2 — Présences affaires"])
def _date_civile_reunion(valeur):
if getattr(valeur, "tzinfo", None):
return valeur.astimezone(ZoneInfo("America/Toronto")).date()
return valeur.date()
async def _postes_vacants(groupe_id, db: AsyncSession):
result = await db.execute(select(Poste).where(Poste.actif == True).order_by(Poste.ordre_affichage))
postes = result.scalars().all()
vacants = []
for poste in postes:
affectation = (await db.execute(
select(Affectation).where(
Affectation.groupe_id == groupe_id,
Affectation.poste_id == poste.id,
Affectation.statut == "actif",
).limit(1)
)).scalar_one_or_none()
if not affectation:
vacants.append(poste)
return vacants
async def _assemblee_publique(reunion: Reunion, db: AsyncSession):
groupe = await db.get(Groupe, reunion.groupe_id)
jour = _date_civile_reunion(reunion.date_reunion)
pv = (await db.execute(
select(PvReunion).where(PvReunion.reunion_id == reunion.id).order_by(desc(PvReunion.cree_le)).limit(1)
)).scalar_one_or_none()
propositions = (await db.execute(
select(Proposition).where(
Proposition.groupe_id == reunion.groupe_id,
Proposition.statut.in_(["proposee", "secondee", "rejetee"]),
).order_by(Proposition.cree_le)
)).scalars().all()
resolutions = (await db.execute(
select(Proposition).where(
Proposition.groupe_id == reunion.groupe_id,
Proposition.statut == "adoptee",
Proposition.date_vote == jour,
).order_by(Proposition.numero_resolution)
)).scalars().all()
postes = await _postes_vacants(reunion.groupe_id, db)
return {
"groupe": {
"nom": groupe.nom if groupe else "Groupe",
"adresse_local": groupe.adresse_local if groupe else None,
},
"assemblee": {
"id": str(reunion.id),
"date_reunion": reunion.date_reunion,
"note": reunion.note,
},
"pv": {
"approuve": pv.approuve,
"contenu": pv.contenu,
} if pv else None,
"propositions": [
{
"id": str(p.id),
"sujet": p.sujet,
"description": p.description,
"statut": p.statut,
}
for p in propositions
],
"decisions_du_jour": [
{
"numero_resolution": p.numero_resolution,
"sujet": p.sujet,
"decision": p.groupe_a_decide_de or p.sujet,
}
for p in resolutions
],
"postes_disponibles": [{"id": str(p.id), "nom": p.nom, "description": p.description} for p in postes],
}
@router.post("/reunion", response_model=ReunionAffairesOut)
async def creer_reunion_affaires(
req: ReunionAffairesCreate,
membre=Depends(require_secretaire),
db: AsyncSession = Depends(get_db),
):
"""Créer une réunion d'affaires (point de départ pour présences et PV)."""
groupe = await db.get(Groupe, membre.groupe_id)
# ── Validation : réunion d'affaires prévue dans les paramètres du groupe ──
dt = req.date_reunion
date_civile = _date_civile_reunion(dt)
if groupe.jour_reunion:
jour_attendu = jour_reunion_attendu(groupe)
if jour_attendu is not None:
ordre = groupe.assemblee_affaires_ordre or "premiere"
date_attendue = date_assemblee_affaires_du_mois(date_civile.year, date_civile.month, jour_attendu, ordre)
if date_civile != date_attendue:
raise HTTPException(
status_code=400,
detail=f"La réunion d'affaires doit avoir lieu à la date prévue ({date_attendue.isoformat()})",
)
# Une seule réunion d'affaires par mois
result = await db.execute(
select(Reunion).where(
Reunion.groupe_id == membre.groupe_id,
Reunion.type == "affaires",
extract("year", Reunion.date_reunion) == date_civile.year,
extract("month", Reunion.date_reunion) == date_civile.month,
)
)
if result.scalars().first():
raise HTTPException(
status_code=400,
detail=f"Il y a déjà une réunion d'affaires pour {dt.strftime('%B %Y')}",
)
reunion = Reunion(
groupe_id=membre.groupe_id,
type="affaires",
date_reunion=req.date_reunion,
note=req.note,
)
db.add(reunion)
await db.flush()
return ReunionAffairesOut(id=reunion.id, date_reunion=reunion.date_reunion, note=reunion.note, modifie_le=reunion.modifie_le)
@router.get("/reunions", response_model=list[ReunionAffairesOut])
async def liste_reunions_affaires(
limite: int = 12,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Lister les réunions d'affaires récentes avec leurs présences."""
result = await db.execute(
select(Reunion).where(
Reunion.groupe_id == membre.groupe_id,
Reunion.type == "affaires",
).order_by(desc(Reunion.date_reunion)).limit(limite)
)
reunions = result.scalars().all()
out = []
for r in reunions:
pres_result = await db.execute(
select(Presence).where(Presence.reunion_id == r.id)
)
presences = pres_result.scalars().all()
pres_out = []
for p in presences:
mbr = await db.get(Membre, p.membre_id)
pres_out.append(PresenceOut(
membre_id=p.membre_id,
membre_prenom=mbr.prenom if mbr else "?",
present=p.present,
))
out.append(ReunionAffairesOut(
id=r.id, date_reunion=r.date_reunion, note=r.note, modifie_le=r.modifie_le, presences=pres_out,
))
return out
@router.post("/reunions/{reunion_id}/invitation")
async def creer_invitation_assemblee(
reunion_id: str,
membre=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
reunion = (await db.execute(
select(Reunion).where(
Reunion.id == reunion_id,
Reunion.groupe_id == membre.groupe_id,
Reunion.type == "affaires",
)
)).scalar_one_or_none()
if not reunion:
raise HTTPException(status_code=404, detail="Assemblée introuvable")
code = secrets.token_hex(6).upper()
invitation = Invitation(
groupe_id=membre.groupe_id,
code=code,
cree_par=membre.id,
expire_le=datetime.now(timezone.utc) + timedelta(days=14),
cible_type="assemblee",
cible_id=reunion.id,
)
db.add(invitation)
await db.flush()
return {
"code": invitation.code,
"expire_le": invitation.expire_le,
"path": f"/invite/assemblee/{invitation.code}",
}
@router.get("/invite/{code}")
async def lire_assemblee_invite(
code: str,
db: AsyncSession = Depends(get_db),
):
invitation = (await db.execute(
select(Invitation).where(
Invitation.code == code,
Invitation.cible_type == "assemblee",
)
)).scalar_one_or_none()
if not invitation:
raise HTTPException(status_code=404, detail="Invitation introuvable")
if invitation.expire_le < datetime.now(timezone.utc):
raise HTTPException(status_code=410, detail="Invitation expirée")
reunion = await db.get(Reunion, invitation.cible_id)
if not reunion or reunion.type != "affaires":
raise HTTPException(status_code=404, detail="Assemblée introuvable")
return await _assemblee_publique(reunion, db)
@router.get("/reunions/{reunion_id}/rapport.pdf")
async def rapport_assemblee_pdf(
reunion_id: str,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Produire le rapport PDF d'une assemblée d'affaires."""
try:
from weasyprint import HTML
except ImportError:
raise HTTPException(status_code=500, detail="WeasyPrint non installé")
reunion = (await db.execute(
select(Reunion).where(
Reunion.id == reunion_id,
Reunion.groupe_id == membre.groupe_id,
Reunion.type == "affaires",
)
)).scalar_one_or_none()
if not reunion:
raise HTTPException(status_code=404, detail="Assemblée introuvable")
groupe = await db.get(Groupe, membre.groupe_id)
jour = _date_civile_reunion(reunion.date_reunion)
presences = (await db.execute(
select(Presence).where(Presence.reunion_id == reunion.id)
)).scalars().all()
presences_rows = []
for presence in presences:
mbr = await db.get(Membre, presence.membre_id)
if presence.present:
presences_rows.append(f"<li>{escape(mbr.prenom if mbr else '?')}</li>")
pv = (await db.execute(
select(PvReunion).where(PvReunion.reunion_id == reunion.id).order_by(desc(PvReunion.cree_le)).limit(1)
)).scalar_one_or_none()
propositions = (await db.execute(
select(Proposition).where(
Proposition.groupe_id == membre.groupe_id,
Proposition.statut == "secondee",
).order_by(Proposition.cree_le)
)).scalars().all()
propositions_rows = []
for prop in propositions:
proposeur = await db.get(Membre, prop.proposeur_id) if prop.proposeur_id else None
secondeur = await db.get(Membre, prop.secondeur_id) if prop.secondeur_id else None
propositions_rows.append(
f"<li>{escape(prop.sujet)} ({escape(proposeur.prenom if proposeur else '—')} / {escape(secondeur.prenom if secondeur else 'à confirmer')})</li>"
)
resolutions = (await db.execute(
select(Proposition).where(
Proposition.groupe_id == membre.groupe_id,
Proposition.statut == "adoptee",
Proposition.date_vote == jour,
).order_by(Proposition.numero_resolution)
)).scalars().all()
resolutions_rows = [
f"<li>{escape(r.numero_resolution or '')} {escape(r.sujet)}</li>".strip()
for r in resolutions
]
postes_rows = [f"<li>{escape(p.nom)}</li>" for p in await _postes_vacants(membre.groupe_id, db)]
pv_contenu = pv.contenu if pv else "\n".join([
"Ouverture de l'assemblée.",
"",
"Lecture du procès-verbal précédent.",
"",
"Réflexions en cours:",
*[f"- {p.sujet}" for p in propositions],
"",
"Décisions prises pendant cette assemblée:",
*[f"- {r.numero_resolution or ''} {r.sujet}".strip() for r in resolutions],
"",
"Postes disponibles:",
*[f"- {p.nom}" for p in await _postes_vacants(membre.groupe_id, db)],
])
css = """
body { font-family: 'DejaVu Sans', sans-serif; font-size: 10.5pt; color: #27323a; margin: 1.6cm; }
.header { border-bottom: 3px solid #3f3c8f; padding-bottom: 12pt; margin-bottom: 18pt; }
.groupe { color: #3f3c8f; font-size: 20pt; font-weight: 800; }
.coords { color: #667; font-size: 9pt; margin-top: 3pt; }
h1 { font-size: 16pt; color: #3f3c8f; margin: 0 0 12pt; }
h2 { font-size: 12pt; color: #3f3c8f; margin: 16pt 0 6pt; border-bottom: 1px solid #dedcf4; padding-bottom: 3pt; }
.meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8pt; }
.box { border: 1px solid #dedcf4; border-radius: 6pt; padding: 8pt; background: #fafaff; }
.label { font-size: 8pt; color: #667; text-transform: uppercase; font-weight: 700; }
.value { font-size: 11pt; font-weight: 700; margin-top: 2pt; }
ul { margin-top: 4pt; }
li { margin-bottom: 3pt; }
.pv { white-space: pre-wrap; border: 1px solid #e5e7eb; border-radius: 6pt; padding: 8pt; background: #fcfcfd; }
.footer { margin-top: 20pt; color: #889; font-size: 8pt; text-align: center; }
"""
groupe_nom = groupe.nom if groupe and groupe.nom else "Groupe"
coords = " · ".join([
item for item in [
groupe.adresse_local if groupe else None,
f"District {groupe.district}" if groupe and groupe.district else None,
f"Région {groupe.region}" if groupe and groupe.region else None,
] if item
])
html = f"""<!doctype html><html><head><meta charset="utf-8"><style>{css}</style></head><body>
<div class="header">
<div class="groupe">{escape(groupe_nom)}</div>
<div class="coords">{escape(coords)}</div>
</div>
<h1>Rapport d'assemblée d'affaires</h1>
<div class="meta">
<div class="box"><div class="label">Date</div><div class="value">{jour.isoformat()}</div></div>
<div class="box"><div class="label">Heure</div><div class="value">{reunion.date_reunion.strftime('%H:%M')}</div></div>
<div class="box"><div class="label">Présences</div><div class="value">{len(presences_rows)}</div></div>
<div class="box"><div class="label">PV</div><div class="value">{'Adopté' if pv and pv.approuve else 'À adopter' if pv else 'À renseigner'}</div></div>
</div>
<h2>Présences</h2><ul>{"".join(presences_rows) or "<li>Présences à cocher sur papier.</li>"}</ul>
<h2>Réflexions en cours</h2><ul>{"".join(propositions_rows) or "<li>Aucune proposition secondée non résolue.</li>"}</ul>
<h2>Décisions prises</h2><ul>{"".join(resolutions_rows) or "<li>Aucune décision enregistrée pour cette date.</li>"}</ul>
<h2>Postes disponibles</h2><ul>{"".join(postes_rows) or "<li>Aucun poste vacant.</li>"}</ul>
<h2>Procès-verbal</h2><div class="pv">{escape(pv_contenu)}</div>
<div class="footer">Rapport généré automatiquement · {datetime.now().strftime('%Y-%m-%d %H:%M')}</div>
</body></html>"""
pdf_bytes = HTML(string=html).write_pdf()
filename = f"rapport_assemblee_{jour.isoformat()}.pdf"
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{filename}"'},
)
@router.post("/", response_model=list[PresenceOut])
async def enregistrer_presences(
req: PresencesBatch,
membre=Depends(require_secretaire),
db: AsyncSession = Depends(get_db),
):
"""M2 — Enregistrer les présences pour une réunion d'affaires."""
# Vérifier que la réunion existe et est de type affaires
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")
# Supprimer les présences existantes pour cette réunion (pour pouvoir re-saisir)
existing = await db.execute(
select(Presence).where(Presence.reunion_id == req.reunion_id)
)
for p in existing.scalars().all():
await db.delete(p)
# Insérer les nouvelles
out = []
for item in req.presences:
pres = Presence(
reunion_id=req.reunion_id,
membre_id=item.membre_id,
present=item.present,
)
db.add(pres)
mbr = await db.get(Membre, item.membre_id)
out.append(PresenceOut(
membre_id=item.membre_id,
membre_prenom=mbr.prenom if mbr else "?",
present=item.present,
))
await db.flush()
return out