Formulaire public — paiement en ligne : - Le bénévole n'est plus compté en base tant qu'un paiement en ligne choisi n'est pas confirmé (PendingFormSubmission, promu en Volunteer à la confirmation, webhook ou vérification en direct). - Type de paiement « mécénat » : étape d'adresse postale avant le paiement, conservée sur le bénévole pour un reçu fiscal (cerfa) ultérieur. - Page de confirmation au retour de SumUp, avec relance possible après échec/expiration sans ressaisir le formulaire. - « Je paierai sur place » remplace « je ne participe pas » ; tous les champs activés par la coordination sont désormais obligatoires. - La fiche bénévole n'affiche plus l'état des cases à cocher, seulement le résultat réel du paiement (libellé, montant, statut). Emails : - Email de confirmation d'inscription à la création du bénévole (tient lieu de reçu de paiement quand une cotisation est payée en ligne). - Mise en forme HTML au thème de l'application (logo de l'édition en en-tête) pour tous les emails envoyés, texte brut conservé en repli. Export Excel des bénévoles (COORDINATION) : coordonnées, vœux résolus en noms lisibles, cotisation et mécénat. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
121 lines
4.3 KiB
Python
121 lines
4.3 KiB
Python
"""Envoi d'emails d'information aux bénévoles (récapitulatif de planning)."""
|
|
|
|
import uuid
|
|
from collections import defaultdict
|
|
|
|
from fastapi import APIRouter, BackgroundTasks, Depends
|
|
from sqlmodel import Session, select
|
|
|
|
from app.api.deps import get_edition
|
|
from app.api.schemas import (
|
|
PlanningNotificationRequest,
|
|
PlanningNotificationResult,
|
|
)
|
|
from app.auth.deps import require_coordinator
|
|
from app.auth.magic_link import build_magic_link_url, issue_magic_link_token
|
|
from app.db import get_session
|
|
from app.email import edition_logo_url, render_planning, send_many
|
|
from app.i18n import t
|
|
from app.models import Account, Assignment, Edition, Position, Shift, Volunteer
|
|
|
|
router = APIRouter(
|
|
prefix="/editions/{edition_id}/notifications", tags=["notifications"]
|
|
)
|
|
|
|
_LOGIN_PLACEHOLDER = "{lien_connexion}"
|
|
|
|
|
|
def _login_link(session: Session, edition: Edition, volunteer: Volunteer) -> str:
|
|
"""Lien magique personnel si le bénévole a un compte, sinon l'accueil."""
|
|
account = session.exec(
|
|
select(Account)
|
|
.where(Account.edition_id == edition.id)
|
|
.where(Account.volunteer_id == volunteer.id)
|
|
.where(Account.password_hash.is_(None))
|
|
).first()
|
|
if account is None:
|
|
return build_magic_link_url("").split("?")[0]
|
|
return build_magic_link_url(issue_magic_link_token(session, account))
|
|
|
|
|
|
@router.post("/planning", response_model=PlanningNotificationResult)
|
|
def send_planning_notification(
|
|
payload: PlanningNotificationRequest,
|
|
background: BackgroundTasks,
|
|
edition: Edition = Depends(get_edition),
|
|
_guard: Account = Depends(require_coordinator),
|
|
session: Session = Depends(get_session),
|
|
) -> PlanningNotificationResult:
|
|
subject_template = payload.subject or t("email.planning.default_subject")
|
|
body_template = payload.body or t("email.planning.default_body")
|
|
wants_login_link = _LOGIN_PLACEHOLDER in subject_template or (
|
|
_LOGIN_PLACEHOLDER in body_template
|
|
)
|
|
|
|
stmt = select(Volunteer).where(Volunteer.edition_id == edition.id)
|
|
if payload.volunteer_ids is not None:
|
|
stmt = stmt.where(Volunteer.id.in_(payload.volunteer_ids))
|
|
volunteers = list(session.exec(stmt.order_by(Volunteer.last_name)))
|
|
|
|
positions = {
|
|
p.id: p
|
|
for p in session.exec(
|
|
select(Position).where(Position.edition_id == edition.id)
|
|
)
|
|
}
|
|
shifts_by_volunteer: dict[uuid.UUID, list[Shift]] = defaultdict(list)
|
|
for volunteer_id, shift in session.exec(
|
|
select(Assignment.volunteer_id, Shift)
|
|
.join(Shift, Shift.id == Assignment.shift_id)
|
|
.where(Assignment.edition_id == edition.id)
|
|
):
|
|
shifts_by_volunteer[volunteer_id].append(shift)
|
|
|
|
items: list[tuple[str, str, str]] = []
|
|
recipients: list[uuid.UUID] = []
|
|
skipped_no_email = 0
|
|
for volunteer in volunteers:
|
|
if not volunteer.email:
|
|
skipped_no_email += 1
|
|
continue
|
|
shifts = sorted(
|
|
shifts_by_volunteer.get(volunteer.id, []),
|
|
key=lambda s: s.starts_at,
|
|
)
|
|
minutes = sum(
|
|
(s.ends_at - s.starts_at).total_seconds() / 60 for s in shifts
|
|
)
|
|
shifts_text = "\n".join(
|
|
t(
|
|
"email.planning.shift_line",
|
|
position=positions[s.position_id].name,
|
|
start=s.starts_at.strftime("%d/%m %H:%M"),
|
|
end=s.ends_at.strftime("%d/%m %H:%M"),
|
|
)
|
|
for s in shifts
|
|
) or t("email.planning.no_shifts")
|
|
subject, body = render_planning(
|
|
subject_template=subject_template,
|
|
body_template=body_template,
|
|
first_name=volunteer.first_name,
|
|
last_name=volunteer.last_name,
|
|
shift_count=len(shifts),
|
|
hours=round(minutes / 60, 1),
|
|
shifts_text=shifts_text,
|
|
login_link=(
|
|
_login_link(session, edition, volunteer)
|
|
if wants_login_link
|
|
else ""
|
|
),
|
|
)
|
|
items.append((volunteer.email, subject, body))
|
|
recipients.append(volunteer.id)
|
|
|
|
session.commit() # persiste les éventuels MagicLinkToken émis
|
|
background.add_task(send_many, items, logo_url=edition_logo_url(session, edition.id))
|
|
return PlanningNotificationResult(
|
|
queued=len(items),
|
|
skipped_no_email=skipped_no_email,
|
|
recipients=recipients,
|
|
)
|