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

549 lines
22 KiB
Python
Raw Normal View History

2026-03-04 14:01:00 -05:00
"""Routes S4 — Rapport de réunion."""
import io
from html import escape
2026-04-17 04:04:04 -04:00
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, Header, 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
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.reunions import jour_reunion_attendu
from app.core.security import get_current_membre, require_executif
2026-03-04 14:01:00 -05:00
from app.models.reunion import Reunion
from app.models.collecte import Collecte
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.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.models.rapport_adopte import RapportAdopte
2026-03-04 14:01:00 -05:00
from app.schemas.phase1 import ReunionCreate, ReunionOut
from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee
from app.services.rapports_adoptes import rapport_adopte_pour_reunion, verifier_reunion_non_cristallisee
2026-03-04 14:01:00 -05:00
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()
async def _html_rapport_rencontre(reunion: Reunion, membre, db: AsyncSession) -> tuple[str, datetime.date]:
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 = "<p>Aucune collecte saisie.</p>"
if collecte:
detenteur = await db.get(Membre, collecte.detenteur_id) if collecte.detenteur_id else None
collecte_html = (
"<table>"
f"<tr><td>Montant</td><td class=\"montant\">{float(collecte.montant):.2f} $</td></tr>"
f"<tr><td>Détenteur</td><td>{escape(detenteur.prenom if detenteur else '—')}</td></tr>"
f"<tr><td>Statut</td><td>{escape(collecte.statut)}</td></tr>"
"</table>"
)
ventes_lit_rows = []
ventes_lit_result = await db.execute(
select(VenteLitterature).where(
VenteLitterature.groupe_id == membre.groupe_id,
(VenteLitterature.reunion_id == reunion.id) | (VenteLitterature.date_vente == jour),
)
)
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_lit_rows.append(
f"<tr><td>{escape(item.titre if item else '?')}</td><td>{vente.quantite}</td>"
f"<td class=\"montant\">{float(vente.montant):.2f} $</td><td>{escape(vendeur.prenom if vendeur else '—')}</td>"
f"<td>{'Reçue' if vente.remis_tresorier else 'À recevoir'}</td></tr>"
)
ventes_jetons_rows = []
ventes_jetons_result = await db.execute(
select(VenteJeton).where(
VenteJeton.groupe_id == membre.groupe_id,
(VenteJeton.reunion_id == reunion.id) | (VenteJeton.date_vente == jour),
)
)
for vente in ventes_jetons_result.scalars().all():
vendeur = await db.get(Membre, vente.vendu_par) if vente.vendu_par else None
ventes_jetons_rows.append(
f"<tr><td>{escape(vente.type_jeton)}</td><td>{vente.quantite}</td>"
f"<td class=\"montant\">{float(vente.montant):.2f} $</td><td>{escape(vendeur.prenom if vendeur else '—')}</td>"
f"<td>{'Reçue' if vente.remis_tresorier else 'À recevoir'}</td></tr>"
)
jetons_rows = []
jetons_result = await db.execute(
select(Jeton).where(
Jeton.groupe_id == membre.groupe_id,
(Jeton.reunion_id == reunion.id) | (Jeton.date_remise == jour),
)
)
for jeton in jetons_result.scalars().all():
cible = await db.get(Membre, jeton.membre_id) if jeton.membre_id else None
detail = f"{jeton.annees} ans" if jeton.annees else ""
jetons_rows.append(
f"<tr><td>{escape(jeton.type_jeton)}</td><td>{escape(cible.prenom if cible else 'Anonyme')}</td><td>{escape(detail)}</td></tr>"
)
annonces = reunion.annonces or []
annonces_texte = "\n".join(annonces)
collecte_texte = "Aucune collecte saisie."
if collecte:
detenteur = await db.get(Membre, collecte.detenteur_id) if collecte.detenteur_id else None
collecte_texte = (
f"{float(collecte.montant):.2f} $"
f"\nDétenteur: {detenteur.prenom if detenteur else '—'}"
f"\nStatut: {collecte.statut}"
)
ventes_lit_texte = "\n".join(
row.replace("<tr><td>", "")
.replace("</td><td>", " · ")
.replace("</td><td class=\"montant\">", " · ")
.replace("</td></tr>", "")
for row in ventes_lit_rows
)
jetons_texte = "\n".join(
row.replace("<tr><td>", "")
.replace("</td><td>", " · ")
.replace("</td></tr>", "")
for row in jetons_rows
)
ventes_jetons_texte = "\n".join(
row.replace("<tr><td>", "")
.replace("</td><td>", " · ")
.replace("</td><td class=\"montant\">", " · ")
.replace("</td></tr>", "")
for row in ventes_jetons_rows
)
notes_texte = "\n\n".join([texte for texte in [reunion.note, annonces_texte] if texte])
def bloc(label: str, contenu: str = "", cls: str = "") -> str:
return (
f"<div class=\"field {cls}\"><div class=\"box\">"
f"{escape(contenu or '')}</div><div class=\"label\">{escape(label)}</div></div>"
)
css = """
@page { size: letter; margin: 0.55in 0.65in; }
body { font-family: Arial, 'DejaVu Sans', sans-serif; color: #111; font-size: 10pt; }
2026-06-02 00:44:36 -04:00
.header { text-align: center; margin: 8pt 0 28pt; }
.groupe { font-size: 20pt; font-weight: 400; margin-bottom: 8pt; }
.district { font-size: 14pt; margin-bottom: 6pt; }
.adresse { font-size: 9.5pt; margin-bottom: 16pt; color: #333; }
h1 { font-size: 14pt; font-weight: 500; letter-spacing: 0; margin: 0; }
2026-06-02 00:44:36 -04:00
.grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14pt 16pt; }
.field { break-inside: avoid; }
.field.full { grid-column: 1 / -1; }
2026-06-02 00:44:36 -04:00
.field.span2 { grid-column: span 2; }
.box {
border: 1pt solid #222;
min-height: 27pt;
padding: 6pt 8pt;
white-space: pre-wrap;
line-height: 1.25;
overflow: hidden;
}
.label { font-style: italic; font-size: 8.5pt; margin: 4pt 0 0 1pt; }
2026-06-02 00:44:36 -04:00
.short .box { min-height: 23pt; }
.theme .box { min-height: 54pt; }
.medium .box { min-height: 70pt; }
.tall .box { min-height: 86pt; }
.notes .box { min-height: 132pt; }
.footer { margin-top: 10pt; color: #777; font-size: 7.5pt; text-align: center; }
"""
groupe_nom = groupe.nom if groupe and groupe.nom else "Groupe"
district = f"District {groupe.district}" if groupe and groupe.district else "District"
2026-06-02 00:44:36 -04:00
adresse = groupe.adresse_local if groupe and groupe.adresse_local else ""
nouveaux_jetons = "\n\n".join([texte for texte in ["Nouveau(x):", jetons_texte] if texte])
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="district">{escape(district)}</div>
2026-06-02 00:44:36 -04:00
<div class="adresse">{escape(adresse)}</div>
<h1>RÉUNION HEBDOMADAIRE</h1>
</div>
<div class="grid">
{bloc("DATE ( AAAA / MM / JJ)", reunion.date_reunion.date().isoformat(), "short")}
{bloc("PRÉSENCES", "", "short")}
{bloc("ANIMATION", animateur.prenom if animateur else "", "short")}
{bloc("THÈME DE LA DISCUSSION", reunion.theme or "", "full theme")}
2026-06-02 00:44:36 -04:00
{bloc("NOUVEAU(X) / ANNIVERSAIRES / JETONS", nouveaux_jetons, "span2 medium")}
{bloc("COLLECTE", collecte_texte, "short")}
2026-06-02 00:44:36 -04:00
{bloc("VENTES (Littérature)", ventes_lit_texte, "span2 short")}
{bloc("VENTES (Jetons)", ventes_jetons_texte, "short")}
2026-06-02 00:44:36 -04:00
{bloc("DÉPENSES", "", "full tall")}
{bloc("NOTES", notes_texte, "full notes")}
</div>
<div class="footer">Rapport généré automatiquement · {datetime.now().strftime('%Y-%m-%d %H:%M')}</div>
</body></html>"""
return html, jour
2026-03-04 14:01:00 -05:00
@router.post("/", response_model=ReunionOut)
async def creer_rapport_reunion(
req: ReunionCreate,
2026-05-25 17:39:38 -04:00
membre=Depends(require_executif),
2026-03-04 14:01:00 -05:00
db: AsyncSession = Depends(get_db),
):
"""S4 — L'animateur note le thème et les annonces."""
2026-04-17 04:04:04 -04:00
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)
2026-05-25 17:39:38 -04:00
jour_attendu = jour_reunion_attendu(groupe)
jour_soumis = date_civile.weekday()
2026-04-17 04:04:04 -04:00
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()
2026-04-17 04:04:04 -04:00
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")
2026-03-04 14:01:00 -05:00
reunion = Reunion(
groupe_id=membre.groupe_id,
type=req.type,
date_reunion=req.date_reunion,
animateur_id=animateur.id,
2026-03-04 14:01:00 -05:00
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,
2026-03-04 14:01:00 -05:00
theme=reunion.theme,
annonces=reunion.annonces,
note=reunion.note,
cree_le=reunion.cree_le,
modifie_le=reunion.modifie_le,
2026-03-04 14:01:00 -05:00
)
@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,
2026-03-04 14:01:00 -05:00
))
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,
})
rapport_adopte = await rapport_adopte_pour_reunion(db, membre.groupe_id, reunion.id, "rencontre")
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,
"rapport_adopte_id": str(rapport_adopte.id) if rapport_adopte else None,
})
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_reunion_non_cristallisee(
db, membre.groupe_id, reunion.id, "rencontre", "Cette rencontre est clôturée"
)
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")
adopte = await rapport_adopte_pour_reunion(db, membre.groupe_id, reunion.id, "rencontre")
if adopte:
pdf_bytes = HTML(string=adopte.contenu_html).write_pdf()
filename = f"rapport_rencontre_{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_rencontre(reunion, membre, db)
pdf_bytes = HTML(string=html).write_pdf()
filename = f"rapport_rencontre_{jour.isoformat()}.pdf"
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{filename}"'},
)
@router.post("/{reunion_id}/cloturer")
async def cloturer_rencontre(
reunion_id: str,
membre=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Cristalliser le rapport d'une rencontre régulière."""
reunion = (await db.execute(
select(Reunion).where(
Reunion.id == reunion_id,
Reunion.groupe_id == membre.groupe_id,
Reunion.type == "reguliere",
)
)).scalar_one_or_none()
if not reunion:
raise HTTPException(status_code=404, detail="Rencontre introuvable")
if await rapport_adopte_pour_reunion(db, membre.groupe_id, reunion.id, "rencontre"):
raise HTTPException(status_code=400, detail="Cette rencontre est déjà clôturée")
html, jour = await _html_rapport_rencontre(reunion, membre, db)
rapport = RapportAdopte(
groupe_id=membre.groupe_id,
adopte_par_id=membre.id,
type_rapport="rencontre",
titre=f"Rapport de rencontre — {jour.isoformat()}",
annee=jour.year,
mois=jour.month,
contenu_html=html,
donnees={"reunion_id": str(reunion.id), "date": jour.isoformat()},
)
db.add(rapport)
await db.flush()
return {"id": str(rapport.id), "titre": rapport.titre, "adopte_le": rapport.adopte_le}
2026-03-04 14:01:00 -05:00
@router.get("/{reunion_id}", response_model=ReunionOut)
async def detail_reunion(
reunion_id: str,
membre=Depends(get_current_membre),
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")
anim = await db.get(Membre, reunion.animateur_id) if reunion.animateur_id else None
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,
2026-03-04 14:01:00 -05:00
)