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

525 lines
21 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 pydantic import BaseModel
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.models.rapport_adopte import RapportAdopte
from app.schemas.phase4 import (
PresencesBatch, PresenceOut, ReunionAffairesCreate, ReunionAffairesOut,
)
from app.services.rapports_adoptes import rapport_adopte_pour_reunion, verifier_reunion_non_cristallisee
router = APIRouter(prefix="/presences", tags=["M2 — Présences affaires"])
class RapportAssembleeAdoption(BaseModel):
proposeur_id: str
secondeur_id: str
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.groupe_id == groupe_id,
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],
}
async def _html_rapport_assemblee(reunion: Reunion, membre, db: AsyncSession) -> tuple[str, datetime.date]:
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)]
mois_precedent = jour.month - 1
annee_precedente = jour.year
if mois_precedent == 0:
mois_precedent = 12
annee_precedente -= 1
rapport_tresorier = (await db.execute(
select(RapportAdopte).where(
RapportAdopte.groupe_id == membre.groupe_id,
RapportAdopte.type_rapport == "tresorerie",
RapportAdopte.annee == annee_precedente,
RapportAdopte.mois == mois_precedent,
).limit(1)
)).scalar_one_or_none()
tresorier_html = (
f"<p>Rapport adopté: {escape(rapport_tresorier.titre)}</p>"
if rapport_tresorier else "<p>Rapport du trésorier à adopter avant les contributions.</p>"
)
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,
] 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>Rapport du trésorier</h2>{tresorier_html}
<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>"""
return html, jour
@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,
))
rapport_adopte = await rapport_adopte_pour_reunion(db, membre.groupe_id, r.id, "assemblee")
out.append(ReunionAffairesOut(
id=r.id,
date_reunion=r.date_reunion,
note=r.note,
modifie_le=r.modifie_le,
rapport_adopte_id=rapport_adopte.id if rapport_adopte else None,
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.groupe_id != invitation.groupe_id 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")
adopte = await rapport_adopte_pour_reunion(db, membre.groupe_id, reunion.id, "assemblee")
if adopte:
pdf_bytes = HTML(string=adopte.contenu_html).write_pdf()
filename = f"rapport_assemblee_{reunion.date_reunion.date().isoformat()}.pdf"
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{filename}"'},
)
html, jour = await _html_rapport_assemblee(reunion, membre, db)
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("/reunions/{reunion_id}/adopter")
async def adopter_rapport_assemblee(
reunion_id: str,
req: RapportAssembleeAdoption,
membre=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Cristalliser le rapport d'assemblée adopté par le groupe."""
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")
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")
if await rapport_adopte_pour_reunion(db, membre.groupe_id, reunion.id, "assemblee"):
raise HTTPException(status_code=400, detail="Cette assemblée est déjà adoptée")
html, jour = await _html_rapport_assemblee(reunion, membre, db)
rapport = RapportAdopte(
groupe_id=membre.groupe_id,
adopte_par_id=membre.id,
type_rapport="assemblee",
titre=f"Rapport d'assemblée — {jour.isoformat()}",
annee=jour.year,
mois=jour.month,
contenu_html=html,
donnees={
"reunion_id": str(reunion.id),
"date": jour.isoformat(),
"proposeur_id": str(proposeur.id),
"secondeur_id": str(secondeur.id),
"proposeur_prenom": proposeur.prenom,
"secondeur_prenom": secondeur.prenom,
},
)
db.add(rapport)
await db.flush()
return {"id": str(rapport.id), "titre": rapport.titre, "adopte_le": rapport.adopte_le}
@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")
await verifier_reunion_non_cristallisee(
db, membre.groupe_id, reunion.id, "assemblee", "Cette assemblée est adoptée"
)
# 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:
mbr = await db.get(Membre, item.membre_id)
if not mbr or mbr.groupe_id != membre.groupe_id or not mbr.actif:
raise HTTPException(status_code=404, detail="Membre introuvable")
pres = Presence(
reunion_id=req.reunion_id,
membre_id=item.membre_id,
present=item.present,
)
db.add(pres)
out.append(PresenceOut(
membre_id=item.membre_id,
membre_prenom=mbr.prenom,
present=item.present,
))
await db.flush()
return out