"""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.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], } @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.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") 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"