2026-03-04 14:01:00 -05:00
|
|
|
"""Routes M2 — Présences en réunion d'affaires."""
|
Intégrer rencontres et assemblées pilotées par tuiles
Refond les pages Rencontres et Assemblées autour de tuiles ouvrables avec actions locales, PDF serveur, ventes, collectes, présences, décisions, postes et contributions.
Ajoute les invitations publiques d'assemblée en lecture seule avec lien expirant, l'adoption formelle du rapport du trésorier avec proposeur/secondeur, et bloque les contributions tant que ce rapport n'est pas adopté.
Uniformise la navigation et les entêtes, retire les modules redondants des contextes, ajoute la création de comptes membres par exécutif et les données démo reliées aux nouveaux processus.
Ce commit capture un lot fonctionnel important; une refactorisation ciblée du module Assemblées, puis Trésorerie/Gouvernance, est maintenant pertinente pour isoler les règles métier et faciliter les tests.
2026-05-31 09:02:02 -04:00
|
|
|
import io
|
|
|
|
|
import secrets
|
|
|
|
|
from html import escape
|
|
|
|
|
from datetime import datetime, timedelta, timezone
|
2026-03-04 14:01:00 -05:00
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
Intégrer rencontres et assemblées pilotées par tuiles
Refond les pages Rencontres et Assemblées autour de tuiles ouvrables avec actions locales, PDF serveur, ventes, collectes, présences, décisions, postes et contributions.
Ajoute les invitations publiques d'assemblée en lecture seule avec lien expirant, l'adoption formelle du rapport du trésorier avec proposeur/secondeur, et bloque les contributions tant que ce rapport n'est pas adopté.
Uniformise la navigation et les entêtes, retire les modules redondants des contextes, ajoute la création de comptes membres par exécutif et les données démo reliées aux nouveaux processus.
Ce commit capture un lot fonctionnel important; une refactorisation ciblée du module Assemblées, puis Trésorerie/Gouvernance, est maintenant pertinente pour isoler les règles métier et faciliter les tests.
2026-05-31 09:02:02 -04:00
|
|
|
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
|
Intégrer rencontres et assemblées pilotées par tuiles
Refond les pages Rencontres et Assemblées autour de tuiles ouvrables avec actions locales, PDF serveur, ventes, collectes, présences, décisions, postes et contributions.
Ajoute les invitations publiques d'assemblée en lecture seule avec lien expirant, l'adoption formelle du rapport du trésorier avec proposeur/secondeur, et bloque les contributions tant que ce rapport n'est pas adopté.
Uniformise la navigation et les entêtes, retire les modules redondants des contextes, ajoute la création de comptes membres par exécutif et les données démo reliées aux nouveaux processus.
Ce commit capture un lot fonctionnel important; une refactorisation ciblée du module Assemblées, puis Trésorerie/Gouvernance, est maintenant pertinente pour isoler les règles métier et faciliter les tests.
2026-05-31 09:02:02 -04:00
|
|
|
from zoneinfo import ZoneInfo
|
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.roles import require_secretaire
|
|
|
|
|
from app.core.reunions import date_assemblee_affaires_du_mois, jour_reunion_attendu
|
Intégrer rencontres et assemblées pilotées par tuiles
Refond les pages Rencontres et Assemblées autour de tuiles ouvrables avec actions locales, PDF serveur, ventes, collectes, présences, décisions, postes et contributions.
Ajoute les invitations publiques d'assemblée en lecture seule avec lien expirant, l'adoption formelle du rapport du trésorier avec proposeur/secondeur, et bloque les contributions tant que ce rapport n'est pas adopté.
Uniformise la navigation et les entêtes, retire les modules redondants des contextes, ajoute la création de comptes membres par exécutif et les données démo reliées aux nouveaux processus.
Ce commit capture un lot fonctionnel important; une refactorisation ciblée du module Assemblées, puis Trésorerie/Gouvernance, est maintenant pertinente pour isoler les règles métier et faciliter les tests.
2026-05-31 09:02:02 -04:00
|
|
|
from app.core.security import get_current_membre, require_executif
|
|
|
|
|
from app.models.invitation import Invitation
|
2026-03-04 14:01:00 -05:00
|
|
|
from app.models.reunion import Reunion
|
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.presence import Presence
|
|
|
|
|
from app.models.membre import Membre
|
Intégrer rencontres et assemblées pilotées par tuiles
Refond les pages Rencontres et Assemblées autour de tuiles ouvrables avec actions locales, PDF serveur, ventes, collectes, présences, décisions, postes et contributions.
Ajoute les invitations publiques d'assemblée en lecture seule avec lien expirant, l'adoption formelle du rapport du trésorier avec proposeur/secondeur, et bloque les contributions tant que ce rapport n'est pas adopté.
Uniformise la navigation et les entêtes, retire les modules redondants des contextes, ajoute la création de comptes membres par exécutif et les données démo reliées aux nouveaux processus.
Ce commit capture un lot fonctionnel important; une refactorisation ciblée du module Assemblées, puis Trésorerie/Gouvernance, est maintenant pertinente pour isoler les règles métier et faciliter les tests.
2026-05-31 09:02:02 -04:00
|
|
|
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
|
2026-03-04 14:01:00 -05:00
|
|
|
from app.schemas.phase4 import (
|
|
|
|
|
PresencesBatch, PresenceOut, ReunionAffairesCreate, ReunionAffairesOut,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/presences", tags=["M2 — Présences affaires"])
|
|
|
|
|
|
|
|
|
|
|
Intégrer rencontres et assemblées pilotées par tuiles
Refond les pages Rencontres et Assemblées autour de tuiles ouvrables avec actions locales, PDF serveur, ventes, collectes, présences, décisions, postes et contributions.
Ajoute les invitations publiques d'assemblée en lecture seule avec lien expirant, l'adoption formelle du rapport du trésorier avec proposeur/secondeur, et bloque les contributions tant que ce rapport n'est pas adopté.
Uniformise la navigation et les entêtes, retire les modules redondants des contextes, ajoute la création de comptes membres par exécutif et les données démo reliées aux nouveaux processus.
Ce commit capture un lot fonctionnel important; une refactorisation ciblée du module Assemblées, puis Trésorerie/Gouvernance, est maintenant pertinente pour isoler les règles métier et faciliter les tests.
2026-05-31 09:02:02 -04:00
|
|
|
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.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],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-03-04 14:01:00 -05:00
|
|
|
@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)."""
|
2026-04-17 04:04:04 -04:00
|
|
|
groupe = await db.get(Groupe, membre.groupe_id)
|
|
|
|
|
|
2026-05-25 17:39:38 -04:00
|
|
|
# ── Validation : réunion d'affaires prévue dans les paramètres du groupe ──
|
2026-04-17 04:04:04 -04:00
|
|
|
dt = req.date_reunion
|
Intégrer rencontres et assemblées pilotées par tuiles
Refond les pages Rencontres et Assemblées autour de tuiles ouvrables avec actions locales, PDF serveur, ventes, collectes, présences, décisions, postes et contributions.
Ajoute les invitations publiques d'assemblée en lecture seule avec lien expirant, l'adoption formelle du rapport du trésorier avec proposeur/secondeur, et bloque les contributions tant que ce rapport n'est pas adopté.
Uniformise la navigation et les entêtes, retire les modules redondants des contextes, ajoute la création de comptes membres par exécutif et les données démo reliées aux nouveaux processus.
Ce commit capture un lot fonctionnel important; une refactorisation ciblée du module Assemblées, puis Trésorerie/Gouvernance, est maintenant pertinente pour isoler les règles métier et faciliter les tests.
2026-05-31 09:02:02 -04:00
|
|
|
date_civile = _date_civile_reunion(dt)
|
2026-04-17 04:04:04 -04:00
|
|
|
if groupe.jour_reunion:
|
2026-05-25 17:39:38 -04:00
|
|
|
jour_attendu = jour_reunion_attendu(groupe)
|
2026-04-17 04:04:04 -04:00
|
|
|
if jour_attendu is not None:
|
2026-05-25 17:39:38 -04:00
|
|
|
ordre = groupe.assemblee_affaires_ordre or "premiere"
|
Intégrer rencontres et assemblées pilotées par tuiles
Refond les pages Rencontres et Assemblées autour de tuiles ouvrables avec actions locales, PDF serveur, ventes, collectes, présences, décisions, postes et contributions.
Ajoute les invitations publiques d'assemblée en lecture seule avec lien expirant, l'adoption formelle du rapport du trésorier avec proposeur/secondeur, et bloque les contributions tant que ce rapport n'est pas adopté.
Uniformise la navigation et les entêtes, retire les modules redondants des contextes, ajoute la création de comptes membres par exécutif et les données démo reliées aux nouveaux processus.
Ce commit capture un lot fonctionnel important; une refactorisation ciblée du module Assemblées, puis Trésorerie/Gouvernance, est maintenant pertinente pour isoler les règles métier et faciliter les tests.
2026-05-31 09:02:02 -04:00
|
|
|
date_attendue = date_assemblee_affaires_du_mois(date_civile.year, date_civile.month, jour_attendu, ordre)
|
|
|
|
|
if date_civile != date_attendue:
|
2026-04-17 04:04:04 -04:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
2026-05-25 17:39:38 -04:00
|
|
|
detail=f"La réunion d'affaires doit avoir lieu à la date prévue ({date_attendue.isoformat()})",
|
2026-04-17 04:04:04 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Une seule réunion d'affaires par mois
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Reunion).where(
|
|
|
|
|
Reunion.groupe_id == membre.groupe_id,
|
|
|
|
|
Reunion.type == "affaires",
|
Intégrer rencontres et assemblées pilotées par tuiles
Refond les pages Rencontres et Assemblées autour de tuiles ouvrables avec actions locales, PDF serveur, ventes, collectes, présences, décisions, postes et contributions.
Ajoute les invitations publiques d'assemblée en lecture seule avec lien expirant, l'adoption formelle du rapport du trésorier avec proposeur/secondeur, et bloque les contributions tant que ce rapport n'est pas adopté.
Uniformise la navigation et les entêtes, retire les modules redondants des contextes, ajoute la création de comptes membres par exécutif et les données démo reliées aux nouveaux processus.
Ce commit capture un lot fonctionnel important; une refactorisation ciblée du module Assemblées, puis Trésorerie/Gouvernance, est maintenant pertinente pour isoler les règles métier et faciliter les tests.
2026-05-31 09:02:02 -04:00
|
|
|
extract("year", Reunion.date_reunion) == date_civile.year,
|
|
|
|
|
extract("month", Reunion.date_reunion) == date_civile.month,
|
2026-04-17 04:04:04 -04:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
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')}",
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-04 14:01:00 -05:00
|
|
|
reunion = Reunion(
|
|
|
|
|
groupe_id=membre.groupe_id,
|
|
|
|
|
type="affaires",
|
|
|
|
|
date_reunion=req.date_reunion,
|
|
|
|
|
note=req.note,
|
|
|
|
|
)
|
|
|
|
|
db.add(reunion)
|
|
|
|
|
await db.flush()
|
2026-05-31 10:59:41 -04:00
|
|
|
return ReunionAffairesOut(id=reunion.id, date_reunion=reunion.date_reunion, note=reunion.note, modifie_le=reunion.modifie_le)
|
2026-03-04 14:01:00 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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(
|
2026-05-31 10:59:41 -04:00
|
|
|
id=r.id, date_reunion=r.date_reunion, note=r.note, modifie_le=r.modifie_le, presences=pres_out,
|
2026-03-04 14:01:00 -05:00
|
|
|
))
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
Intégrer rencontres et assemblées pilotées par tuiles
Refond les pages Rencontres et Assemblées autour de tuiles ouvrables avec actions locales, PDF serveur, ventes, collectes, présences, décisions, postes et contributions.
Ajoute les invitations publiques d'assemblée en lecture seule avec lien expirant, l'adoption formelle du rapport du trésorier avec proposeur/secondeur, et bloque les contributions tant que ce rapport n'est pas adopté.
Uniformise la navigation et les entêtes, retire les modules redondants des contextes, ajoute la création de comptes membres par exécutif et les données démo reliées aux nouveaux processus.
Ce commit capture un lot fonctionnel important; une refactorisation ciblée du module Assemblées, puis Trésorerie/Gouvernance, est maintenant pertinente pour isoler les règles métier et faciliter les tests.
2026-05-31 09:02:02 -04:00
|
|
|
@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.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"<li>{escape(mbr.prenom if mbr else '?')}</li>")
|
|
|
|
|
|
|
|
|
|
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"<li>{escape(prop.sujet)} ({escape(proposeur.prenom if proposeur else '—')} / {escape(secondeur.prenom if secondeur else 'à confirmer')})</li>"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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"<li>{escape(r.numero_resolution or '')} {escape(r.sujet)}</li>".strip()
|
|
|
|
|
for r in resolutions
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
postes_rows = [f"<li>{escape(p.nom)}</li>" 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,
|
|
|
|
|
f"Région {groupe.region}" if groupe and groupe.region else None,
|
|
|
|
|
] if item
|
|
|
|
|
])
|
|
|
|
|
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 d'assemblée d'affaires</h1>
|
|
|
|
|
<div class="meta">
|
|
|
|
|
<div class="box"><div class="label">Date</div><div class="value">{jour.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">Présences</div><div class="value">{len(presences_rows)}</div></div>
|
|
|
|
|
<div class="box"><div class="label">PV</div><div class="value">{'Adopté' if pv and pv.approuve else 'À adopter' if pv else 'À renseigner'}</div></div>
|
|
|
|
|
</div>
|
|
|
|
|
<h2>Présences</h2><ul>{"".join(presences_rows) or "<li>Présences à cocher sur papier.</li>"}</ul>
|
|
|
|
|
<h2>Réflexions en cours</h2><ul>{"".join(propositions_rows) or "<li>Aucune proposition secondée non résolue.</li>"}</ul>
|
|
|
|
|
<h2>Décisions prises</h2><ul>{"".join(resolutions_rows) or "<li>Aucune décision enregistrée pour cette date.</li>"}</ul>
|
|
|
|
|
<h2>Postes disponibles</h2><ul>{"".join(postes_rows) or "<li>Aucun poste vacant.</li>"}</ul>
|
|
|
|
|
<h2>Procès-verbal</h2><div class="pv">{escape(pv_contenu)}</div>
|
|
|
|
|
<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_assemblee_{jour.isoformat()}.pdf"
|
|
|
|
|
return StreamingResponse(
|
|
|
|
|
io.BytesIO(pdf_bytes),
|
|
|
|
|
media_type="application/pdf",
|
|
|
|
|
headers={"Content-Disposition": f'inline; filename="{filename}"'},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-03-04 14:01:00 -05:00
|
|
|
@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:
|
|
|
|
|
pres = Presence(
|
|
|
|
|
reunion_id=req.reunion_id,
|
|
|
|
|
membre_id=item.membre_id,
|
|
|
|
|
present=item.present,
|
|
|
|
|
)
|
|
|
|
|
db.add(pres)
|
|
|
|
|
mbr = await db.get(Membre, item.membre_id)
|
|
|
|
|
out.append(PresenceOut(
|
|
|
|
|
membre_id=item.membre_id,
|
|
|
|
|
membre_prenom=mbr.prenom if mbr else "?",
|
|
|
|
|
present=item.present,
|
|
|
|
|
))
|
|
|
|
|
await db.flush()
|
|
|
|
|
return out
|