Email de test : récapitulatif de planning compilé
L'email de test reprend le modèle en cours d'édition, balises remplies avec le planning d'un bénévole au choix (premier avec un créneau par défaut), option lien de connexion sans vrai jeton, sujet « [Test] … » — même fonction de rendu que l'envoi réel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
461742eb78
commit
440466ccf9
@@ -45,6 +45,41 @@ def _login_link(session: Session, edition: Edition, volunteer: Volunteer) -> str
|
||||
return build_magic_link_url(issue_magic_link_token(session, account))
|
||||
|
||||
|
||||
def _planning_email(
|
||||
*,
|
||||
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)
|
||||
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"),
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test", response_model=TestEmailResult)
|
||||
def send_test_email(
|
||||
payload: TestEmailRequest,
|
||||
@@ -52,17 +87,54 @@ def send_test_email(
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> TestEmailResult:
|
||||
"""Envoie tout de suite un email de test à l'adresse saisie (même mise
|
||||
en forme et même logo que les vrais emails) et renvoie le résultat réel
|
||||
du serveur SMTP — pour vérifier la configuration depuis l'écran
|
||||
« Messages » sans accès au serveur (`python -m app.email.check`)."""
|
||||
"""Envoie tout de suite à l'adresse saisie le récapitulatif de planning
|
||||
**tel que les bénévoles le recevront** : le modèle en cours d'édition
|
||||
(`subject`/`body`), variables remplies avec les données d'un vrai
|
||||
bénévole, même mise en forme et même logo — sujet préfixé « [Test] ».
|
||||
Renvoie le résultat réel du serveur SMTP, pour vérifier à la fois le
|
||||
contenu et la configuration d'envoi depuis l'écran « Messages ».
|
||||
`{lien_connexion}` y est remplacé par l'adresse de la page de connexion,
|
||||
**sans jeton** : un vrai lien magique ouvrirait le compte du bénévole à
|
||||
quiconque lit la boîte de test."""
|
||||
email = payload.email.strip()
|
||||
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)
|
||||
|
||||
volunteers = list(
|
||||
session.exec(
|
||||
select(Volunteer)
|
||||
.where(Volunteer.edition_id == edition.id)
|
||||
.order_by(Volunteer.last_name, Volunteer.first_name)
|
||||
)
|
||||
)
|
||||
example = next((v for v in volunteers if v.id == payload.volunteer_id), None) or next(
|
||||
(v for v in volunteers if shifts_by_volunteer.get(v.id)),
|
||||
volunteers[0] if volunteers else None,
|
||||
)
|
||||
subject, body = _planning_email(
|
||||
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"),
|
||||
t("email.test.body", edition=edition.name or str(edition.year)),
|
||||
t("email.test.subject_prefix", subject=subject),
|
||||
body,
|
||||
logo=edition_logo(session, edition.id),
|
||||
)
|
||||
status, detail = deliver_test(message)
|
||||
@@ -72,6 +144,10 @@ def send_test_email(
|
||||
detail=detail,
|
||||
smtp_host=f"{settings.smtp_host}:{settings.smtp_port}",
|
||||
smtp_from=settings.smtp_from,
|
||||
example_volunteer_id=example.id if example else None,
|
||||
example_volunteer_name=(
|
||||
f"{example.first_name} {example.last_name}".strip() if example else ""
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -115,30 +191,13 @@ def send_planning_notification(
|
||||
if not volunteer.email:
|
||||
skipped_no_email += 1
|
||||
continue
|
||||
shifts = sorted(
|
||||
shifts_by_volunteer.get(volunteer.id, []),
|
||||
key=lambda s: s.starts_at,
|
||||
)
|
||||
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"),
|
||||
)
|
||||
for s in shifts
|
||||
) or t("email.planning.no_shifts")
|
||||
subject, body = render_planning(
|
||||
subject, body = _planning_email(
|
||||
subject_template=subject_template,
|
||||
body_template=body_template,
|
||||
first_name=volunteer.first_name,
|
||||
last_name=volunteer.last_name,
|
||||
shift_count=len(shifts),
|
||||
hours=round(minutes / 60, 1),
|
||||
shifts_text=shifts_text,
|
||||
shifts=shifts_by_volunteer.get(volunteer.id, []),
|
||||
positions=positions,
|
||||
login_link=(
|
||||
_login_link(session, edition, volunteer)
|
||||
if wants_login_link
|
||||
|
||||
@@ -518,6 +518,14 @@ class PlanningNotificationResult(ApiModel):
|
||||
|
||||
class TestEmailRequest(ApiModel):
|
||||
email: str
|
||||
# Modèle du récapitulatif de planning en cours d'édition (mêmes
|
||||
# variables que `PlanningNotificationRequest`) ; absent = modèle par
|
||||
# défaut.
|
||||
subject: str | None = None
|
||||
body: str | None = None
|
||||
# Bénévole dont les données remplissent les variables ; absent = le
|
||||
# premier (par nom) qui a au moins un créneau.
|
||||
volunteer_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class TestEmailResult(ApiModel):
|
||||
@@ -529,6 +537,10 @@ class TestEmailResult(ApiModel):
|
||||
detail: str | None = None
|
||||
smtp_host: str
|
||||
smtp_from: str
|
||||
# Bénévole dont les données ont rempli le modèle (`None` : exemple
|
||||
# fictif, l'édition n'a encore aucun bénévole).
|
||||
example_volunteer_id: uuid.UUID | None = None
|
||||
example_volunteer_name: str
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -53,8 +53,9 @@
|
||||
"email.registration_confirmation_paid.mecenat_receipt_note": "Tu trouveras en pièce jointe ton reçu fiscal au titre des dons (cerfa n° 11580*05), à conserver pour ta déclaration de revenus.",
|
||||
"email.planning.default_subject": "Ton planning bénévole",
|
||||
"email.planning.default_body": "Bonjour {prenom},\n\nTon planning a été mis à jour : {nb_creneaux} créneau(x), soit {nb_heures} h au total.\n\n{creneaux}\n\nÀ très vite sur le festival !",
|
||||
"email.test.subject": "Email de test — gestion des bénévoles",
|
||||
"email.test.body": "Bonjour,\n\nCet email de test confirme que l'envoi d'emails fonctionne pour {edition}.\n\nRien à faire de plus.",
|
||||
"email.test.subject_prefix": "[Test] {subject}",
|
||||
"email.test.example_first_name": "Camille",
|
||||
"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}",
|
||||
|
||||
@@ -81,3 +81,64 @@ def test_invalid_address_is_rejected(client, edition, email):
|
||||
def test_is_coordinator_only(edition, make_account, as_account):
|
||||
lead = as_account(make_account(Role.LEAD))
|
||||
assert lead.post(_url(edition), json={"email": "moi@exemple.fr"}).status_code == 403
|
||||
|
||||
|
||||
def _send(client, edition, monkeypatch, **payload):
|
||||
captured = []
|
||||
monkeypatch.setattr(
|
||||
"app.api.notifications.deliver_test",
|
||||
lambda message: captured.append(message) or ("sent", None),
|
||||
)
|
||||
body = client.post(_url(edition), json={"email": "moi@exemple.fr", **payload}).json()
|
||||
return body, captured[0]
|
||||
|
||||
|
||||
def test_test_email_is_the_compiled_planning_email(client, edition, session, monkeypatch):
|
||||
from app.models import Assignment
|
||||
from tests.factories import dt_at, make_position, make_shift, make_volunteer
|
||||
from tests.mail import plain_text
|
||||
|
||||
make_volunteer(session, edition, first_name="Zoé", last_name="Zébu") # sans créneau
|
||||
alex = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
shift = make_shift(session, edition, make_position(session, edition, name="Bar"),
|
||||
start=dt_at(10, 18), end=dt_at(10, 22))
|
||||
session.add(Assignment(edition_id=edition.id, volunteer_id=alex.id, shift_id=shift.id))
|
||||
session.commit()
|
||||
|
||||
body, message = _send(
|
||||
client, edition, monkeypatch,
|
||||
subject="Planning de {prenom}",
|
||||
body="Salut {prenom} {nom} : {nb_creneaux} créneau(x), {nb_heures} h\n{creneaux}",
|
||||
)
|
||||
# Premier bénévole (par nom) qui a un créneau, pas Zoé.
|
||||
assert body["example_volunteer_name"] == "Alex Martin"
|
||||
assert message["Subject"] == "[Test] Planning de Alex"
|
||||
text = plain_text(message)
|
||||
assert "Salut Alex Martin : 1 créneau(x), 4.0 h" in text
|
||||
assert "Bar" in text
|
||||
assert "{" not in text
|
||||
|
||||
|
||||
def test_login_link_in_test_email_carries_no_token(client, edition, session, monkeypatch):
|
||||
from sqlmodel import select
|
||||
|
||||
from app.models import MagicLinkToken
|
||||
from tests.factories import make_volunteer
|
||||
from tests.mail import plain_text
|
||||
|
||||
make_volunteer(session, edition)
|
||||
_, message = _send(client, edition, monkeypatch, body="Connexion : {lien_connexion}")
|
||||
assert "token=" not in plain_text(message)
|
||||
assert session.exec(select(MagicLinkToken)).all() == []
|
||||
|
||||
|
||||
def test_chosen_volunteer_and_fictitious_example(client, edition, session, monkeypatch):
|
||||
from tests.factories import make_volunteer
|
||||
|
||||
body, message = _send(client, edition, monkeypatch, subject="Pour {prenom}")
|
||||
assert body["example_volunteer_id"] is None
|
||||
assert message["Subject"] == "[Test] Pour Camille"
|
||||
|
||||
sam = make_volunteer(session, edition, first_name="Sam", last_name="Nuit")
|
||||
body, _ = _send(client, edition, monkeypatch, volunteer_id=str(sam.id))
|
||||
assert body["example_volunteer_id"] == str(sam.id)
|
||||
|
||||
Reference in New Issue
Block a user