From 8d6781e6cee735a273c8e9f90ddf9cb5330fd4e8 Mon Sep 17 00:00:00 2001
From: AdamThomas
Date: Wed, 28 Oct 2026 19:52:06 +0100
Subject: [PATCH] =?UTF-8?q?R=C3=A9capitulatif=20de=20planning=20:=20cr?=
=?UTF-8?q?=C3=A9neaux=20en=20tableau=20avec=20le=20r=C3=A9f=C3=A9rent?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
{creneaux} devient un tableau par jour de festival (horaire, poste,
référent avec son téléphone), en HTML dans l'email et en lignes lisibles
dans la version texte — envoi réel et email de test.
Co-Authored-By: Claude Opus 5.5
---
CLAUDE.md | 11 +-
backend/app/api/notifications.py | 215 +++++++++++++++++-------
backend/app/email/html.py | 73 ++++++--
backend/app/email/sender.py | 24 ++-
backend/app/i18n/locales/fr.json | 6 +-
backend/tests/test_api_notifications.py | 38 +++++
6 files changed, 291 insertions(+), 76 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index ef1a695..26cc978 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -646,7 +646,15 @@ ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
émis pour le compte du bénévole). `volunteer_ids` absent ⇒ tous les bénévoles
de l'édition qui ont un email. Envoi en tâche de fond (`BackgroundTasks`,
une connexion SMTP réutilisée). Réponse : `{ queued, skipped_no_email,
- recipients }`.
+ recipients }`. `{creneaux}` est un **tableau**, comme sur les badges : le jour de
+ festival en en-tête (une nuit reste sur le jour où elle a commencé),
+ puis une ligne par créneau — horaire, poste, référent du poste avec son
+ téléphone (« à désigner » sinon). Vrai tableau HTML dans la version HTML
+ (`app/email/html.py:render_planning_table`, inséré à la place d'un repère
+ via `build_message(html_body=…, html_blocks=…)`), et version texte une
+ ligne par créneau sous chaque jour (`email.planning.table.text_line`).
+ Composition partagée par l'envoi réel et l'email de test
+ (`notifications.py:_planning_email`, `_load_context`).
- **Confirmation d'inscription** (`app/api/forms.py`) : dès qu'un `Volunteer`
est effectivement créé et a un email — que ce soit tout de suite (pas de
paiement en ligne) ou à la promotion d'une `PendingFormSubmission`
@@ -1089,6 +1097,7 @@ Aucune dépendance locale requise hors Docker.
| 66 | Ajout manuel d'un bénévole sans champ obligatoire (prénom et nom facultatifs, côté formulaire et API) ; un bénévole sans nom s'affiche « Sans nom » / « ? » dans les listes, la grille, la vue par bénévole, les badges et l'écran référent | à valider |
| 67 | Email de test : adresse + bouton dans l'onglet « Messages » de la configuration, envoi immédiat et résultat réel affiché (envoyé, envoi désactivé, connexion refusée, message refusé, avec la réponse du serveur SMTP) — `POST .../notifications/test` | à valider |
| 68 | L'email de test est le récapitulatif de planning compilé tel qu'envoyé aux bénévoles (modèle en cours d'édition, variables remplies avec le planning d'un bénévole au choix, option lien de connexion — sans vrai jeton —, sujet « [Test] … ») | à valider |
+| 69 | Récapitulatif de planning : `{creneaux}` devient un tableau par jour de festival (horaire, poste, référent avec son téléphone), en HTML dans l'email et en lignes lisibles dans la version texte — envoi réel et email de test | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, IA. Frontend : squelette + 6 écrans +
diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py
index 8675c60..32a832e 100644
--- a/backend/app/api/notifications.py
+++ b/backend/app/api/notifications.py
@@ -1,8 +1,11 @@
"""Envoi d'emails d'information aux bénévoles (récapitulatif de planning)."""
+import datetime as dt
import logging
import uuid
from collections import defaultdict
+from dataclasses import dataclass
+from email.message import EmailMessage
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlmodel import Session, select
@@ -18,10 +21,12 @@ from app.auth.deps import require_coordinator
from app.auth.magic_link import build_magic_link_url, issue_magic_link_token
from app.config import get_settings
from app.db import get_session
-from app.email import edition_logo, render_planning, send_many
-from app.email.sender import build_message, deliver_test
+from app.email import edition_logo, render_planning
+from app.email.html import render_planning_table
+from app.email.sender import build_message, deliver_test, send_messages
from app.i18n import t
-from app.models import Account, Assignment, Edition, Position, Shift, Volunteer
+from app.models import Account, Assignment, DayPart, Edition, Position, Shift, Volunteer
+from app.validation.engine import day_boundary, festival_day
logger = logging.getLogger("app.notifications")
@@ -45,39 +50,136 @@ def _login_link(session: Session, edition: Edition, volunteer: Volunteer) -> str
return build_magic_link_url(issue_magic_link_token(session, account))
+# Repère du tableau des créneaux dans la version HTML (caractère d'usage
+# privé : jamais saisi dans un modèle), remplacé par le vrai tableau.
+_SHIFTS_MARKER = "\ue000creneaux\ue000"
+
+
+@dataclass(frozen=True)
+class _PlanningContext:
+ """Ce qui sert à composer le récapitulatif de chaque bénévole, chargé
+ une fois par envoi."""
+
+ positions: dict[uuid.UUID, Position]
+ # Nom + téléphone du référent de chaque poste qui en a un.
+ referents: dict[uuid.UUID, str]
+ shifts_by_volunteer: dict[uuid.UUID, list[Shift]]
+ boundary: dt.time
+
+
+def _load_context(session: Session, edition: Edition) -> _PlanningContext:
+ positions = {
+ p.id: p for p in session.exec(select(Position).where(Position.edition_id == edition.id))
+ }
+ referents: dict[uuid.UUID, str] = {}
+ for position_id, referent in session.exec(
+ select(Position.id, Volunteer)
+ .join(Account, Account.id == Position.lead_account_id)
+ .join(Volunteer, Volunteer.id == Account.volunteer_id)
+ .where(Position.edition_id == edition.id)
+ ):
+ name = f"{referent.first_name} {referent.last_name}".strip()
+ referents[position_id] = f"{name} · {referent.phone}" if referent.phone else name
+ 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)
+ boundary = day_boundary(session.exec(select(DayPart).where(DayPart.edition_id == edition.id)))
+ return _PlanningContext(positions, referents, shifts_by_volunteer, boundary)
+
+
+def _day_label(day: dt.date) -> str:
+ """« Samedi 11 » — même en-tête de jour que le tableau des badges."""
+ return t("badge.table.day_label", weekday=t(f"badge.weekday.{day.weekday()}"), day=day.day)
+
+
+def _shift_sections(
+ shifts: list[Shift], ctx: _PlanningContext
+) -> list[tuple[str, list[tuple[str, str, str]]]]:
+ """Créneaux regroupés par journée de festival (une nuit reste sur le
+ jour où elle a commencé), comme le tableau des badges :
+ `[(jour, [(horaire, poste, référent)])]`."""
+ by_day: dict[dt.date, list[tuple[str, str, str]]] = defaultdict(list)
+ for s in sorted(shifts, key=lambda s: s.starts_at):
+ by_day[festival_day(s.starts_at, ctx.boundary)].append(
+ (
+ f"{s.starts_at:%H:%M}–{s.ends_at:%H:%M}",
+ ctx.positions[s.position_id].name,
+ ctx.referents.get(s.position_id) or t("email.planning.table.no_referent"),
+ )
+ )
+ return [(_day_label(day), rows) for day, rows in sorted(by_day.items())]
+
+
+def _shifts_text(sections: list[tuple[str, list[tuple[str, str, str]]]]) -> str:
+ """Version texte du tableau (clients mail sans HTML) : le jour, puis
+ une ligne par créneau."""
+ if not sections:
+ return t("email.planning.no_shifts")
+ return "\n\n".join(
+ day
+ + "\n"
+ + "\n".join(
+ t("email.planning.table.text_line", time=time, position=position, referent=referent)
+ for time, position, referent in rows
+ )
+ for day, rows in sections
+ )
+
+
+@dataclass(frozen=True)
+class _PlanningEmail:
+ subject: str
+ body: str
+ # Corps de la version HTML : `{creneaux}` y devient un vrai tableau.
+ html_body: str
+ html_blocks: dict[str, str]
+
+
def _planning_email(
*,
+ ctx: _PlanningContext,
subject_template: str,
body_template: str,
first_name: str,
last_name: str,
shifts: list[Shift],
- positions: dict[uuid.UUID, Position],
login_link: str,
-) -> tuple[str, str]:
- """Sujet et corps du récapitulatif de planning d'un bénévole — partagé
- par l'envoi réel et l'email de test."""
- shifts = sorted(shifts, key=lambda s: s.starts_at)
+) -> _PlanningEmail:
+ """Récapitulatif de planning d'un bénévole — partagé par l'envoi réel
+ et l'email de test. `{creneaux}` : tableau par jour (horaire, poste,
+ référent avec son téléphone), en HTML et en texte."""
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"),
+ sections = _shift_sections(shifts, ctx)
+
+ def render(shifts_text: str) -> tuple[str, str]:
+ return render_planning(
+ subject_template=subject_template,
+ body_template=body_template,
+ first_name=first_name,
+ last_name=last_name,
+ shift_count=len(shifts),
+ hours=round(minutes / 60, 1),
+ shifts_text=shifts_text,
+ login_link=login_link,
)
- for s in shifts
- ) or t("email.planning.no_shifts")
- return render_planning(
- subject_template=subject_template,
- body_template=body_template,
- first_name=first_name,
- last_name=last_name,
- shift_count=len(shifts),
- hours=round(minutes / 60, 1),
- shifts_text=shifts_text,
- login_link=login_link,
+
+ subject, body = render(_shifts_text(sections))
+ if not sections:
+ return _PlanningEmail(subject, body, body, {})
+ _, html_body = render(_SHIFTS_MARKER)
+ table = render_planning_table(
+ sections,
+ (
+ t("email.planning.table.time"),
+ t("email.planning.table.position"),
+ t("email.planning.table.referent"),
+ ),
)
+ return _PlanningEmail(subject, body, html_body, {_SHIFTS_MARKER: table})
@router.post("/test", response_model=TestEmailResult)
@@ -100,17 +202,8 @@ def send_test_email(
if "@" not in email or email.startswith("@") or email.endswith("@") or " " in email:
raise HTTPException(status_code=422, detail={"code": "notifications.invalid_email"})
- 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)
-
+ ctx = _load_context(session, edition)
+ shifts_by_volunteer = ctx.shifts_by_volunteer
volunteers = list(
session.exec(
select(Volunteer)
@@ -122,20 +215,22 @@ def send_test_email(
(v for v in volunteers if shifts_by_volunteer.get(v.id)),
volunteers[0] if volunteers else None,
)
- subject, body = _planning_email(
+ composed = _planning_email(
+ ctx=ctx,
subject_template=payload.subject or t("email.planning.default_subject"),
body_template=payload.body or t("email.planning.default_body"),
first_name=example.first_name if example else t("email.test.example_first_name"),
last_name=example.last_name if example else t("email.test.example_last_name"),
shifts=shifts_by_volunteer.get(example.id, []) if example else [],
- positions=positions,
login_link=build_magic_link_url("").split("?")[0],
)
message = build_message(
email,
- t("email.test.subject_prefix", subject=subject),
- body,
+ t("email.test.subject_prefix", subject=composed.subject),
+ composed.body,
logo=edition_logo(session, edition.id),
+ html_body=composed.html_body,
+ html_blocks=composed.html_blocks,
)
status, detail = deliver_test(message)
settings = get_settings()
@@ -170,53 +265,51 @@ def send_planning_notification(
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)
+ ctx = _load_context(session, edition)
+ logo = edition_logo(session, edition.id)
- items: list[tuple[str, str, str]] = []
+ messages: list[EmailMessage] = []
recipients: list[uuid.UUID] = []
skipped_no_email = 0
for volunteer in volunteers:
if not volunteer.email:
skipped_no_email += 1
continue
- subject, body = _planning_email(
+ composed = _planning_email(
+ ctx=ctx,
subject_template=subject_template,
body_template=body_template,
first_name=volunteer.first_name,
last_name=volunteer.last_name,
- shifts=shifts_by_volunteer.get(volunteer.id, []),
- positions=positions,
+ shifts=ctx.shifts_by_volunteer.get(volunteer.id, []),
login_link=(
_login_link(session, edition, volunteer)
if wants_login_link
else ""
),
)
- items.append((volunteer.email, subject, body))
+ messages.append(
+ build_message(
+ volunteer.email,
+ composed.subject,
+ composed.body,
+ logo=logo,
+ html_body=composed.html_body,
+ html_blocks=composed.html_blocks,
+ )
+ )
recipients.append(volunteer.id)
session.commit() # persiste les éventuels MagicLinkToken émis
logger.info(
"Récapitulatif de planning (édition %s) : %d emails en file, %d bénévoles sans email",
edition.id,
- len(items),
+ len(messages),
skipped_no_email,
)
- background.add_task(send_many, items, logo=edition_logo(session, edition.id))
+ background.add_task(send_messages, messages)
return PlanningNotificationResult(
- queued=len(items),
+ queued=len(messages),
skipped_no_email=skipped_no_email,
recipients=recipients,
)
diff --git a/backend/app/email/html.py b/backend/app/email/html.py
index 04afe30..ca96945 100644
--- a/backend/app/email/html.py
+++ b/backend/app/email/html.py
@@ -9,6 +9,8 @@ que dans le texte déjà journalisable). Pas de CSS externe ni de police web
en ligne, polices système en repli du style « Archivo »/« DM Mono »."""
import html as _html
+import re
+from collections.abc import Mapping, Sequence
_INK = "#14110E"
_CREAM = "#F5EFE1"
@@ -26,21 +28,72 @@ _FONT_MONO = "'Courier New', Courier, monospace"
LOGO_CID = "edition-logo"
-def _paragraphs(body: str) -> str:
+_P_OPEN = ''
+
+
+def _paragraphs(body: str, blocks: Mapping[str, str]) -> str:
"""`\n\n` sépare les paragraphes, un simple `\n` reste un retour à la
- ligne — même convention que les modèles texte (`app/email/templates.py`)."""
- blocks = body.strip().split("\n\n")
- return "".join(
- f'
{_html.escape(block).replace(chr(10), "
")}
'
- for block in blocks
- if block.strip()
+ ligne — même convention que les modèles texte (`app/email/templates.py`).
+ `blocks` : repère → HTML déjà construit (ex. tableau des créneaux),
+ inséré tel quel à la place du repère, hors de tout paragraphe."""
+ out = []
+ for block in body.strip().split("\n\n"):
+ if not block.strip():
+ continue
+ html = _P_OPEN + _html.escape(block).replace(chr(10), "
") + "
"
+ for marker, fragment in blocks.items():
+ html = html.replace(_html.escape(marker), f"{fragment}{_P_OPEN}")
+ # Paragraphes vidés par l'insertion d'un bloc (repère seul, ou
+ # entouré de retours à la ligne).
+ html = re.sub(rf"{re.escape(_P_OPEN)}(?:
|\s)*", "", html)
+ out.append(html)
+ return "".join(out)
+
+
+_CELL = f"padding:6px 8px;border:1px solid {_INK};vertical-align:top;"
+
+
+def render_planning_table(
+ sections: Sequence[tuple[str, Sequence[tuple[str, str, str]]]],
+ headers: tuple[str, str, str],
+) -> str:
+ """Tableau des créneaux d'un bénévole, comme sur les badges : le jour
+ en en-tête sur toute la largeur, puis une ligne par créneau (horaire,
+ poste, référent). `sections` = `[(jour, [(horaire, poste, référent)])]`."""
+ head = "".join(
+ f''
+ f"{_html.escape(h)} | "
+ for h in headers
+ )
+ rows = [f"{head}
"]
+ for day, lines in sections:
+ rows.append(
+ f'| {_html.escape(day)} |
'
+ )
+ for time, position, referent in lines:
+ rows.append(
+ f'| '
+ f"{_html.escape(time)} | "
+ f''
+ f"{_html.escape(position)} | "
+ f'{_html.escape(referent).replace(chr(10), " ")} |
'
+ )
+ return (
+ '"
)
-def render_html(*, subject: str, body: str, has_logo: bool) -> str:
+def render_html(
+ *, subject: str, body: str, has_logo: bool, blocks: Mapping[str, str] | None = None
+) -> str:
"""`has_logo` : voir `app/email/branding.py:edition_logo` — `False` tant
que l'édition n'a pas importé de logo, l'en-tête retombe alors sur un
- simple bandeau texte (le sujet, en capitales)."""
+ simple bandeau texte (le sujet, en capitales). `blocks` : voir
+ `_paragraphs`."""
if has_logo:
header = (
f'
str:
- {_paragraphs(body)}
+ {_paragraphs(body, blocks or {})}
|
diff --git a/backend/app/email/sender.py b/backend/app/email/sender.py
index 23e4b5f..ebec9c6 100644
--- a/backend/app/email/sender.py
+++ b/backend/app/email/sender.py
@@ -8,7 +8,7 @@ Pour diagnostiquer : `python -m app.email.check `."""
import logging
import smtplib
import ssl
-from collections.abc import Iterable, Sequence
+from collections.abc import Iterable, Mapping, Sequence
from email.message import EmailMessage
from app.config import Settings, get_settings
@@ -44,8 +44,15 @@ def build_message(
*,
logo: tuple[bytes, str] | None = None,
attachments: Sequence[Attachment] = (),
+ html_body: str | None = None,
+ html_blocks: Mapping[str, str] | None = None,
) -> EmailMessage:
- """`logo` = (octets, content-type) (voir `app/email/branding.py:
+ """`html_body` / `html_blocks` : corps de la version HTML quand il
+ diffère du texte — repères remplacés par des blocs HTML déjà construits
+ (ex. tableau des créneaux, `app/email/html.py:render_planning_table`) ;
+ le texte brut garde sa propre version lisible.
+
+ `logo` = (octets, content-type) (voir `app/email/branding.py:
edition_logo`) : ajoute une version HTML au thème de l'appli
(`app/email/html.py`) en alternative au texte brut — le texte reste la
version de référence, envoyée telle quelle si aucun client ne préfère le
@@ -61,7 +68,12 @@ def build_message(
message["Subject"] = subject
message.set_content(body)
message.add_alternative(
- render_html(subject=subject, body=body, has_logo=logo is not None),
+ render_html(
+ subject=subject,
+ body=html_body if html_body is not None else body,
+ has_logo=logo is not None,
+ blocks=html_blocks,
+ ),
subtype="html",
)
if logo is not None:
@@ -189,6 +201,12 @@ def send(
deliver_many([build_message(to, subject, body, logo=logo, attachments=attachments)])
+def send_messages(messages: list[EmailMessage]) -> None:
+ """Remet des messages déjà construits (`build_message`) — ex. le
+ récapitulatif de planning, dont la version HTML porte un tableau."""
+ deliver_many(messages)
+
+
def send_many(
items: Iterable[tuple[str, str, str]], *, logo: tuple[bytes, str] | None = None
) -> None:
diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json
index b50d3ee..92f7f8f 100644
--- a/backend/app/i18n/locales/fr.json
+++ b/backend/app/i18n/locales/fr.json
@@ -58,7 +58,11 @@
"email.test.example_last_name": "Exemple",
"notifications.invalid_email": "Adresse email invalide.",
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
- "email.planning.shift_line": "- {position} : {start} → {end}",
+ "email.planning.table.time": "Horaire",
+ "email.planning.table.position": "Poste",
+ "email.planning.table.referent": "Référent",
+ "email.planning.table.no_referent": "à désigner",
+ "email.planning.table.text_line": " {time} · {position} · référent : {referent}",
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
"badge.image_unreadable": "L'image est illisible ou corrompue.",
"badge.empty_image": "L'image est vide.",
diff --git a/backend/tests/test_api_notifications.py b/backend/tests/test_api_notifications.py
index 07c6e51..4ea1f45 100644
--- a/backend/tests/test_api_notifications.py
+++ b/backend/tests/test_api_notifications.py
@@ -91,3 +91,41 @@ def test_planning_notification_is_coordinator_only(
lead = make_account(Role.LEAD, email="lead@e.fr")
resp = as_account(lead).post(_url(edition), json={})
assert resp.status_code == 403
+
+
+def test_shifts_placeholder_is_a_table_by_day_with_the_referent(
+ client, edition, session, outbox, make_account
+):
+ rita = make_volunteer(session, edition, first_name="Rita", last_name="Ref", email="r@e.fr")
+ rita.phone = "06 11 22 33 44"
+ session.add(rita)
+ lead = make_account(Role.LEAD, volunteer_id=rita.id)
+ bar = make_position(session, edition, name="Bar central")
+ bar.lead_account_id = lead.id
+ session.add(bar)
+ parking = make_position(session, edition, name="Parking")
+ s1 = make_shift(session, edition, bar, start=dt_at(10, 18), end=dt_at(10, 22))
+ s2 = make_shift(session, edition, parking, start=dt_at(11, 9), end=dt_at(11, 12))
+ ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
+ session.add_all(
+ [
+ Assignment(edition_id=edition.id, volunteer_id=ana.id, shift_id=s1.id),
+ Assignment(edition_id=edition.id, volunteer_id=ana.id, shift_id=s2.id),
+ ]
+ )
+ session.commit()
+
+ client.post(
+ _url(edition), json={"volunteer_ids": [str(ana.id)], "body": "Ton planning :\n\n{creneaux}"}
+ )
+ (message,) = outbox
+ html = message.get_body(preferencelist=("html",)).get_content()
+ # Vrai tableau : en-têtes, une ligne de jour, horaire, poste, référent.
+ assert "