"""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"
  • {escape(mbr.prenom if mbr else '?')}
  • ") 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"
  • {escape(prop.sujet)} ({escape(proposeur.prenom if proposeur else '—')} / {escape(secondeur.prenom if secondeur else 'à confirmer')})
  • " ) 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"
  • {escape(r.numero_resolution or '')} {escape(r.sujet)}
  • ".strip() for r in resolutions ] postes_rows = [f"
  • {escape(p.nom)}
  • " 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, ] if item ]) html = f"""
    {escape(groupe_nom)}
    {escape(coords)}

    Rapport d'assemblée d'affaires

    Date
    {jour.isoformat()}
    Heure
    {reunion.date_reunion.strftime('%H:%M')}
    Présences
    {len(presences_rows)}
    PV
    {'Adopté' if pv and pv.approuve else 'À adopter' if pv else 'À renseigner'}

    Présences

    Réflexions en cours

    Décisions prises

    Postes disponibles

    Procès-verbal

    {escape(pv_contenu)}
    """ 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: 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