Étape 5 : API CRUD, matrice et validation branchée

- CRUD complet, scopé par édition, sur Position, Shift, Volunteer, Assignment
  (routers app/api/*, schémas Pydantic, dépendance get_edition partagée)
- Volunteer : gestion des compétences par codes (création à la volée),
  suppression = effacement complet en cascade (RGPD)
- Shift : contrôle fin > début, poste de la même édition, compétences requises
- Assignment : toute écriture active passe par le moteur de validation ;
  violation bloquante -> 422 error.validation_failed, avertissements renvoyés
  dans la réponse. Endpoint POST .../assignments/validate en simulation.
  app/validation/loader.py construit la ValidationInput depuis la base.
- GET /editions/{id}/matrix : postes -> créneaux -> {min, ideal, confirmed,
  proposed}, source de l'écran principal
- 30 tests d'API (CRUD, 404/409/422, cascade, blocage, avertissements, matrice,
  loader) ; 104 tests au total ; alembic check toujours vert
- Clés de traduction shift.* / assignment.* / volunteer.* ajoutées

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-09 22:54:22 +02:00
co-authored by Claude Sonnet 5
parent c96e85af37
commit 74e35f0195
20 changed files with 1775 additions and 8 deletions
+55
View File
@@ -0,0 +1,55 @@
"""Helpers partagés par les routers CRUD."""
import uuid
from collections.abc import Iterable
from fastapi import HTTPException
from sqlalchemy import delete
from sqlmodel import Session, select
from app.models import Skill
def owned_or_404(instance, edition_id: uuid.UUID):
"""404 si l'instance est absente ou rattachée à une autre édition."""
if instance is None or instance.edition_id != edition_id:
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
return instance
def resolve_skills(
session: Session, edition_id: uuid.UUID, codes: Iterable[str]
) -> list[Skill]:
"""Récupère ou crée les compétences de l'édition pour les codes donnés."""
existing = {
s.code: s
for s in session.exec(
select(Skill).where(Skill.edition_id == edition_id)
)
}
resolved: list[Skill] = []
seen: set[str] = set()
for raw in codes:
code = raw.strip()
if not code or code in seen:
continue
seen.add(code)
skill = existing.get(code)
if skill is None:
skill = Skill(edition_id=edition_id, code=code, label=code)
session.add(skill)
session.flush()
existing[code] = skill
resolved.append(skill)
return resolved
def replace_links(
session: Session, link_model, *, filter_column, filter_value, pairs
) -> None:
"""Remplace toutes les lignes d'une table d'association pour une clé."""
session.execute(
delete(link_model).where(filter_column == filter_value)
)
for row in pairs:
session.add(link_model(**row))
+207
View File
@@ -0,0 +1,207 @@
"""CRUD des affectations. Toute écriture passe par le moteur de validation :
une violation bloquante interdit l'enregistrement, les avertissements sont
renvoyés."""
import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlmodel import Session, select
from app.api._common import owned_or_404
from app.api.deps import get_edition
from app.api.schemas import (
AssignmentCreate,
AssignmentRead,
AssignmentUpdate,
AssignmentValidateRequest,
AssignmentValidateResult,
AssignmentWriteResult,
)
from app.db import get_session
from app.models import (
ACTIVE_ASSIGNMENT_STATUSES,
Assignment,
Edition,
Shift,
Volunteer,
)
from app.validation import has_blocking, validate
from app.validation.loader import build_validation_input
router = APIRouter(
prefix="/editions/{edition_id}/assignments", tags=["assignments"]
)
def _pair_or_422(
session: Session, edition_id: uuid.UUID, volunteer_id: uuid.UUID, shift_id: uuid.UUID
) -> tuple[Volunteer, Shift]:
volunteer = session.get(Volunteer, volunteer_id)
shift = session.get(Shift, shift_id)
if (
volunteer is None
or shift is None
or volunteer.edition_id != edition_id
or shift.edition_id != edition_id
):
raise HTTPException(
status_code=422, detail={"code": "assignment.wrong_edition"}
)
return volunteer, shift
@router.post(
"/validate", response_model=AssignmentValidateResult, tags=["assignments"]
)
def validate_assignment(
payload: AssignmentValidateRequest,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> AssignmentValidateResult:
volunteer, shift = _pair_or_422(
session, edition.id, payload.volunteer_id, payload.shift_id
)
violations = validate(
build_validation_input(
session=session, edition=edition, volunteer=volunteer, shift=shift
)
)
return AssignmentValidateResult(
blocking=has_blocking(violations), violations=violations
)
@router.post(
"", response_model=AssignmentWriteResult, status_code=status.HTTP_201_CREATED
)
def create_assignment(
payload: AssignmentCreate,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> AssignmentWriteResult:
volunteer, shift = _pair_or_422(
session, edition.id, payload.volunteer_id, payload.shift_id
)
existing = session.exec(
select(Assignment)
.where(Assignment.volunteer_id == volunteer.id)
.where(Assignment.shift_id == shift.id)
).first()
if existing is not None:
raise HTTPException(
status_code=409, detail={"code": "assignment.duplicate"}
)
# La validation ne s'applique qu'aux affectations actives : créer
# directement une affectation DECLINEE ou ANNULEE n'a rien à valider.
violations: list = []
if payload.status in ACTIVE_ASSIGNMENT_STATUSES:
violations = validate(
build_validation_input(
session=session,
edition=edition,
volunteer=volunteer,
shift=shift,
)
)
if has_blocking(violations):
raise HTTPException(
status_code=422,
detail={
"code": "error.validation_failed",
"violations": [v.model_dump() for v in violations],
},
)
assignment = Assignment(
edition_id=edition.id,
volunteer_id=volunteer.id,
shift_id=shift.id,
status=payload.status,
source=payload.source,
locked=payload.locked,
)
session.add(assignment)
session.commit()
session.refresh(assignment)
return AssignmentWriteResult(assignment=assignment, warnings=violations)
@router.get("", response_model=list[AssignmentRead])
def list_assignments(
volunteer_id: uuid.UUID | None = None,
shift_id: uuid.UUID | None = None,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> list[Assignment]:
stmt = select(Assignment).where(Assignment.edition_id == edition.id)
if volunteer_id is not None:
stmt = stmt.where(Assignment.volunteer_id == volunteer_id)
if shift_id is not None:
stmt = stmt.where(Assignment.shift_id == shift_id)
return list(session.exec(stmt))
@router.get("/{assignment_id}", response_model=AssignmentRead)
def get_assignment(
assignment_id: uuid.UUID,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> Assignment:
return owned_or_404(session.get(Assignment, assignment_id), edition.id)
@router.patch("/{assignment_id}", response_model=AssignmentWriteResult)
def update_assignment(
assignment_id: uuid.UUID,
payload: AssignmentUpdate,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> AssignmentWriteResult:
assignment = owned_or_404(
session.get(Assignment, assignment_id), edition.id
)
changes = payload.model_dump(exclude_unset=True)
for field, value in changes.items():
setattr(assignment, field, value)
warnings: list = []
if assignment.status in ACTIVE_ASSIGNMENT_STATUSES:
volunteer = session.get(Volunteer, assignment.volunteer_id)
shift = session.get(Shift, assignment.shift_id)
warnings = validate(
build_validation_input(
session=session,
edition=edition,
volunteer=volunteer,
shift=shift,
exclude_assignment_id=assignment.id,
)
)
if has_blocking(warnings):
session.rollback()
raise HTTPException(
status_code=422,
detail={
"code": "error.validation_failed",
"violations": [v.model_dump() for v in warnings],
},
)
session.commit()
session.refresh(assignment)
return AssignmentWriteResult(assignment=assignment, warnings=warnings)
@router.delete("/{assignment_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_assignment(
assignment_id: uuid.UUID,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> Response:
assignment = owned_or_404(
session.get(Assignment, assignment_id), edition.id
)
session.delete(assignment)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
+20
View File
@@ -0,0 +1,20 @@
"""Dépendances FastAPI partagées par les routers."""
import uuid
from fastapi import Depends, HTTPException
from sqlmodel import Session
from app.db import get_session
from app.models import Edition
def get_edition(
edition_id: uuid.UUID, session: Session = Depends(get_session)
) -> Edition:
edition = session.get(Edition, edition_id)
if edition is None:
raise HTTPException(
status_code=404, detail={"code": "error.not_found"}
)
return edition
+75
View File
@@ -0,0 +1,75 @@
"""Matrice poste × créneau d'une édition : source de l'écran principal."""
from fastapi import APIRouter, Depends
from sqlalchemy import func
from sqlmodel import Session, select
from app.api.deps import get_edition
from app.api.schemas import MatrixPosition, MatrixResponse, MatrixShift
from app.db import get_session
from app.models import Assignment, AssignmentStatus, Edition, Position, Shift
router = APIRouter(prefix="/editions/{edition_id}/matrix", tags=["matrix"])
@router.get("", response_model=MatrixResponse)
def get_matrix(
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> MatrixResponse:
positions = list(
session.exec(
select(Position)
.where(Position.edition_id == edition.id)
.order_by(Position.name)
)
)
shifts = list(
session.exec(
select(Shift)
.where(Shift.edition_id == edition.id)
.order_by(Shift.starts_at)
)
)
counts = {
(shift_id, status): total
for shift_id, status, total in session.exec(
select(
Assignment.shift_id, Assignment.status, func.count()
)
.where(Assignment.edition_id == edition.id)
.group_by(Assignment.shift_id, Assignment.status)
)
}
shifts_by_position: dict = {}
for shift in shifts:
shifts_by_position.setdefault(shift.position_id, []).append(
MatrixShift(
shift_id=shift.id,
starts_at=shift.starts_at,
ends_at=shift.ends_at,
min_staff=shift.min_staff,
ideal_staff=shift.ideal_staff,
confirmed=counts.get(
(shift.id, AssignmentStatus.CONFIRMED), 0
),
proposed=counts.get(
(shift.id, AssignmentStatus.PROPOSED), 0
),
)
)
return MatrixResponse(
edition_id=edition.id,
positions=[
MatrixPosition(
id=position.id,
name=position.name,
category=position.category,
location=position.location,
shifts=shifts_by_position.get(position.id, []),
)
for position in positions
],
)
+79
View File
@@ -0,0 +1,79 @@
"""CRUD des postes."""
import uuid
from fastapi import APIRouter, Depends, Response, status
from sqlmodel import Session, select
from app.api._common import owned_or_404
from app.api.deps import get_edition
from app.api.schemas import PositionCreate, PositionRead, PositionUpdate
from app.db import get_session
from app.models import Edition, Position
router = APIRouter(
prefix="/editions/{edition_id}/positions", tags=["positions"]
)
@router.post("", response_model=PositionRead, status_code=status.HTTP_201_CREATED)
def create_position(
payload: PositionCreate,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> Position:
position = Position(edition_id=edition.id, **payload.model_dump())
session.add(position)
session.commit()
session.refresh(position)
return position
@router.get("", response_model=list[PositionRead])
def list_positions(
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> list[Position]:
return list(
session.exec(
select(Position)
.where(Position.edition_id == edition.id)
.order_by(Position.name)
)
)
@router.get("/{position_id}", response_model=PositionRead)
def get_position(
position_id: uuid.UUID,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> Position:
return owned_or_404(session.get(Position, position_id), edition.id)
@router.patch("/{position_id}", response_model=PositionRead)
def update_position(
position_id: uuid.UUID,
payload: PositionUpdate,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> Position:
position = owned_or_404(session.get(Position, position_id), edition.id)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(position, field, value)
session.commit()
session.refresh(position)
return position
@router.delete("/{position_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_position(
position_id: uuid.UUID,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> Response:
position = owned_or_404(session.get(Position, position_id), edition.id)
session.delete(position)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
+192
View File
@@ -0,0 +1,192 @@
"""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,
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]
+141
View File
@@ -0,0 +1,141 @@
"""CRUD des créneaux."""
import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlmodel import Session, select
from app.api._common import owned_or_404, replace_links, resolve_skills
from app.api.deps import get_edition
from app.api.schemas import ShiftCreate, ShiftRead, ShiftUpdate
from app.db import get_session
from app.models import Edition, Position, Shift, ShiftRequiredSkill, Skill
router = APIRouter(prefix="/editions/{edition_id}/shifts", tags=["shifts"])
def _skill_codes(session: Session, shift_id: uuid.UUID) -> list[str]:
return sorted(
session.exec(
select(Skill.code)
.join(ShiftRequiredSkill, ShiftRequiredSkill.skill_id == Skill.id)
.where(ShiftRequiredSkill.shift_id == shift_id)
)
)
def _read(session: Session, shift: Shift) -> ShiftRead:
return ShiftRead(
**shift.model_dump(),
required_skill_codes=_skill_codes(session, shift.id),
)
def _set_skills(session: Session, shift: Shift, codes: list[str]) -> None:
skills = resolve_skills(session, shift.edition_id, codes)
replace_links(
session,
ShiftRequiredSkill,
filter_column=ShiftRequiredSkill.shift_id,
filter_value=shift.id,
pairs=[{"shift_id": shift.id, "skill_id": s.id} for s in skills],
)
def _check_range(starts_at, ends_at) -> None:
if ends_at <= starts_at:
raise HTTPException(
status_code=422,
detail={"code": "shift.ends_not_after_starts"},
)
def _require_position(
session: Session, position_id: uuid.UUID, edition_id: uuid.UUID
) -> Position:
position = session.get(Position, position_id)
if position is None or position.edition_id != edition_id:
raise HTTPException(
status_code=422, detail={"code": "shift.position_wrong_edition"}
)
return position
@router.post("", response_model=ShiftRead, status_code=status.HTTP_201_CREATED)
def create_shift(
payload: ShiftCreate,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> ShiftRead:
_check_range(payload.starts_at, payload.ends_at)
_require_position(session, payload.position_id, edition.id)
shift = Shift(
edition_id=edition.id,
**payload.model_dump(exclude={"required_skill_codes"}),
)
session.add(shift)
session.flush()
_set_skills(session, shift, payload.required_skill_codes)
session.commit()
session.refresh(shift)
return _read(session, shift)
@router.get("", response_model=list[ShiftRead])
def list_shifts(
position_id: uuid.UUID | None = None,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> list[ShiftRead]:
stmt = (
select(Shift)
.where(Shift.edition_id == edition.id)
.order_by(Shift.starts_at)
)
if position_id is not None:
stmt = stmt.where(Shift.position_id == position_id)
return [_read(session, s) for s in session.exec(stmt)]
@router.get("/{shift_id}", response_model=ShiftRead)
def get_shift(
shift_id: uuid.UUID,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> ShiftRead:
shift = owned_or_404(session.get(Shift, shift_id), edition.id)
return _read(session, shift)
@router.patch("/{shift_id}", response_model=ShiftRead)
def update_shift(
shift_id: uuid.UUID,
payload: ShiftUpdate,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> ShiftRead:
shift = owned_or_404(session.get(Shift, shift_id), edition.id)
changes = payload.model_dump(exclude_unset=True)
skill_codes = changes.pop("required_skill_codes", None)
if "position_id" in changes:
_require_position(session, changes["position_id"], edition.id)
for field, value in changes.items():
setattr(shift, field, value)
_check_range(shift.starts_at, shift.ends_at)
if skill_codes is not None:
_set_skills(session, shift, skill_codes)
session.commit()
session.refresh(shift)
return _read(session, shift)
@router.delete("/{shift_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_shift(
shift_id: uuid.UUID,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> Response:
shift = owned_or_404(session.get(Shift, shift_id), edition.id)
session.delete(shift)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
+136
View File
@@ -0,0 +1,136 @@
"""CRUD des bénévoles. La suppression est un effacement complet (RGPD)."""
import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select
from app.api._common import owned_or_404, replace_links, resolve_skills
from app.api.deps import get_edition
from app.api.schemas import VolunteerCreate, VolunteerRead, VolunteerUpdate
from app.db import get_session
from app.models import Edition, Skill, Volunteer, VolunteerSkill
router = APIRouter(
prefix="/editions/{edition_id}/volunteers", tags=["volunteers"]
)
def _skill_codes(session: Session, volunteer_id: uuid.UUID) -> list[str]:
return sorted(
session.exec(
select(Skill.code)
.join(VolunteerSkill, VolunteerSkill.skill_id == Skill.id)
.where(VolunteerSkill.volunteer_id == volunteer_id)
)
)
def _read(session: Session, volunteer: Volunteer) -> VolunteerRead:
return VolunteerRead(
**volunteer.model_dump(),
skill_codes=_skill_codes(session, volunteer.id),
)
def _set_skills(
session: Session, volunteer: Volunteer, codes: list[str]
) -> None:
skills = resolve_skills(session, volunteer.edition_id, codes)
replace_links(
session,
VolunteerSkill,
filter_column=VolunteerSkill.volunteer_id,
filter_value=volunteer.id,
pairs=[
{"volunteer_id": volunteer.id, "skill_id": s.id} for s in skills
],
)
@router.post("", response_model=VolunteerRead, status_code=status.HTTP_201_CREATED)
def create_volunteer(
payload: VolunteerCreate,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> VolunteerRead:
volunteer = Volunteer(
edition_id=edition.id,
**payload.model_dump(exclude={"skill_codes"}),
)
session.add(volunteer)
try:
session.flush()
except IntegrityError as exc:
session.rollback()
raise HTTPException(
status_code=409, detail={"code": "volunteer.duplicate_email"}
) from exc
_set_skills(session, volunteer, payload.skill_codes)
session.commit()
session.refresh(volunteer)
return _read(session, volunteer)
@router.get("", response_model=list[VolunteerRead])
def list_volunteers(
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> list[VolunteerRead]:
volunteers = session.exec(
select(Volunteer)
.where(Volunteer.edition_id == edition.id)
.order_by(Volunteer.last_name, Volunteer.first_name)
)
return [_read(session, v) for v in volunteers]
@router.get("/{volunteer_id}", response_model=VolunteerRead)
def get_volunteer(
volunteer_id: uuid.UUID,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> VolunteerRead:
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
return _read(session, volunteer)
@router.patch("/{volunteer_id}", response_model=VolunteerRead)
def update_volunteer(
volunteer_id: uuid.UUID,
payload: VolunteerUpdate,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> VolunteerRead:
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
changes = payload.model_dump(exclude_unset=True)
skill_codes = changes.pop("skill_codes", None)
for field, value in changes.items():
setattr(volunteer, field, value)
try:
session.flush()
except IntegrityError as exc:
session.rollback()
raise HTTPException(
status_code=409, detail={"code": "volunteer.duplicate_email"}
) from exc
if skill_codes is not None:
_set_skills(session, volunteer, skill_codes)
session.commit()
session.refresh(volunteer)
return _read(session, volunteer)
@router.delete("/{volunteer_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_volunteer(
volunteer_id: uuid.UUID,
edition: Edition = Depends(get_edition),
session: Session = Depends(get_session),
) -> Response:
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
# Effacement complet : la cascade base supprime disponibilités,
# affectations, liens de compétence et compte éventuel.
session.delete(volunteer)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
+6
View File
@@ -4,6 +4,12 @@
"error.forbidden": "Accès refusé",
"error.unauthorized": "Authentification requise",
"error.validation_failed": "La validation a échoué",
"error.conflict": "Conflit avec une ressource existante.",
"shift.ends_not_after_starts": "La fin du créneau doit être postérieure à son début.",
"shift.position_wrong_edition": "Le poste n'appartient pas à cette édition.",
"volunteer.duplicate_email": "Un bénévole avec cet email existe déjà dans l'édition.",
"assignment.wrong_edition": "Le bénévole ou le créneau n'appartient pas à cette édition.",
"assignment.duplicate": "Ce bénévole est déjà affecté à ce créneau.",
"import.error.empty_file": "Le fichier est vide.",
"import.error.encoding": "L'encodage du fichier n'est pas reconnu.",
+10
View File
@@ -1,8 +1,13 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.assignments import router as assignment_router
from app.api.health import router as health_router
from app.api.imports import router as import_router
from app.api.matrix import router as matrix_router
from app.api.positions import router as position_router
from app.api.shifts import router as shift_router
from app.api.volunteers import router as volunteer_router
from app.config import get_settings
from app.logging_config import configure_logging
@@ -22,3 +27,8 @@ app.add_middleware(
app.include_router(health_router)
app.include_router(import_router)
app.include_router(volunteer_router)
app.include_router(position_router)
app.include_router(shift_router)
app.include_router(assignment_router)
app.include_router(matrix_router)
+111
View File
@@ -0,0 +1,111 @@
"""Construit une `ValidationInput` depuis la base pour un couple (bénévole,
créneau). Couche de liaison : c'est le seul endroit du module `validation` qui
connaît la base."""
import uuid
from sqlmodel import Session, select
from app.models import (
ACTIVE_ASSIGNMENT_STATUSES,
Assignment,
Availability,
DayPart,
Edition,
Shift,
ShiftRequiredSkill,
Skill,
Volunteer,
VolunteerSkill,
)
from app.validation.models import (
AvailabilityDeclaration,
DayPartWindow,
ShiftInfo,
ValidationInput,
)
def _volunteer_skill_codes(
session: Session, volunteer_id: uuid.UUID
) -> frozenset[str]:
return frozenset(
session.exec(
select(Skill.code)
.join(VolunteerSkill, VolunteerSkill.skill_id == Skill.id)
.where(VolunteerSkill.volunteer_id == volunteer_id)
)
)
def _shift_required_skill_codes(
session: Session, shift_id: uuid.UUID
) -> frozenset[str]:
return frozenset(
session.exec(
select(Skill.code)
.join(
ShiftRequiredSkill, ShiftRequiredSkill.skill_id == Skill.id
)
.where(ShiftRequiredSkill.shift_id == shift_id)
)
)
def build_validation_input(
*,
session: Session,
edition: Edition,
volunteer: Volunteer,
shift: Shift,
exclude_assignment_id: uuid.UUID | None = None,
) -> ValidationInput:
day_parts = list(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
)
code_by_day_part_id = {dp.id: dp.code for dp in day_parts}
stmt = (
select(Shift)
.join(Assignment, Assignment.shift_id == Shift.id)
.where(Assignment.volunteer_id == volunteer.id)
.where(
Assignment.status.in_(tuple(ACTIVE_ASSIGNMENT_STATUSES))
)
.where(Shift.id != shift.id)
)
if exclude_assignment_id is not None:
stmt = stmt.where(Assignment.id != exclude_assignment_id)
existing_shifts = tuple(
ShiftInfo(id=str(s.id), starts_at=s.starts_at, ends_at=s.ends_at)
for s in session.exec(stmt)
)
declarations = tuple(
AvailabilityDeclaration(
date=a.date,
day_part_code=code_by_day_part_id.get(a.day_part_id, ""),
status=a.status,
)
for a in session.exec(
select(Availability).where(
Availability.volunteer_id == volunteer.id
)
)
)
return ValidationInput(
candidate_shift=ShiftInfo(
id=str(shift.id),
starts_at=shift.starts_at,
ends_at=shift.ends_at,
required_skill_codes=_shift_required_skill_codes(session, shift.id),
),
volunteer_skill_codes=_volunteer_skill_codes(session, volunteer.id),
existing_shifts=existing_shifts,
availabilities=declarations,
day_parts=tuple(
DayPartWindow(dp.code, dp.start_time, dp.end_time, dp.order)
for dp in day_parts
),
)
+9 -1
View File
@@ -2,7 +2,7 @@ import datetime as dt
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, SQLModel, create_engine
from sqlmodel import Session, SQLModel, create_engine, select
from app import models # noqa: F401 (enregistre les tables sur SQLModel.metadata)
from app.config import get_settings
@@ -75,3 +75,11 @@ def edition(session) -> Edition:
)
session.commit()
return ed
@pytest.fixture
def day_parts(session, edition) -> dict[str, DayPart]:
parts = session.exec(
select(DayPart).where(DayPart.edition_id == edition.id)
)
return {p.code: p for p in parts}
+118
View File
@@ -0,0 +1,118 @@
"""Fabriques d'objets du domaine pour les tests (écriture directe en base)."""
import datetime as dt
from app.models import (
Availability,
AvailabilityStatus,
Position,
PositionCategory,
Shift,
Skill,
Volunteer,
VolunteerSkill,
)
def make_position(session, edition, *, name="Bar", category=PositionCategory.BAR):
position = Position(edition_id=edition.id, name=name, category=category)
session.add(position)
session.commit()
session.refresh(position)
return position
def make_shift(
session, edition, position, *, start, end, min_staff=1, ideal_staff=2, skills=()
):
shift = Shift(
edition_id=edition.id,
position_id=position.id,
starts_at=start,
ends_at=end,
min_staff=min_staff,
ideal_staff=ideal_staff,
)
session.add(shift)
session.flush()
for code in skills:
_link_skill(session, edition, code=code, shift=shift)
session.commit()
session.refresh(shift)
return shift
def make_volunteer(
session, edition, *, first_name="Alex", last_name="Martin", email=None, skills=()
):
volunteer = Volunteer(
edition_id=edition.id,
first_name=first_name,
last_name=last_name,
email=email,
)
session.add(volunteer)
session.flush()
for code in skills:
_link_skill(session, edition, volunteer=volunteer, code=code)
session.commit()
session.refresh(volunteer)
return volunteer
def make_availability(session, edition, volunteer, day_part, *, date, status):
row = Availability(
edition_id=edition.id,
volunteer_id=volunteer.id,
day_part_id=day_part.id,
date=date,
status=status,
)
session.add(row)
session.commit()
return row
def declare_available_all_day(session, edition, volunteer, day_parts, date):
for part in day_parts.values():
make_availability(
session,
edition,
volunteer,
part,
date=date,
status=AvailabilityStatus.AVAILABLE,
)
def _get_or_create_skill(session, edition, code) -> Skill:
from sqlmodel import select
skill = session.exec(
select(Skill)
.where(Skill.edition_id == edition.id)
.where(Skill.code == code)
).first()
if skill is None:
skill = Skill(edition_id=edition.id, code=code, label=code)
session.add(skill)
session.flush()
return skill
def _link_skill(session, edition, *, code, volunteer=None, shift=None):
from app.models import ShiftRequiredSkill
skill = _get_or_create_skill(session, edition, code)
if volunteer is not None:
session.add(
VolunteerSkill(volunteer_id=volunteer.id, skill_id=skill.id)
)
if shift is not None:
session.add(
ShiftRequiredSkill(shift_id=shift.id, skill_id=skill.id)
)
def dt_at(day: int, hour: int, minute: int = 0) -> dt.datetime:
return dt.datetime(2026, 7, day, hour, minute)
+195
View File
@@ -0,0 +1,195 @@
import datetime as dt
from tests.factories import (
declare_available_all_day,
dt_at,
make_position,
make_shift,
make_volunteer,
)
def _url(edition, suffix=""):
return f"/editions/{edition.id}/assignments{suffix}"
def _setup(session, edition, day_parts, *, available=True, skills=()):
position = make_position(session, edition)
shift = make_shift(
session, edition, position, start=dt_at(10, 14), end=dt_at(10, 18)
)
volunteer = make_volunteer(session, edition, email="v@ex.fr", skills=skills)
if available:
declare_available_all_day(
session, edition, volunteer, day_parts, dt.date(2026, 7, 10)
)
return volunteer, shift
def test_create_assignment_clean(client, edition, session, day_parts):
volunteer, shift = _setup(session, edition, day_parts)
resp = client.post(
_url(edition),
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
)
assert resp.status_code == 201
body = resp.json()
assert body["warnings"] == []
assert body["assignment"]["status"] == "PROPOSED"
assert body["assignment"]["source"] == "MANUAL"
def test_create_assignment_returns_warnings(client, edition, session, day_parts):
# Bénévole non déclaré disponible + compétence requise absente.
volunteer, shift = _setup(session, edition, day_parts, available=False)
position = make_position(session, edition, name="Régie")
shift_with_skill = make_shift(
session,
edition,
position,
start=dt_at(10, 14),
end=dt_at(10, 18),
skills=["SOUND"],
)
resp = client.post(
_url(edition),
json={
"volunteer_id": str(volunteer.id),
"shift_id": str(shift_with_skill.id),
},
)
assert resp.status_code == 201
codes = {w["code"] for w in resp.json()["warnings"]}
assert "MISSING_REQUIRED_SKILL" in codes
assert "OUTSIDE_AVAILABILITY" in codes
def test_create_assignment_blocked_by_overlap(client, edition, session, day_parts):
volunteer, shift = _setup(session, edition, day_parts)
assert client.post(
_url(edition),
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
).status_code == 201
position = make_position(session, edition, name="Parking")
overlapping = make_shift(
session, edition, position, start=dt_at(10, 16), end=dt_at(10, 20)
)
resp = client.post(
_url(edition),
json={
"volunteer_id": str(volunteer.id),
"shift_id": str(overlapping.id),
},
)
assert resp.status_code == 422
detail = resp.json()["detail"]
assert detail["code"] == "error.validation_failed"
assert any(v["code"] == "SHIFT_OVERLAP" for v in detail["violations"])
# Rien n'a été enregistré pour ce créneau.
listed = client.get(
_url(edition), params={"shift_id": str(overlapping.id)}
).json()
assert listed == []
def test_create_duplicate_assignment_conflicts(client, edition, session, day_parts):
volunteer, shift = _setup(session, edition, day_parts)
body = {"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)}
assert client.post(_url(edition), json=body).status_code == 201
resp = client.post(_url(edition), json=body)
assert resp.status_code == 409
assert resp.json()["detail"]["code"] == "assignment.duplicate"
def test_assignment_wrong_edition_is_422(client, edition, session, day_parts):
from app.models import Edition
other = Edition(
year=2027, start_date=dt.date(2027, 7, 1), end_date=dt.date(2027, 7, 4)
)
session.add(other)
session.commit()
session.refresh(other)
stranger = make_volunteer(session, other, email="s@ex.fr")
_, shift = _setup(session, edition, day_parts)
resp = client.post(
_url(edition),
json={"volunteer_id": str(stranger.id), "shift_id": str(shift.id)},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "assignment.wrong_edition"
def test_validate_endpoint_does_not_persist(client, edition, session, day_parts):
volunteer, shift = _setup(session, edition, day_parts, available=False)
resp = client.post(
_url(edition, "/validate"),
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
)
assert resp.status_code == 200
body = resp.json()
assert body["blocking"] is False
assert any(v["code"] == "OUTSIDE_AVAILABILITY" for v in body["violations"])
assert client.get(_url(edition)).json() == []
def test_update_status_revalidates_and_returns_warnings(
client, edition, session, day_parts
):
volunteer, shift = _setup(session, edition, day_parts, available=False)
aid = client.post(
_url(edition),
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
).json()["assignment"]["id"]
resp = client.patch(
_url(edition, f"/{aid}"), json={"status": "CONFIRMED"}
)
assert resp.status_code == 200
assert resp.json()["assignment"]["status"] == "CONFIRMED"
assert any(
w["code"] == "OUTSIDE_AVAILABILITY" for w in resp.json()["warnings"]
)
def test_cancelling_an_assignment_skips_validation(
client, edition, session, day_parts
):
volunteer, shift = _setup(session, edition, day_parts)
first = client.post(
_url(edition),
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
).json()["assignment"]["id"]
# Un second créneau qui chevauche, mais l'affectation est directement DECLINEE.
position = make_position(session, edition, name="X")
overlapping = make_shift(
session, edition, position, start=dt_at(10, 16), end=dt_at(10, 20)
)
created = client.post(
_url(edition),
json={
"volunteer_id": str(volunteer.id),
"shift_id": str(overlapping.id),
"status": "DECLINED",
},
)
assert created.status_code == 201
patched = client.patch(
_url(edition, f"/{first}"), json={"locked": True}
)
assert patched.status_code == 200
assert patched.json()["assignment"]["locked"] is True
def test_delete_assignment(client, edition, session, day_parts):
volunteer, shift = _setup(session, edition, day_parts)
aid = client.post(
_url(edition),
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
).json()["assignment"]["id"]
assert client.delete(_url(edition, f"/{aid}")).status_code == 204
assert client.get(_url(edition, f"/{aid}")).status_code == 404
+62
View File
@@ -0,0 +1,62 @@
from app.models import Assignment, AssignmentStatus
from tests.factories import dt_at, make_position, make_shift, make_volunteer
def test_matrix_shape_and_counts(client, edition, session):
bar = make_position(session, edition, name="Bar")
parking = make_position(session, edition, name="Parking")
shift_a = make_shift(
session, edition, bar, start=dt_at(10, 18), end=dt_at(10, 22),
min_staff=2, ideal_staff=5,
)
shift_b = make_shift(
session, edition, bar, start=dt_at(11, 18), end=dt_at(11, 22)
)
make_shift(
session, edition, parking, start=dt_at(10, 8), end=dt_at(10, 12)
)
for i, status in enumerate(
[
AssignmentStatus.CONFIRMED,
AssignmentStatus.CONFIRMED,
AssignmentStatus.PROPOSED,
AssignmentStatus.DECLINED,
]
):
volunteer = make_volunteer(
session, edition, first_name=f"V{i}", last_name="X", email=f"v{i}@ex.fr"
)
session.add(
Assignment(
edition_id=edition.id,
volunteer_id=volunteer.id,
shift_id=shift_a.id,
status=status,
)
)
session.commit()
body = client.get(f"/editions/{edition.id}/matrix").json()
assert body["edition_id"] == str(edition.id)
assert [p["name"] for p in body["positions"]] == ["Bar", "Parking"]
bar_shifts = body["positions"][0]["shifts"]
assert [s["shift_id"] for s in bar_shifts] == [str(shift_a.id), str(shift_b.id)]
first = bar_shifts[0]
assert first["confirmed"] == 2
assert first["proposed"] == 1 # la DECLINEE n'est pas comptée
assert first["min_staff"] == 2
assert first["ideal_staff"] == 5
assert bar_shifts[1]["confirmed"] == 0
assert body["positions"][1]["shifts"][0]["proposed"] == 0
def test_matrix_unknown_edition_is_404(client):
import uuid
assert client.get(f"/editions/{uuid.uuid4()}/matrix").status_code == 404
+37
View File
@@ -0,0 +1,37 @@
import uuid
def _url(edition, suffix=""):
return f"/editions/{edition.id}/positions{suffix}"
def test_position_crud_roundtrip(client, edition):
created = client.post(
_url(edition),
json={"name": "Billetterie", "category": "TICKETING", "location": "Entrée A"},
)
assert created.status_code == 201
pid = created.json()["id"]
assert created.json()["category"] == "TICKETING"
assert client.get(_url(edition, f"/{pid}")).json()["name"] == "Billetterie"
assert [p["name"] for p in client.get(_url(edition)).json()] == ["Billetterie"]
patched = client.patch(
_url(edition, f"/{pid}"), json={"location": "Entrée B"}
)
assert patched.json()["location"] == "Entrée B"
assert patched.json()["name"] == "Billetterie"
assert client.delete(_url(edition, f"/{pid}")).status_code == 204
assert client.get(_url(edition, f"/{pid}")).status_code == 404
def test_unknown_position_is_404(client, edition):
assert client.get(_url(edition, f"/{uuid.uuid4()}")).status_code == 404
def test_unknown_edition_is_404(client):
assert (
client.get(f"/editions/{uuid.uuid4()}/positions").status_code == 404
)
+100
View File
@@ -0,0 +1,100 @@
import datetime as dt
from tests.factories import make_position
def _url(edition, suffix=""):
return f"/editions/{edition.id}/shifts{suffix}"
def _payload(position, **overrides):
base = {
"position_id": str(position.id),
"starts_at": "2026-07-10T18:00:00",
"ends_at": "2026-07-10T22:00:00",
"min_staff": 2,
"ideal_staff": 4,
"required_skill_codes": ["SST"],
}
base.update(overrides)
return base
def test_create_shift_with_required_skills(client, edition, session):
position = make_position(session, edition)
resp = client.post(_url(edition), json=_payload(position))
assert resp.status_code == 201
body = resp.json()
assert body["required_skill_codes"] == ["SST"]
assert body["min_staff"] == 2
assert body["position_id"] == str(position.id)
def test_create_shift_end_before_start_is_422(client, edition, session):
position = make_position(session, edition)
resp = client.post(
_url(edition),
json=_payload(position, ends_at="2026-07-10T17:00:00"),
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "shift.ends_not_after_starts"
def test_create_shift_with_position_from_other_edition_is_422(client, edition, session):
from app.models import Edition
other = Edition(
year=2027, start_date=dt.date(2027, 7, 1), end_date=dt.date(2027, 7, 4)
)
session.add(other)
session.commit()
session.refresh(other)
stranger_position = make_position(session, other)
resp = client.post(_url(edition), json=_payload(stranger_position))
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "shift.position_wrong_edition"
def test_list_filter_by_position(client, edition, session):
p1 = make_position(session, edition, name="P1")
p2 = make_position(session, edition, name="P2")
client.post(_url(edition), json=_payload(p1))
client.post(
_url(edition),
json=_payload(
p2,
starts_at="2026-07-11T09:00:00",
ends_at="2026-07-11T12:00:00",
),
)
only_p1 = client.get(_url(edition), params={"position_id": str(p1.id)}).json()
assert len(only_p1) == 1
assert only_p1[0]["position_id"] == str(p1.id)
assert len(client.get(_url(edition)).json()) == 2
def test_patch_shift_times_and_skills(client, edition, session):
position = make_position(session, edition)
sid = client.post(_url(edition), json=_payload(position)).json()["id"]
patched = client.patch(
_url(edition, f"/{sid}"),
json={
"ends_at": "2026-07-10T23:30:00",
"required_skill_codes": ["SOUND", "SST"],
},
)
assert patched.status_code == 200
assert patched.json()["ends_at"] == "2026-07-10T23:30:00"
assert patched.json()["required_skill_codes"] == ["SOUND", "SST"]
def test_patch_shift_into_invalid_range_is_422(client, edition, session):
position = make_position(session, edition)
sid = client.post(_url(edition), json=_payload(position)).json()["id"]
resp = client.patch(
_url(edition, f"/{sid}"), json={"starts_at": "2026-07-11T00:00:00"}
)
assert resp.status_code == 422
+130
View File
@@ -0,0 +1,130 @@
import datetime as dt
from sqlmodel import select
from app.models import Assignment, Availability, AvailabilityStatus, Volunteer
from tests.factories import (
dt_at,
make_availability,
make_position,
make_shift,
make_volunteer,
)
def _url(edition, suffix=""):
return f"/editions/{edition.id}/volunteers{suffix}"
def test_create_volunteer_with_skills(client, edition):
resp = client.post(
_url(edition),
json={
"first_name": "Alex",
"last_name": "Martin",
"email": "alex@ex.fr",
"tshirt_size": "M",
"skill_codes": ["SST", "PERMIS_B", "SST"],
},
)
assert resp.status_code == 201
body = resp.json()
assert body["skill_codes"] == ["PERMIS_B", "SST"]
assert body["tshirt_size"] == "M"
assert body["edition_id"] == str(edition.id)
def test_create_duplicate_email_conflicts(client, edition):
payload = {"first_name": "A", "last_name": "A", "email": "dup@ex.fr"}
assert client.post(_url(edition), json=payload).status_code == 201
resp = client.post(_url(edition), json={**payload, "first_name": "B"})
assert resp.status_code == 409
assert resp.json()["detail"]["code"] == "volunteer.duplicate_email"
def test_volunteers_without_email_are_allowed(client, edition):
for name in ("Un", "Deux"):
r = client.post(
_url(edition), json={"first_name": name, "last_name": "SansMail"}
)
assert r.status_code == 201
def test_list_is_ordered_by_name(client, edition, session):
make_volunteer(session, edition, first_name="Bob", last_name="Zoe")
make_volunteer(session, edition, first_name="Ana", last_name="Ada")
names = [v["last_name"] for v in client.get(_url(edition)).json()]
assert names == ["Ada", "Zoe"]
def test_get_unknown_id_is_404(client, edition):
import uuid
assert client.get(_url(edition, f"/{uuid.uuid4()}")).status_code == 404
def test_get_volunteer_of_another_edition_is_404(client, edition, session):
from app.models import Edition
other = Edition(
year=2027,
start_date=dt.date(2027, 7, 1),
end_date=dt.date(2027, 7, 4),
)
session.add(other)
session.commit()
session.refresh(other)
stranger = make_volunteer(session, other, email="x@ex.fr")
assert client.get(_url(edition, f"/{stranger.id}")).status_code == 404
def test_patch_replaces_skills_and_can_clear(client, edition, session):
volunteer = make_volunteer(session, edition, email="p@ex.fr")
r1 = client.patch(
_url(edition, f"/{volunteer.id}"),
json={"first_name": "Neo", "skill_codes": ["SST"]},
)
assert r1.status_code == 200
assert r1.json()["first_name"] == "Neo"
assert r1.json()["skill_codes"] == ["SST"]
r2 = client.patch(
_url(edition, f"/{volunteer.id}"), json={"skill_codes": []}
)
assert r2.json()["skill_codes"] == []
def test_delete_is_a_full_cascade(client, edition, session, day_parts):
volunteer = make_volunteer(session, edition, email="gone@ex.fr")
position = make_position(session, edition)
shift = make_shift(
session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12)
)
make_availability(
session,
edition,
volunteer,
day_parts["MORNING"],
date=dt.date(2026, 7, 10),
status=AvailabilityStatus.AVAILABLE,
)
session.add(
Assignment(
edition_id=edition.id,
volunteer_id=volunteer.id,
shift_id=shift.id,
)
)
session.commit()
resp = client.delete(_url(edition, f"/{volunteer.id}"))
assert resp.status_code == 204
assert client.get(_url(edition, f"/{volunteer.id}")).status_code == 404
assert session.exec(select(Volunteer)).all() == []
assert session.exec(select(Assignment)).all() == []
assert session.exec(select(Availability)).all() == []
# Le créneau subsiste.
from app.models import Shift
assert session.get(Shift, shift.id) is not None
+86
View File
@@ -0,0 +1,86 @@
import datetime as dt
from app.models import Assignment, AssignmentStatus, AvailabilityStatus
from app.validation import AvailabilityDeclaration
from app.validation.loader import build_validation_input
from tests.factories import (
dt_at,
make_availability,
make_position,
make_shift,
make_volunteer,
)
def test_build_input_gathers_everything_from_db(client, edition, session, day_parts):
position = make_position(session, edition)
candidate = make_shift(
session, edition, position, start=dt_at(10, 14), end=dt_at(10, 18),
skills=["SST"],
)
active_shift = make_shift(
session, edition, position, start=dt_at(11, 9), end=dt_at(11, 12)
)
declined_shift = make_shift(
session, edition, position, start=dt_at(12, 9), end=dt_at(12, 12)
)
volunteer = make_volunteer(
session, edition, email="v@ex.fr", skills=["SST", "PERMIS_B"]
)
make_availability(
session, edition, volunteer, day_parts["AFTERNOON"],
date=dt.date(2026, 7, 10), status=AvailabilityStatus.IF_NEEDED,
)
session.add_all(
[
Assignment(
edition_id=edition.id, volunteer_id=volunteer.id,
shift_id=active_shift.id, status=AssignmentStatus.CONFIRMED,
),
Assignment(
edition_id=edition.id, volunteer_id=volunteer.id,
shift_id=declined_shift.id, status=AssignmentStatus.DECLINED,
),
]
)
session.commit()
data = build_validation_input(
session=session, edition=edition, volunteer=volunteer, shift=candidate
)
assert data.candidate_shift.id == str(candidate.id)
assert data.candidate_shift.required_skill_codes == frozenset({"SST"})
assert data.volunteer_skill_codes == frozenset({"SST", "PERMIS_B"})
# Seul le créneau de l'affectation active, ni le candidat ni la DECLINEE.
assert [s.id for s in data.existing_shifts] == [str(active_shift.id)]
assert len(data.day_parts) == 3
assert data.availabilities == (
AvailabilityDeclaration(
dt.date(2026, 7, 10), "AFTERNOON", AvailabilityStatus.IF_NEEDED
),
)
def test_exclude_assignment_id_is_honoured(client, edition, session, day_parts):
position = make_position(session, edition)
candidate = make_shift(
session, edition, position, start=dt_at(10, 14), end=dt_at(10, 18)
)
other_shift = make_shift(
session, edition, position, start=dt_at(11, 9), end=dt_at(11, 12)
)
volunteer = make_volunteer(session, edition, email="v@ex.fr")
own = Assignment(
edition_id=edition.id, volunteer_id=volunteer.id,
shift_id=other_shift.id, status=AssignmentStatus.PROPOSED,
)
session.add(own)
session.commit()
session.refresh(own)
data = build_validation_input(
session=session, edition=edition, volunteer=volunteer,
shift=candidate, exclude_assignment_id=own.id,
)
assert data.existing_shifts == ()