Récapitulatif de planning : créneaux en tableau avec le référent
ci-cd / build-backend (push) Successful in 48s
ci-cd / deploy (push) Successful in 13s
ci-cd / build-frontend (push) Successful in 7s

{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 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-28 19:52:06 +01:00
co-authored by Claude Opus 5.5
parent 440466ccf9
commit 8d6781e6ce
6 changed files with 291 additions and 76 deletions
+154 -61
View File
@@ -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,
)
+63 -10
View File
@@ -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 = '<p style="margin:0 0 14px;">'
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'<p style="margin:0 0 14px;">{_html.escape(block).replace(chr(10), "<br>")}</p>'
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), "<br>") + "</p>"
for marker, fragment in blocks.items():
html = html.replace(_html.escape(marker), f"</p>{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)}(?:<br>|\s)*</p>", "", 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'<th align="left" style="{_CELL}background:{_INK};color:{_CREAM};'
f'font-family:{_FONT_DISPLAY};font-size:11px;text-transform:uppercase;">'
f"{_html.escape(h)}</th>"
for h in headers
)
rows = [f"<tr>{head}</tr>"]
for day, lines in sections:
rows.append(
f'<tr><td colspan="3" style="{_CELL}background:{_YELLOW};'
f'font-family:{_FONT_DISPLAY};font-weight:800;">{_html.escape(day)}</td></tr>'
)
for time, position, referent in lines:
rows.append(
f'<tr><td style="{_CELL}white-space:nowrap;font-weight:700;">'
f"{_html.escape(time)}</td>"
f'<td style="{_CELL}font-family:{_FONT_DISPLAY};font-weight:700;">'
f"{_html.escape(position)}</td>"
f'<td style="{_CELL}">{_html.escape(referent).replace(chr(10), "<br>")}</td></tr>'
)
return (
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
f'style="border-collapse:collapse;margin:0 0 14px;font-size:12px;'
f'font-family:{_FONT_MONO};background:{_PAPER};">' + "".join(rows) + "</table>"
)
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'<img src="cid:{LOGO_CID}" alt="" width="160" '
@@ -72,7 +125,7 @@ def render_html(*, subject: str, body: str, has_logo: bool) -> str:
<td style="background:{_PAPER};border:3px solid {_INK};
border-top:none;padding:22px;font-size:13px;
line-height:1.6;color:{_INK};font-family:{_FONT_MONO};">
{_paragraphs(body)}
{_paragraphs(body, blocks or {})}
</td>
</tr>
</table>
+21 -3
View File
@@ -8,7 +8,7 @@ Pour diagnostiquer : `python -m app.email.check <destinataire>`."""
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:
+5 -1
View File
@@ -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.",
+38
View File
@@ -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 "<table" in html and "Référent" in html
assert "18:00–22:00" in html and "Bar central" in html
assert "Rita Ref · 06 11 22 33 44" in html
assert "à désigner" in html # Parking n'a pas de référent
assert "" not in html
text = plain_text(message)
assert "18:00–22:00 · Bar central · référent : Rita Ref · 06 11 22 33 44" in text
assert "09:00–12:00 · Parking · référent : à désigner" in text