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
+10 -1
View File
@@ -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 é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`, 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, 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` - **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 est effectivement créé et a un email — que ce soit tout de suite (pas de
paiement en ligne) ou à la promotion d'une `PendingFormSubmission` 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 | | 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 | | 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 | | 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 **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, IA. Frontend : squelette + 6 écrans + de formulaire, IA. Frontend : squelette + 6 écrans +
+146 -53
View File
@@ -1,8 +1,11 @@
"""Envoi d'emails d'information aux bénévoles (récapitulatif de planning).""" """Envoi d'emails d'information aux bénévoles (récapitulatif de planning)."""
import datetime as dt
import logging import logging
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass
from email.message import EmailMessage
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlmodel import Session, select 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.auth.magic_link import build_magic_link_url, issue_magic_link_token
from app.config import get_settings from app.config import get_settings
from app.db import get_session from app.db import get_session
from app.email import edition_logo, render_planning, send_many from app.email import edition_logo, render_planning
from app.email.sender import build_message, deliver_test 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.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") logger = logging.getLogger("app.notifications")
@@ -45,29 +50,112 @@ def _login_link(session: Session, edition: Edition, volunteer: Volunteer) -> str
return build_magic_link_url(issue_magic_link_token(session, account)) 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( def _planning_email(
*, *,
ctx: _PlanningContext,
subject_template: str, subject_template: str,
body_template: str, body_template: str,
first_name: str, first_name: str,
last_name: str, last_name: str,
shifts: list[Shift], shifts: list[Shift],
positions: dict[uuid.UUID, Position],
login_link: str, login_link: str,
) -> tuple[str, str]: ) -> _PlanningEmail:
"""Sujet et corps du récapitulatif de planning d'un bénévole — partagé """Récapitulatif de planning d'un bénévole — partagé par l'envoi réel
par l'envoi réel et l'email de test.""" et l'email de test. `{creneaux}` : tableau par jour (horaire, poste,
shifts = sorted(shifts, key=lambda s: s.starts_at) 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) minutes = sum((s.ends_at - s.starts_at).total_seconds() / 60 for s in shifts)
shifts_text = "\n".join( sections = _shift_sections(shifts, ctx)
t(
"email.planning.shift_line", def render(shifts_text: str) -> tuple[str, str]:
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")
return render_planning( return render_planning(
subject_template=subject_template, subject_template=subject_template,
body_template=body_template, body_template=body_template,
@@ -79,6 +167,20 @@ def _planning_email(
login_link=login_link, 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) @router.post("/test", response_model=TestEmailResult)
def send_test_email( def send_test_email(
@@ -100,17 +202,8 @@ def send_test_email(
if "@" not in email or email.startswith("@") or email.endswith("@") or " " in email: if "@" not in email or email.startswith("@") or email.endswith("@") or " " in email:
raise HTTPException(status_code=422, detail={"code": "notifications.invalid_email"}) raise HTTPException(status_code=422, detail={"code": "notifications.invalid_email"})
positions = { ctx = _load_context(session, edition)
p.id: p for p in session.exec(select(Position).where(Position.edition_id == edition.id)) shifts_by_volunteer = ctx.shifts_by_volunteer
}
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)
volunteers = list( volunteers = list(
session.exec( session.exec(
select(Volunteer) select(Volunteer)
@@ -122,20 +215,22 @@ def send_test_email(
(v for v in volunteers if shifts_by_volunteer.get(v.id)), (v for v in volunteers if shifts_by_volunteer.get(v.id)),
volunteers[0] if volunteers else None, 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"), subject_template=payload.subject or t("email.planning.default_subject"),
body_template=payload.body or t("email.planning.default_body"), body_template=payload.body or t("email.planning.default_body"),
first_name=example.first_name if example else t("email.test.example_first_name"), 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"), last_name=example.last_name if example else t("email.test.example_last_name"),
shifts=shifts_by_volunteer.get(example.id, []) if example else [], shifts=shifts_by_volunteer.get(example.id, []) if example else [],
positions=positions,
login_link=build_magic_link_url("").split("?")[0], login_link=build_magic_link_url("").split("?")[0],
) )
message = build_message( message = build_message(
email, email,
t("email.test.subject_prefix", subject=subject), t("email.test.subject_prefix", subject=composed.subject),
body, composed.body,
logo=edition_logo(session, edition.id), logo=edition_logo(session, edition.id),
html_body=composed.html_body,
html_blocks=composed.html_blocks,
) )
status, detail = deliver_test(message) status, detail = deliver_test(message)
settings = get_settings() settings = get_settings()
@@ -170,53 +265,51 @@ def send_planning_notification(
stmt = stmt.where(Volunteer.id.in_(payload.volunteer_ids)) stmt = stmt.where(Volunteer.id.in_(payload.volunteer_ids))
volunteers = list(session.exec(stmt.order_by(Volunteer.last_name))) volunteers = list(session.exec(stmt.order_by(Volunteer.last_name)))
positions = { ctx = _load_context(session, edition)
p.id: p logo = edition_logo(session, edition.id)
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]] = [] messages: list[EmailMessage] = []
recipients: list[uuid.UUID] = [] recipients: list[uuid.UUID] = []
skipped_no_email = 0 skipped_no_email = 0
for volunteer in volunteers: for volunteer in volunteers:
if not volunteer.email: if not volunteer.email:
skipped_no_email += 1 skipped_no_email += 1
continue continue
subject, body = _planning_email( composed = _planning_email(
ctx=ctx,
subject_template=subject_template, subject_template=subject_template,
body_template=body_template, body_template=body_template,
first_name=volunteer.first_name, first_name=volunteer.first_name,
last_name=volunteer.last_name, last_name=volunteer.last_name,
shifts=shifts_by_volunteer.get(volunteer.id, []), shifts=ctx.shifts_by_volunteer.get(volunteer.id, []),
positions=positions,
login_link=( login_link=(
_login_link(session, edition, volunteer) _login_link(session, edition, volunteer)
if wants_login_link if wants_login_link
else "" 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) recipients.append(volunteer.id)
session.commit() # persiste les éventuels MagicLinkToken émis session.commit() # persiste les éventuels MagicLinkToken émis
logger.info( logger.info(
"Récapitulatif de planning (édition %s) : %d emails en file, %d bénévoles sans email", "Récapitulatif de planning (édition %s) : %d emails en file, %d bénévoles sans email",
edition.id, edition.id,
len(items), len(messages),
skipped_no_email, skipped_no_email,
) )
background.add_task(send_many, items, logo=edition_logo(session, edition.id)) background.add_task(send_messages, messages)
return PlanningNotificationResult( return PlanningNotificationResult(
queued=len(items), queued=len(messages),
skipped_no_email=skipped_no_email, skipped_no_email=skipped_no_email,
recipients=recipients, 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 ».""" en ligne, polices système en repli du style « Archivo »/« DM Mono »."""
import html as _html import html as _html
import re
from collections.abc import Mapping, Sequence
_INK = "#14110E" _INK = "#14110E"
_CREAM = "#F5EFE1" _CREAM = "#F5EFE1"
@@ -26,21 +28,72 @@ _FONT_MONO = "'Courier New', Courier, monospace"
LOGO_CID = "edition-logo" 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 """`\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`).""" ligne — même convention que les modèles texte (`app/email/templates.py`).
blocks = body.strip().split("\n\n") `blocks` : repère → HTML déjà construit (ex. tableau des créneaux),
return "".join( inséré tel quel à la place du repère, hors de tout paragraphe."""
f'<p style="margin:0 0 14px;">{_html.escape(block).replace(chr(10), "<br>")}</p>' out = []
for block in blocks for block in body.strip().split("\n\n"):
if block.strip() 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 """`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 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: if has_logo:
header = ( header = (
f'<img src="cid:{LOGO_CID}" alt="" width="160" ' 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}; <td style="background:{_PAPER};border:3px solid {_INK};
border-top:none;padding:22px;font-size:13px; border-top:none;padding:22px;font-size:13px;
line-height:1.6;color:{_INK};font-family:{_FONT_MONO};"> line-height:1.6;color:{_INK};font-family:{_FONT_MONO};">
{_paragraphs(body)} {_paragraphs(body, blocks or {})}
</td> </td>
</tr> </tr>
</table> </table>
+21 -3
View File
@@ -8,7 +8,7 @@ Pour diagnostiquer : `python -m app.email.check <destinataire>`."""
import logging import logging
import smtplib import smtplib
import ssl import ssl
from collections.abc import Iterable, Sequence from collections.abc import Iterable, Mapping, Sequence
from email.message import EmailMessage from email.message import EmailMessage
from app.config import Settings, get_settings from app.config import Settings, get_settings
@@ -44,8 +44,15 @@ def build_message(
*, *,
logo: tuple[bytes, str] | None = None, logo: tuple[bytes, str] | None = None,
attachments: Sequence[Attachment] = (), attachments: Sequence[Attachment] = (),
html_body: str | None = None,
html_blocks: Mapping[str, str] | None = None,
) -> EmailMessage: ) -> 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 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 (`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 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["Subject"] = subject
message.set_content(body) message.set_content(body)
message.add_alternative( 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", subtype="html",
) )
if logo is not None: if logo is not None:
@@ -189,6 +201,12 @@ def send(
deliver_many([build_message(to, subject, body, logo=logo, attachments=attachments)]) 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( def send_many(
items: Iterable[tuple[str, str, str]], *, logo: tuple[bytes, str] | None = None items: Iterable[tuple[str, str, str]], *, logo: tuple[bytes, str] | None = None
) -> None: ) -> None:
+5 -1
View File
@@ -58,7 +58,11 @@
"email.test.example_last_name": "Exemple", "email.test.example_last_name": "Exemple",
"notifications.invalid_email": "Adresse email invalide.", "notifications.invalid_email": "Adresse email invalide.",
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.", "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_type_not_supported": "L'image doit être au format PNG ou JPEG.",
"badge.image_unreadable": "L'image est illisible ou corrompue.", "badge.image_unreadable": "L'image est illisible ou corrompue.",
"badge.empty_image": "L'image est vide.", "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") lead = make_account(Role.LEAD, email="lead@e.fr")
resp = as_account(lead).post(_url(edition), json={}) resp = as_account(lead).post(_url(edition), json={})
assert resp.status_code == 403 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