É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:
co-authored by
Claude Sonnet 5
parent
977bd4dc34
commit
b268e308ab
+16
-18
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user