Étape 10 : envoi d'emails par SMTP

- app/email/ : sender (smtplib, STARTTLS ou SSL, connexion réutilisée pour un
  lot) + templates (rendu, textes dans locales/fr.json). EMAIL_ENABLED=false
  (défaut) ⇒ journalisé seulement, sans destinataire ni corps (RGPD).
- Lien magique : POST .../auth/magic-link envoie désormais l'email
  (PUBLIC_BASE_URL + magic_link_path + ?token=). Helper app/auth/magic_link.py
  partagé. Sans SMTP, le jeton non nominatif reste journalisé pour le dev.
- Récapitulatif de planning : POST /editions/{id}/notifications/planning
  (COORDINATION). subject/body sont des modèles ({prenom}, {nom}, {nb_creneaux},
  {nb_heures}, {creneaux}, {lien_connexion} → lien magique personnel émis).
  Cible tous les bénévoles avec email ou une liste. Envoi en BackgroundTasks.
- Config : PUBLIC_BASE_URL, EMAIL_ENABLED, SMTP_* ; .env.example,
  docker-compose (dev + prod), deploy/README.
- 9 tests (sender activé/désactivé, rendu, notifications : contenu, gabarit par
  défaut, lien magique embarqué, sous-ensemble, réservé COORDINATION).
  137 tests au total ; ruff + alembic check verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
AdamThomas
2026-09-10 08:08:55 +02:00
co-authored by Claude Sonnet 5
parent 977bd4dc34
commit b268e308ab
19 changed files with 581 additions and 33 deletions
+16 -18
View File
@@ -7,7 +7,6 @@ Deux systèmes, selon le compte :
comptes sans mot de passe — référents et bénévoles.
"""
import datetime as dt
import logging
import uuid
@@ -24,10 +23,12 @@ from app.api.schemas import (
VerifyRequest,
)
from app.auth.deps import get_current_account
from app.auth.magic_link import build_magic_link_url, issue_magic_link_token
from app.auth.passwords import MIN_LENGTH, hash_password, verify_password
from app.auth.tokens import create_access_token, hash_token, new_raw_token
from app.auth.tokens import create_access_token, hash_token
from app.config import get_settings
from app.db import get_session
from app.email import render_magic_link, send
from app.models import Account, MagicLinkToken, local_now
router = APIRouter(tags=["auth"])
@@ -130,24 +131,21 @@ def request_magic_link(
Réponse identique dans tous les cas (pas de divulgation)."""
account = _account_by_email(session, edition_id, payload.email)
if account is not None and account.password_hash is None:
raw = new_raw_token()
token = MagicLinkToken(
account_id=account.id,
token_hash=hash_token(raw),
expires_at=local_now()
+ dt.timedelta(minutes=get_settings().magic_link_ttl_minutes),
)
session.add(token)
settings = get_settings()
raw = issue_magic_link_token(session, account)
session.commit()
# STUB : en production, ce lien part par email. Ici on le journalise
# (identifiants uniquement, aucune donnée nominative).
logger.info(
"STUB lien magique — compte=%s édition=%s expire=%s jeton=%s",
account.id,
edition_id,
token.expires_at.isoformat(),
raw,
subject, body = render_magic_link(
link=build_magic_link_url(raw),
minutes=settings.magic_link_ttl_minutes,
)
send(account.email, subject, body)
if not settings.email_enabled:
# Aide au dev sans SMTP : le jeton (non nominatif) est journalisé.
logger.info(
"Lien magique (email désactivé) — compte=%s jeton=%s",
account.id,
raw,
)
return {"status": "accepted"}
+120
View File
@@ -0,0 +1,120 @@
"""Envoi d'emails d'information aux bénévoles (récapitulatif de planning)."""
import uuid
from collections import defaultdict
from fastapi import APIRouter, BackgroundTasks, Depends
from sqlmodel import Session, select
from app.api.deps import get_edition
from app.api.schemas import (
PlanningNotificationRequest,
PlanningNotificationResult,
)
from app.auth.deps import require_coordinator
from app.auth.magic_link import build_magic_link_url, issue_magic_link_token
from app.db import get_session
from app.email import render_planning, send_many
from app.i18n import t
from app.models import Account, Assignment, Edition, Position, Shift, Volunteer
router = APIRouter(
prefix="/editions/{edition_id}/notifications", tags=["notifications"]
)
_LOGIN_PLACEHOLDER = "{lien_connexion}"
def _login_link(session: Session, edition: Edition, volunteer: Volunteer) -> str:
"""Lien magique personnel si le bénévole a un compte, sinon l'accueil."""
account = session.exec(
select(Account)
.where(Account.edition_id == edition.id)
.where(Account.volunteer_id == volunteer.id)
.where(Account.password_hash.is_(None))
).first()
if account is None:
return build_magic_link_url("").split("?")[0]
return build_magic_link_url(issue_magic_link_token(session, account))
@router.post("/planning", response_model=PlanningNotificationResult)
def send_planning_notification(
payload: PlanningNotificationRequest,
background: BackgroundTasks,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> PlanningNotificationResult:
subject_template = payload.subject or t("email.planning.default_subject")
body_template = payload.body or t("email.planning.default_body")
wants_login_link = _LOGIN_PLACEHOLDER in subject_template or (
_LOGIN_PLACEHOLDER in body_template
)
stmt = select(Volunteer).where(Volunteer.edition_id == edition.id)
if payload.volunteer_ids is not None:
stmt = stmt.where(Volunteer.id.in_(payload.volunteer_ids))
volunteers = list(session.exec(stmt.order_by(Volunteer.last_name)))
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)
items: list[tuple[str, str, str]] = []
recipients: list[uuid.UUID] = []
skipped_no_email = 0
for volunteer in volunteers:
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_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,
login_link=(
_login_link(session, edition, volunteer)
if wants_login_link
else ""
),
)
items.append((volunteer.email, subject, body))
recipients.append(volunteer.id)
session.commit() # persiste les éventuels MagicLinkToken émis
background.add_task(send_many, items)
return PlanningNotificationResult(
queued=len(items),
skipped_no_email=skipped_no_email,
recipients=recipients,
)
+18
View File
@@ -233,3 +233,21 @@ class AccountCreate(ApiModel):
volunteer_id: uuid.UUID | None = None
# Requis pour un compte COORDINATION, interdit sinon.
password: str | None = None
# --------------------------------------------------------------------------- #
# Notifications par email #
# --------------------------------------------------------------------------- #
class PlanningNotificationRequest(ApiModel):
# Modèle saisi par la coordination. Variables : {prenom}, {nom},
# {nb_creneaux}, {nb_heures}, {creneaux}, {lien_connexion}.
subject: str | None = None
body: str | None = None
# None = tous les bénévoles de l'édition qui ont un email.
volunteer_ids: list[uuid.UUID] | None = None
class PlanningNotificationResult(ApiModel):
queued: int
skipped_no_email: int
recipients: list[uuid.UUID] = Field(default_factory=list)