Email de test et ajout manuel sans champ obligatoire
Étape 67 : adresse + bouton dans l'onglet « Messages » pour envoyer tout de suite un email de test, résultat réel affiché (envoyé, désactivé, connexion ou message refusé, réponse du serveur SMTP) — POST .../notifications/test. Étape 66 : prénom et nom facultatifs à l'ajout manuel d'un bénévole ; un bénévole sans nom s'affiche « Sans nom » / « ? » partout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5c2cc0a6c3
commit
461742eb78
@@ -4,18 +4,22 @@ import logging
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api.deps import get_edition
|
||||
from app.api.schemas import (
|
||||
PlanningNotificationRequest,
|
||||
PlanningNotificationResult,
|
||||
TestEmailRequest,
|
||||
TestEmailResult,
|
||||
)
|
||||
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.i18n import t
|
||||
from app.models import Account, Assignment, Edition, Position, Shift, Volunteer
|
||||
|
||||
@@ -41,6 +45,36 @@ def _login_link(session: Session, edition: Edition, volunteer: Volunteer) -> str
|
||||
return build_magic_link_url(issue_magic_link_token(session, account))
|
||||
|
||||
|
||||
@router.post("/test", response_model=TestEmailResult)
|
||||
def send_test_email(
|
||||
payload: TestEmailRequest,
|
||||
edition: Edition = Depends(get_edition),
|
||||
_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`)."""
|
||||
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"})
|
||||
message = build_message(
|
||||
email,
|
||||
t("email.test.subject"),
|
||||
t("email.test.body", edition=edition.name or str(edition.year)),
|
||||
logo=edition_logo(session, edition.id),
|
||||
)
|
||||
status, detail = deliver_test(message)
|
||||
settings = get_settings()
|
||||
return TestEmailResult(
|
||||
status=status,
|
||||
detail=detail,
|
||||
smtp_host=f"{settings.smtp_host}:{settings.smtp_port}",
|
||||
smtp_from=settings.smtp_from,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/planning", response_model=PlanningNotificationResult)
|
||||
def send_planning_notification(
|
||||
payload: PlanningNotificationRequest,
|
||||
|
||||
@@ -60,8 +60,10 @@ class EditionUpdate(ApiModel):
|
||||
# Volunteer #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class VolunteerCreate(ApiModel):
|
||||
first_name: str
|
||||
last_name: str
|
||||
# Facultatifs à l'ajout manuel par la coordination (fiche complétée plus
|
||||
# tard) ; le formulaire public et l'import les exigent toujours.
|
||||
first_name: str = ""
|
||||
last_name: str = ""
|
||||
email: str | None = None
|
||||
phone: str | None = None
|
||||
comment: str | None = None
|
||||
@@ -514,6 +516,21 @@ class PlanningNotificationResult(ApiModel):
|
||||
recipients: list[uuid.UUID] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TestEmailRequest(ApiModel):
|
||||
email: str
|
||||
|
||||
|
||||
class TestEmailResult(ApiModel):
|
||||
"""`status` : `sent` / `disabled` / `connection_failed` / `rejected` —
|
||||
voir `app/email/sender.py:deliver_test`. `detail` : message du serveur
|
||||
SMTP en cas d'échec."""
|
||||
|
||||
status: str
|
||||
detail: str | None = None
|
||||
smtp_host: str
|
||||
smtp_from: str
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Formulaire public d'inscription #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -145,6 +145,39 @@ def deliver_many(messages: list[EmailMessage]) -> None:
|
||||
logger.info("Emails remis : %d/%d", sent, len(messages))
|
||||
|
||||
|
||||
def deliver_test(message: EmailMessage) -> tuple[str, str | None]:
|
||||
"""Envoi d'un email de test, **synchrone** et sans « best effort » :
|
||||
renvoie `(statut, détail)` au lieu de seulement journaliser, pour que la
|
||||
coordination voie tout de suite ce qui ne va pas (`POST
|
||||
.../notifications/test`). Statuts : `sent`, `disabled`
|
||||
(`EMAIL_ENABLED=false`), `connection_failed` (connexion ou
|
||||
authentification), `rejected` (le serveur a refusé le message). Détail :
|
||||
message du serveur ou type d'erreur — jamais l'adresse du destinataire
|
||||
(`_smtp_error_summary`)."""
|
||||
settings = get_settings()
|
||||
if not settings.email_enabled:
|
||||
logger.info("Email de test non envoyé (EMAIL_ENABLED=false)")
|
||||
return "disabled", None
|
||||
try:
|
||||
smtp = open_smtp(settings)
|
||||
except smtplib.SMTPException as exc:
|
||||
logger.error("Email de test : connexion SMTP refusée : %s", _smtp_error_summary(exc))
|
||||
return "connection_failed", _smtp_error_summary(exc)
|
||||
except OSError as exc:
|
||||
logger.error("Email de test : SMTP injoignable : %s", exc)
|
||||
return "connection_failed", f"{exc.__class__.__name__} : {exc}"
|
||||
with smtp:
|
||||
try:
|
||||
smtp.send_message(message)
|
||||
except smtplib.SMTPException as exc:
|
||||
logger.error(
|
||||
"Email de test refusé (From=%s) : %s", message["From"], _smtp_error_summary(exc)
|
||||
)
|
||||
return "rejected", _smtp_error_summary(exc)
|
||||
logger.info("Email de test remis")
|
||||
return "sent", None
|
||||
|
||||
|
||||
def send(
|
||||
to: str,
|
||||
subject: str,
|
||||
|
||||
@@ -53,6 +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.",
|
||||
"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}",
|
||||
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Email de test depuis l'écran « Messages » (`POST .../notifications/test`) :
|
||||
envoi synchrone, résultat réel du serveur SMTP renvoyé à la coordination."""
|
||||
|
||||
import smtplib
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Role
|
||||
|
||||
|
||||
def _url(edition):
|
||||
return f"/editions/{edition.id}/notifications/test"
|
||||
|
||||
|
||||
class _FakeSmtp:
|
||||
def __init__(self, error=None):
|
||||
self.sent = []
|
||||
self.error = error
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def send_message(self, message):
|
||||
if self.error:
|
||||
raise self.error
|
||||
self.sent.append(message)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def smtp_enabled(monkeypatch):
|
||||
monkeypatch.setenv("EMAIL_ENABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_disabled_email_is_reported(client, edition):
|
||||
resp = client.post(_url(edition), json={"email": "moi@exemple.fr"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "disabled"
|
||||
|
||||
|
||||
def test_sends_the_test_email(client, edition, smtp_enabled, monkeypatch):
|
||||
fake = _FakeSmtp()
|
||||
monkeypatch.setattr("app.email.sender.open_smtp", lambda settings: fake)
|
||||
resp = client.post(_url(edition), json={"email": " moi@exemple.fr "})
|
||||
assert resp.json()["status"] == "sent"
|
||||
(message,) = fake.sent
|
||||
assert message["To"] == "moi@exemple.fr"
|
||||
|
||||
|
||||
def test_connection_failure_is_reported_with_the_server_message(
|
||||
client, edition, smtp_enabled, monkeypatch
|
||||
):
|
||||
def refuse(settings):
|
||||
raise smtplib.SMTPAuthenticationError(535, b"authentication failed")
|
||||
|
||||
monkeypatch.setattr("app.email.sender.open_smtp", refuse)
|
||||
body = client.post(_url(edition), json={"email": "moi@exemple.fr"}).json()
|
||||
assert body["status"] == "connection_failed"
|
||||
assert "535" in body["detail"]
|
||||
|
||||
|
||||
def test_rejected_message_never_echoes_the_address(client, edition, smtp_enabled, monkeypatch):
|
||||
refused = smtplib.SMTPRecipientsRefused({"moi@exemple.fr": (550, b"no such user")})
|
||||
monkeypatch.setattr("app.email.sender.open_smtp", lambda settings: _FakeSmtp(refused))
|
||||
body = client.post(_url(edition), json={"email": "moi@exemple.fr"}).json()
|
||||
assert body["status"] == "rejected"
|
||||
assert "550" in body["detail"]
|
||||
assert "moi@exemple.fr" not in body["detail"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("email", ["", "pas-un-email", "@exemple.fr", "moi@", "a b@c.fr"])
|
||||
def test_invalid_address_is_rejected(client, edition, email):
|
||||
resp = client.post(_url(edition), json={"email": email})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
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
|
||||
@@ -656,3 +656,12 @@ def test_only_the_coordination_can_change_a_photo(
|
||||
|
||||
assert lead.put(url, files={"file": ("p.png", _png(), "image/png")}).status_code == 403
|
||||
assert lead.delete(url).status_code == 403
|
||||
|
||||
|
||||
def test_manual_add_requires_no_field(client, edition):
|
||||
resp = client.post(_url(edition), json={})
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["first_name"] == ""
|
||||
assert body["last_name"] == ""
|
||||
assert client.get(_url(edition)).status_code == 200
|
||||
|
||||
Reference in New Issue
Block a user