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 ( + '' + "".join(rows) + "
" ) -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 "