Files
benevoles/backend/app/api/schemas.py
T
AdamThomasandClaude Sonnet 5 8a879ca5c3 Étape 8 : connexion par mot de passe pour la coordination
Deux systèmes d'authentification, une même session JWT :
- COORDINATION : login e-mail + mot de passe (bcrypt), POST .../auth/login ;
  POST .../auth/change-password pour le compte courant
- RÉFÉRENTS / BÉNÉVOLES : lien magique inchangé, mais refusé pour un compte
  qui a un mot de passe

- Account.password_hash (nullable) + migration 0002 ; propriété auth_method
  ("password" / "magic_link") exposée dans AccountRead
- app/auth/passwords.py : hash/verify bcrypt, longueur mini 10
- Création de compte : password requis pour COORDINATOR, interdit sinon
- seed : la coordination de démo a un mot de passe, affiché dans le résumé
- 12 tests (login ok/ko, séparation des deux systèmes, change-password,
  validation à la création) ; 129 tests au total ; alembic check vert

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-09 23:42:31 +02:00

241 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
AssignmentStatus,
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
status: AssignmentStatus = AssignmentStatus.PROPOSED
source: AssignmentSource = AssignmentSource.MANUAL
locked: bool = False
class AssignmentUpdate(ApiModel):
status: AssignmentStatus | None = None
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
status: AssignmentStatus
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
confirmed: int
proposed: 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