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

467 lines
19 KiB
Python

"""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 = "<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_html = "".join(f"<li>{escape(a)}</li>" for a in annonces) or "<li>Aucune annonce.</li>"
css = """
body { font-family: 'DejaVu Sans', sans-serif; font-size: 10.5pt; color: #27323a; margin: 1.6cm; }
.header { border-bottom: 3px solid #1a5276; padding-bottom: 12pt; margin-bottom: 18pt; }
.groupe { color: #1a5276; font-size: 20pt; font-weight: 800; }
.coords { color: #667; font-size: 9pt; margin-top: 3pt; }
h1 { font-size: 16pt; color: #1a5276; margin: 0 0 12pt; }
h2 { font-size: 12pt; color: #1a5276; margin: 16pt 0 6pt; border-bottom: 1px solid #d8e2ea; padding-bottom: 3pt; }
.meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8pt; }
.box { border: 1px solid #d8e2ea; border-radius: 6pt; padding: 8pt; background: #f8fafc; }
.label { font-size: 8pt; color: #667; text-transform: uppercase; font-weight: 700; }
.value { font-size: 11pt; font-weight: 700; margin-top: 2pt; }
table { width: 100%; border-collapse: collapse; margin-top: 5pt; }
th { text-align: left; background: #1a5276; color: white; padding: 5pt; font-size: 8.5pt; }
td { border-bottom: 1px solid #e5e9ef; padding: 5pt; vertical-align: top; }
.montant { text-align: right; white-space: nowrap; }
.note { white-space: pre-wrap; }
.page-break { break-before: page; page-break-before: always; }
.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
])
ventes_lit_html = (
"<table><tr><th>Titre</th><th>Qté</th><th>Montant</th><th>Vendu par</th><th>Statut</th></tr>"
+ "".join(ventes_lit_rows) + "</table>"
if ventes_lit_rows else "<p>Aucune vente de littérature.</p>"
)
ventes_jetons_html = (
"<table><tr><th>Type</th><th>Qté</th><th>Montant</th><th>Vendu par</th><th>Statut</th></tr>"
+ "".join(ventes_jetons_rows) + "</table>"
if ventes_jetons_rows else "<p>Aucune vente de jetons.</p>"
)
jetons_html = (
"<table><tr><th>Type</th><th>Membre</th><th>Détail</th></tr>" + "".join(jetons_rows) + "</table>"
if jetons_rows else "<p>Aucun jeton/gâteau comptabilisé.</p>"
)
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 de rencontre</h1>
<div class="meta">
<div class="box"><div class="label">Date</div><div class="value">{reunion.date_reunion.date().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">Sujet</div><div class="value">{escape(reunion.theme or 'Rencontre hebdomadaire')}</div></div>
<div class="box"><div class="label">Animé par</div><div class="value">{escape(animateur.prenom if animateur else '—')}</div></div>
</div>
<h2>Annonces</h2><ul>{annonces_html}</ul>
<h2>Notes</h2><div class="note">{escape(reunion.note or 'Aucune note.')}</div>
<h2>Collecte</h2>{collecte_html}
<div class="page-break"></div>
<h2>Ventes de littérature</h2>{ventes_lit_html}
<h2>Ventes de jetons</h2>{ventes_jetons_html}
<h2>Jetons/Gâteaux</h2>{jetons_html}
<div class="footer">Rapport généré automatiquement · {datetime.now().strftime('%Y-%m-%d %H:%M')}</div>
</body></html>"""
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.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,
)