467 lines
19 KiB
Python
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,
|
|
)
|