"""Envoi d'emails d'information aux bénévoles (récapitulatif de planning).""" import logging import uuid from collections import defaultdict 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 logger = logging.getLogger("app.notifications") 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("/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, 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 logger.info( "Récapitulatif de planning (édition %s) : %d emails en file, %d bénévoles sans email", edition.id, len(items), skipped_no_email, ) background.add_task(send_many, items, logo=edition_logo(session, edition.id)) return PlanningNotificationResult( queued=len(items), skipped_no_email=skipped_no_email, recipients=recipients, )