Récapitulatif de planning : créneaux en tableau avec le référent
{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:
co-authored by
Claude Opus 5.5
parent
440466ccf9
commit
8d6781e6ce
@@ -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 +
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user