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
+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 »."""
|
||||
|
||||
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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user