525 lines
21 KiB
Python
525 lines
21 KiB
Python
"""Routes M2 — Présences en réunion d'affaires."""
|
|
import io
|
|
import secrets
|
|
from html import escape
|
|
from datetime import datetime, timedelta, timezone
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from fastapi.responses import StreamingResponse
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, desc, extract
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from app.core.database import get_db
|
|
from app.core.roles import require_secretaire
|
|
from app.core.reunions import date_assemblee_affaires_du_mois, jour_reunion_attendu
|
|
from app.core.security import get_current_membre, require_executif
|
|
from app.models.invitation import Invitation
|
|
from app.models.reunion import Reunion
|
|
from app.models.groupe import Groupe
|
|
from app.models.presence import Presence
|
|
from app.models.membre import Membre
|
|
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
|
|
from app.models.rapport_adopte import RapportAdopte
|
|
from app.schemas.phase4 import (
|
|
PresencesBatch, PresenceOut, ReunionAffairesCreate, ReunionAffairesOut,
|
|
)
|
|
from app.services.rapports_adoptes import rapport_adopte_pour_reunion, verifier_reunion_non_cristallisee
|
|
|
|
router = APIRouter(prefix="/presences", tags=["M2 — Présences affaires"])
|
|
|
|
|
|
class RapportAssembleeAdoption(BaseModel):
|
|
proposeur_id: str
|
|
secondeur_id: str
|
|
|
|
|
|
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.groupe_id == groupe_id,
|
|
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],
|
|
}
|
|
|
|
|
|
async def _html_rapport_assemblee(reunion: Reunion, membre, db: AsyncSession) -> tuple[str, datetime.date]:
|
|
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)]
|
|
mois_precedent = jour.month - 1
|
|
annee_precedente = jour.year
|
|
if mois_precedent == 0:
|
|
mois_precedent = 12
|
|
annee_precedente -= 1
|
|
rapport_tresorier = (await db.execute(
|
|
select(RapportAdopte).where(
|
|
RapportAdopte.groupe_id == membre.groupe_id,
|
|
RapportAdopte.type_rapport == "tresorerie",
|
|
RapportAdopte.annee == annee_precedente,
|
|
RapportAdopte.mois == mois_precedent,
|
|
).limit(1)
|
|
)).scalar_one_or_none()
|
|
tresorier_html = (
|
|
f"<p>Rapport adopté: {escape(rapport_tresorier.titre)}</p>"
|
|
if rapport_tresorier else "<p>Rapport du trésorier à adopter avant les contributions.</p>"
|
|
)
|
|
|
|
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,
|
|
] 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>Rapport du trésorier</h2>{tresorier_html}
|
|
<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>"""
|
|
return html, jour
|
|
|
|
|
|
@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)."""
|
|
groupe = await db.get(Groupe, membre.groupe_id)
|
|
|
|
# ── Validation : réunion d'affaires prévue dans les paramètres du groupe ──
|
|
dt = req.date_reunion
|
|
date_civile = _date_civile_reunion(dt)
|
|
if groupe.jour_reunion:
|
|
jour_attendu = jour_reunion_attendu(groupe)
|
|
if jour_attendu is not None:
|
|
ordre = groupe.assemblee_affaires_ordre or "premiere"
|
|
date_attendue = date_assemblee_affaires_du_mois(date_civile.year, date_civile.month, jour_attendu, ordre)
|
|
if date_civile != date_attendue:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"La réunion d'affaires doit avoir lieu à la date prévue ({date_attendue.isoformat()})",
|
|
)
|
|
|
|
# Une seule réunion d'affaires par mois
|
|
result = await db.execute(
|
|
select(Reunion).where(
|
|
Reunion.groupe_id == membre.groupe_id,
|
|
Reunion.type == "affaires",
|
|
extract("year", Reunion.date_reunion) == date_civile.year,
|
|
extract("month", Reunion.date_reunion) == date_civile.month,
|
|
)
|
|
)
|
|
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')}",
|
|
)
|
|
|
|
reunion = Reunion(
|
|
groupe_id=membre.groupe_id,
|
|
type="affaires",
|
|
date_reunion=req.date_reunion,
|
|
note=req.note,
|
|
)
|
|
db.add(reunion)
|
|
await db.flush()
|
|
return ReunionAffairesOut(id=reunion.id, date_reunion=reunion.date_reunion, note=reunion.note, modifie_le=reunion.modifie_le)
|
|
|
|
|
|
@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,
|
|
))
|
|
rapport_adopte = await rapport_adopte_pour_reunion(db, membre.groupe_id, r.id, "assemblee")
|
|
out.append(ReunionAffairesOut(
|
|
id=r.id,
|
|
date_reunion=r.date_reunion,
|
|
note=r.note,
|
|
modifie_le=r.modifie_le,
|
|
rapport_adopte_id=rapport_adopte.id if rapport_adopte else None,
|
|
presences=pres_out,
|
|
))
|
|
return out
|
|
|
|
|
|
@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.groupe_id != invitation.groupe_id 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")
|
|
adopte = await rapport_adopte_pour_reunion(db, membre.groupe_id, reunion.id, "assemblee")
|
|
if adopte:
|
|
pdf_bytes = HTML(string=adopte.contenu_html).write_pdf()
|
|
filename = f"rapport_assemblee_{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_assemblee(reunion, membre, db)
|
|
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}"'},
|
|
)
|
|
|
|
@router.post("/reunions/{reunion_id}/adopter")
|
|
async def adopter_rapport_assemblee(
|
|
reunion_id: str,
|
|
req: RapportAssembleeAdoption,
|
|
membre=Depends(require_executif),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Cristalliser le rapport d'assemblée adopté par le groupe."""
|
|
if req.proposeur_id == req.secondeur_id:
|
|
raise HTTPException(status_code=400, detail="Le proposeur et le secondeur doivent être différents")
|
|
proposeur = await db.get(Membre, req.proposeur_id)
|
|
secondeur = await db.get(Membre, req.secondeur_id)
|
|
if not proposeur or proposeur.groupe_id != membre.groupe_id or not proposeur.actif:
|
|
raise HTTPException(status_code=404, detail="Proposeur introuvable ou inactif")
|
|
if not secondeur or secondeur.groupe_id != membre.groupe_id or not secondeur.actif:
|
|
raise HTTPException(status_code=404, detail="Secondeur introuvable ou inactif")
|
|
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")
|
|
if await rapport_adopte_pour_reunion(db, membre.groupe_id, reunion.id, "assemblee"):
|
|
raise HTTPException(status_code=400, detail="Cette assemblée est déjà adoptée")
|
|
html, jour = await _html_rapport_assemblee(reunion, membre, db)
|
|
rapport = RapportAdopte(
|
|
groupe_id=membre.groupe_id,
|
|
adopte_par_id=membre.id,
|
|
type_rapport="assemblee",
|
|
titre=f"Rapport d'assemblée — {jour.isoformat()}",
|
|
annee=jour.year,
|
|
mois=jour.month,
|
|
contenu_html=html,
|
|
donnees={
|
|
"reunion_id": str(reunion.id),
|
|
"date": jour.isoformat(),
|
|
"proposeur_id": str(proposeur.id),
|
|
"secondeur_id": str(secondeur.id),
|
|
"proposeur_prenom": proposeur.prenom,
|
|
"secondeur_prenom": secondeur.prenom,
|
|
},
|
|
)
|
|
db.add(rapport)
|
|
await db.flush()
|
|
return {"id": str(rapport.id), "titre": rapport.titre, "adopte_le": rapport.adopte_le}
|
|
|
|
|
|
@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")
|
|
await verifier_reunion_non_cristallisee(
|
|
db, membre.groupe_id, reunion.id, "assemblee", "Cette assemblée est adoptée"
|
|
)
|
|
|
|
# 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:
|
|
mbr = await db.get(Membre, item.membre_id)
|
|
if not mbr or mbr.groupe_id != membre.groupe_id or not mbr.actif:
|
|
raise HTTPException(status_code=404, detail="Membre introuvable")
|
|
pres = Presence(
|
|
reunion_id=req.reunion_id,
|
|
membre_id=item.membre_id,
|
|
present=item.present,
|
|
)
|
|
db.add(pres)
|
|
out.append(PresenceOut(
|
|
membre_id=item.membre_id,
|
|
membre_prenom=mbr.prenom,
|
|
present=item.present,
|
|
))
|
|
await db.flush()
|
|
return out
|