groupe-meditation/backend/app/routers/presences.py

424 lines
16 KiB
Python
Raw Normal View History

2026-03-04 14:01:00 -05:00
"""Routes M2 — Présences en réunion d'affaires."""
import io
import secrets
from html import escape
from datetime import datetime, timedelta, timezone
2026-03-04 14:01:00 -05:00
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
2026-03-04 14:01:00 -05:00
from sqlalchemy.ext.asyncio import AsyncSession
2026-04-17 04:04:04 -04:00
from sqlalchemy import select, desc, extract
from zoneinfo import ZoneInfo
2026-03-04 14:01:00 -05:00
from app.core.database import get_db
2026-05-25 17:39:38 -04:00
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
2026-03-04 14:01:00 -05:00
from app.models.reunion import Reunion
2026-04-17 04:04:04 -04:00
from app.models.groupe import Groupe
2026-03-04 14:01:00 -05:00
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
2026-03-04 14:01:00 -05:00
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],
}
2026-03-04 14:01:00 -05:00
@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)."""
2026-04-17 04:04:04 -04:00
groupe = await db.get(Groupe, membre.groupe_id)
2026-05-25 17:39:38 -04:00
# ── Validation : réunion d'affaires prévue dans les paramètres du groupe ──
2026-04-17 04:04:04 -04:00
dt = req.date_reunion
date_civile = _date_civile_reunion(dt)
2026-04-17 04:04:04 -04:00
if groupe.jour_reunion:
2026-05-25 17:39:38 -04:00
jour_attendu = jour_reunion_attendu(groupe)
2026-04-17 04:04:04 -04:00
if jour_attendu is not None:
2026-05-25 17:39:38 -04:00
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:
2026-04-17 04:04:04 -04:00
raise HTTPException(
status_code=400,
2026-05-25 17:39:38 -04:00
detail=f"La réunion d'affaires doit avoir lieu à la date prévue ({date_attendue.isoformat()})",
2026-04-17 04:04:04 -04:00
)
# 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,
2026-04-17 04:04:04 -04:00
)
)
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')}",
)
2026-03-04 14:01:00 -05:00
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)
2026-03-04 14:01:00 -05:00
@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,
2026-03-04 14:01:00 -05:00
))
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}"'},
)
2026-03-04 14:01:00 -05:00
@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