"""Routes S4 — Rapport de réunion.""" import io from html import escape from datetime import datetime, timezone from zoneinfo import ZoneInfo from fastapi import APIRouter, Depends, Header, HTTPException from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc, extract from app.core.database import get_db from app.core.reunions import jour_reunion_attendu from app.core.security import get_current_membre, require_executif from app.models.reunion import Reunion from app.models.collecte import Collecte from app.models.groupe import Groupe from app.models.membre import Membre from app.models.jeton import Jeton from app.models.litterature import Litterature from app.models.vente_jeton import VenteJeton from app.models.vente_litterature import VenteLitterature from app.schemas.phase1 import ReunionCreate, ReunionOut from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee router = APIRouter(prefix="/reunions", tags=["S4 — Rapport de réunion"]) def _date_civile_reunion(valeur: datetime): if valeur.tzinfo: return valeur.astimezone(ZoneInfo("America/Toronto")).date() return valeur.date() @router.post("/", response_model=ReunionOut) async def creer_rapport_reunion( req: ReunionCreate, membre=Depends(require_executif), db: AsyncSession = Depends(get_db), ): """S4 — L'animateur note le thème et les annonces.""" groupe = await db.get(Groupe, membre.groupe_id) # ── Validation : réunion régulière ── if req.type == "reguliere" and groupe.jour_reunion: date_civile = _date_civile_reunion(req.date_reunion) jour_attendu = jour_reunion_attendu(groupe) jour_soumis = date_civile.weekday() if jour_attendu is not None and jour_soumis != jour_attendu: raise HTTPException( status_code=400, detail=f"La réunion régulière doit avoir lieu le {groupe.jour_reunion}", ) # Une seule régulière par semaine ISO iso_year, iso_week, _ = date_civile.isocalendar() result = await db.execute( select(Reunion).where( Reunion.groupe_id == membre.groupe_id, Reunion.type == "reguliere", extract("isoyear", Reunion.date_reunion) == iso_year, extract("week", Reunion.date_reunion) == iso_week, ) ) if result.scalars().first(): raise HTTPException( status_code=400, detail=f"Il y a déjà une réunion régulière pour la semaine {iso_week}", ) animateur = membre if req.animateur_id: animateur = await db.get(Membre, req.animateur_id) if not animateur or animateur.groupe_id != membre.groupe_id or not animateur.actif: raise HTTPException(status_code=404, detail="Animateur introuvable ou inactif") reunion = Reunion( groupe_id=membre.groupe_id, type=req.type, date_reunion=req.date_reunion, animateur_id=animateur.id, theme=req.theme, annonces=req.annonces, note=req.note, ) db.add(reunion) await db.flush() return ReunionOut( id=reunion.id, type=reunion.type, date_reunion=reunion.date_reunion, animateur_prenom=animateur.prenom, theme=reunion.theme, annonces=reunion.annonces, note=reunion.note, cree_le=reunion.cree_le, modifie_le=reunion.modifie_le, ) @router.get("/", response_model=list[ReunionOut]) async def liste_reunions( limite: int = 20, membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db), ): """Lister les réunions récentes du groupe.""" result = await db.execute( select(Reunion) .where(Reunion.groupe_id == membre.groupe_id) .order_by(desc(Reunion.date_reunion)) .limit(limite) ) reunions = result.scalars().all() out = [] for r in reunions: anim = await db.get(Membre, r.animateur_id) if r.animateur_id else None out.append(ReunionOut( id=r.id, type=r.type, date_reunion=r.date_reunion, animateur_prenom=anim.prenom if anim else None, theme=r.theme, annonces=r.annonces, note=r.note, cree_le=r.cree_le, modifie_le=r.modifie_le, )) return out @router.get("/rencontres/details") async def liste_rencontres_details( limite: int = 80, membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db), ): """Lister les rencontres régulières avec leurs saisies associées.""" result = await db.execute( select(Reunion) .where(Reunion.groupe_id == membre.groupe_id, Reunion.type == "reguliere") .order_by(desc(Reunion.date_reunion)) .limit(limite) ) rencontres = [] for reunion in result.scalars().all(): jour = reunion.date_reunion.date() animateur = await db.get(Membre, reunion.animateur_id) if reunion.animateur_id else None collecte_result = await db.execute( select(Collecte).where( Collecte.groupe_id == membre.groupe_id, (Collecte.reunion_id == reunion.id) | (Collecte.date_collecte == jour), ).limit(1) ) collecte = collecte_result.scalar_one_or_none() collecte_out = None if collecte: detenteur = await db.get(Membre, collecte.detenteur_id) if collecte.detenteur_id else None collecte_out = { "id": str(collecte.id), "montant": float(collecte.montant), "statut": collecte.statut, "detenteur_prenom": detenteur.prenom if detenteur else None, "modifie_le": collecte.modifie_le, } ventes_lit_result = await db.execute( select(VenteLitterature).where( VenteLitterature.groupe_id == membre.groupe_id, (VenteLitterature.reunion_id == reunion.id) | (VenteLitterature.date_vente == jour), ) ) ventes_litterature = [] for vente in ventes_lit_result.scalars().all(): item = await db.get(Litterature, vente.litterature_id) vendeur = await db.get(Membre, vente.vendu_par) if vente.vendu_par else None ventes_litterature.append({ "id": str(vente.id), "titre": item.titre if item else "?", "quantite": vente.quantite, "montant": float(vente.montant), "vendu_par_prenom": vendeur.prenom if vendeur else None, "remis_tresorier": vente.remis_tresorier, }) ventes_jetons_result = await db.execute( select(VenteJeton).where( VenteJeton.groupe_id == membre.groupe_id, (VenteJeton.reunion_id == reunion.id) | (VenteJeton.date_vente == jour), ) ) ventes_jetons = [] for vente in ventes_jetons_result.scalars().all(): vendeur = await db.get(Membre, vente.vendu_par) if vente.vendu_par else None ventes_jetons.append({ "id": str(vente.id), "type_jeton": vente.type_jeton, "quantite": vente.quantite, "montant": float(vente.montant), "vendu_par_prenom": vendeur.prenom if vendeur else None, "remis_tresorier": vente.remis_tresorier, }) jetons_result = await db.execute( select(Jeton).where( Jeton.groupe_id == membre.groupe_id, (Jeton.reunion_id == reunion.id) | (Jeton.date_remise == jour), ) ) jetons = [] for jeton in jetons_result.scalars().all(): cible = await db.get(Membre, jeton.membre_id) if jeton.membre_id else None jetons.append({ "id": str(jeton.id), "type_jeton": jeton.type_jeton, "membre_prenom": cible.prenom if cible else None, "annees": jeton.annees, }) rencontres.append({ "id": str(reunion.id), "type": reunion.type, "date_reunion": reunion.date_reunion, "animateur_prenom": animateur.prenom if animateur else None, "theme": reunion.theme, "annonces": reunion.annonces, "note": reunion.note, "modifie_le": reunion.modifie_le, "collecte": collecte_out, "ventes_litterature": ventes_litterature, "ventes_jetons": ventes_jetons, "jetons": jetons, }) return rencontres @router.patch("/{reunion_id}", response_model=ReunionOut) async def modifier_reunion( reunion_id: str, req: ReunionCreate, version_attendue: str | None = Header(default=None, alias="X-Resource-Version"), membre=Depends(require_executif), db: AsyncSession = Depends(get_db), ): result = await db.execute( select(Reunion).where(Reunion.id == 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_version_historisee(db, reunion, version_attendue, table_cible="reunions") avant = snapshot(reunion, "animateur_id", "theme", "annonces", "note") animateur = None if req.animateur_id: animateur = await db.get(Membre, req.animateur_id) if not animateur or animateur.groupe_id != membre.groupe_id or not animateur.actif: raise HTTPException(status_code=404, detail="Animateur introuvable ou inactif") reunion.animateur_id = animateur.id reunion.theme = req.theme reunion.annonces = req.annonces reunion.note = req.note anim = animateur or (await db.get(Membre, reunion.animateur_id) if reunion.animateur_id else None) await enregistrer_historique( db, groupe_id=membre.groupe_id, table_cible="reunions", objet_id=reunion.id, action="reunion.modifiee", raison="Rencontre modifiée.", membre_id=membre.id, avant=avant, apres=snapshot(reunion, "animateur_id", "theme", "annonces", "note"), ) await db.flush() return ReunionOut( id=reunion.id, type=reunion.type, date_reunion=reunion.date_reunion, animateur_prenom=anim.prenom if anim else None, theme=reunion.theme, annonces=reunion.annonces, note=reunion.note, cree_le=reunion.cree_le, modifie_le=reunion.modifie_le, ) @router.get("/{reunion_id}/rapport.pdf") async def rapport_reunion_pdf( reunion_id: str, membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db), ): try: from weasyprint import HTML except ImportError: raise HTTPException(status_code=500, detail="WeasyPrint non installé") result = await db.execute( select(Reunion).where(Reunion.id == 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") groupe = await db.get(Groupe, membre.groupe_id) animateur = await db.get(Membre, reunion.animateur_id) if reunion.animateur_id else None jour = reunion.date_reunion.date() collecte = (await db.execute( select(Collecte).where( Collecte.groupe_id == membre.groupe_id, (Collecte.reunion_id == reunion.id) | (Collecte.date_collecte == jour), ).limit(1) )).scalar_one_or_none() collecte_html = "
Aucune collecte saisie.
" if collecte: detenteur = await db.get(Membre, collecte.detenteur_id) if collecte.detenteur_id else None collecte_html = ( "| Montant | {float(collecte.montant):.2f} $ |
| Détenteur | {escape(detenteur.prenom if detenteur else '—')} |
| Statut | {escape(collecte.statut)} |
| Titre | Qté | Montant | Vendu par | Statut |
|---|
Aucune vente de littérature.
" ) ventes_jetons_html = ( "| Type | Qté | Montant | Vendu par | Statut |
|---|
Aucune vente de jetons.
" ) jetons_html = ( "| Type | Membre | Détail |
|---|
Aucun jeton/gâteau comptabilisé.
" ) html = f"""