- 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
254 lines
7.0 KiB
Python
254 lines
7.0 KiB
Python
"""Schémas d'entrée/sortie de l'API CRUD et de la matrice."""
|
||
|
||
import datetime as dt
|
||
import uuid
|
||
|
||
from pydantic import BaseModel, ConfigDict, Field
|
||
|
||
from app.models import (
|
||
AssignmentSource,
|
||
PositionCategory,
|
||
Role,
|
||
TshirtSize,
|
||
)
|
||
from app.validation import Violation
|
||
|
||
|
||
class ApiModel(BaseModel):
|
||
"""Base commune : autorise la construction depuis un objet ORM."""
|
||
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Volunteer #
|
||
# --------------------------------------------------------------------------- #
|
||
class VolunteerCreate(ApiModel):
|
||
first_name: str
|
||
last_name: str
|
||
email: str | None = None
|
||
phone: str | None = None
|
||
tshirt_size: TshirtSize | None = None
|
||
diet: str | None = None
|
||
comment: str | None = None
|
||
skill_codes: list[str] = Field(default_factory=list)
|
||
|
||
|
||
class VolunteerUpdate(ApiModel):
|
||
first_name: str | None = None
|
||
last_name: str | None = None
|
||
email: str | None = None
|
||
phone: str | None = None
|
||
tshirt_size: TshirtSize | None = None
|
||
diet: str | None = None
|
||
comment: str | None = None
|
||
# None = inchangé ; [] = vider les compétences.
|
||
skill_codes: list[str] | None = None
|
||
|
||
|
||
class VolunteerRead(ApiModel):
|
||
id: uuid.UUID
|
||
edition_id: uuid.UUID
|
||
first_name: str
|
||
last_name: str
|
||
email: str | None
|
||
phone: str | None
|
||
tshirt_size: TshirtSize | None
|
||
diet: str | None
|
||
comment: str | None
|
||
skill_codes: list[str]
|
||
created_at: dt.datetime
|
||
updated_at: dt.datetime
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Position #
|
||
# --------------------------------------------------------------------------- #
|
||
class PositionCreate(ApiModel):
|
||
name: str
|
||
location: str | None = None
|
||
category: PositionCategory
|
||
lead_account_id: uuid.UUID | None = None
|
||
|
||
|
||
class PositionUpdate(ApiModel):
|
||
name: str | None = None
|
||
location: str | None = None
|
||
category: PositionCategory | None = None
|
||
lead_account_id: uuid.UUID | None = None
|
||
|
||
|
||
class PositionRead(ApiModel):
|
||
id: uuid.UUID
|
||
edition_id: uuid.UUID
|
||
name: str
|
||
location: str | None
|
||
category: PositionCategory
|
||
lead_account_id: uuid.UUID | None
|
||
created_at: dt.datetime
|
||
updated_at: dt.datetime
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Shift #
|
||
# --------------------------------------------------------------------------- #
|
||
class ShiftCreate(ApiModel):
|
||
position_id: uuid.UUID
|
||
starts_at: dt.datetime
|
||
ends_at: dt.datetime
|
||
min_staff: int = Field(default=0, ge=0)
|
||
ideal_staff: int = Field(default=0, ge=0)
|
||
required_skill_codes: list[str] = Field(default_factory=list)
|
||
|
||
|
||
class ShiftUpdate(ApiModel):
|
||
position_id: uuid.UUID | None = None
|
||
starts_at: dt.datetime | None = None
|
||
ends_at: dt.datetime | None = None
|
||
min_staff: int | None = Field(default=None, ge=0)
|
||
ideal_staff: int | None = Field(default=None, ge=0)
|
||
required_skill_codes: list[str] | None = None
|
||
|
||
|
||
class ShiftRead(ApiModel):
|
||
id: uuid.UUID
|
||
edition_id: uuid.UUID
|
||
position_id: uuid.UUID
|
||
starts_at: dt.datetime
|
||
ends_at: dt.datetime
|
||
min_staff: int
|
||
ideal_staff: int
|
||
required_skill_codes: list[str]
|
||
created_at: dt.datetime
|
||
updated_at: dt.datetime
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Assignment #
|
||
# --------------------------------------------------------------------------- #
|
||
class AssignmentCreate(ApiModel):
|
||
volunteer_id: uuid.UUID
|
||
shift_id: uuid.UUID
|
||
source: AssignmentSource = AssignmentSource.MANUAL
|
||
locked: bool = False
|
||
|
||
|
||
class AssignmentUpdate(ApiModel):
|
||
source: AssignmentSource | None = None
|
||
locked: bool | None = None
|
||
|
||
|
||
class AssignmentRead(ApiModel):
|
||
id: uuid.UUID
|
||
edition_id: uuid.UUID
|
||
volunteer_id: uuid.UUID
|
||
shift_id: uuid.UUID
|
||
source: AssignmentSource
|
||
locked: bool
|
||
created_at: dt.datetime
|
||
updated_at: dt.datetime
|
||
|
||
|
||
class AssignmentWriteResult(ApiModel):
|
||
assignment: AssignmentRead
|
||
warnings: list[Violation] = Field(default_factory=list)
|
||
|
||
|
||
class AssignmentValidateRequest(ApiModel):
|
||
volunteer_id: uuid.UUID
|
||
shift_id: uuid.UUID
|
||
|
||
|
||
class AssignmentValidateResult(ApiModel):
|
||
blocking: bool
|
||
violations: list[Violation] = Field(default_factory=list)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Matrice poste × créneau #
|
||
# --------------------------------------------------------------------------- #
|
||
class MatrixShift(ApiModel):
|
||
shift_id: uuid.UUID
|
||
starts_at: dt.datetime
|
||
ends_at: dt.datetime
|
||
min_staff: int
|
||
ideal_staff: int
|
||
assigned: int
|
||
|
||
|
||
class MatrixPosition(ApiModel):
|
||
id: uuid.UUID
|
||
name: str
|
||
category: PositionCategory
|
||
location: str | None
|
||
shifts: list[MatrixShift]
|
||
|
||
|
||
class MatrixResponse(ApiModel):
|
||
edition_id: uuid.UUID
|
||
positions: list[MatrixPosition]
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Authentification et comptes #
|
||
# --------------------------------------------------------------------------- #
|
||
class MagicLinkRequest(ApiModel):
|
||
email: str
|
||
|
||
|
||
class VerifyRequest(ApiModel):
|
||
token: str
|
||
|
||
|
||
class LoginRequest(ApiModel):
|
||
email: str
|
||
password: str
|
||
|
||
|
||
class ChangePasswordRequest(ApiModel):
|
||
current_password: str
|
||
new_password: str
|
||
|
||
|
||
class AccountRead(ApiModel):
|
||
id: uuid.UUID
|
||
edition_id: uuid.UUID
|
||
email: str
|
||
role: Role
|
||
volunteer_id: uuid.UUID | None
|
||
# Mode de connexion : "password" (admins) ou "magic_link".
|
||
auth_method: str
|
||
created_at: dt.datetime
|
||
updated_at: dt.datetime
|
||
|
||
|
||
class TokenResponse(ApiModel):
|
||
access_token: str
|
||
token_type: str
|
||
account: AccountRead
|
||
|
||
|
||
class AccountCreate(ApiModel):
|
||
email: str
|
||
role: Role
|
||
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)
|