Étape 9 : suppression du statut d'affectation (plus de confirmation)

Une affectation est valide dès sa création ; la retirer = supprimer la ligne.

- Modèle : suppression de AssignmentStatus, ACTIVE_ASSIGNMENT_STATUSES et de
  Assignment.status ; migration 0003 (drop_column, alembic check vert)
- Loader de validation : toutes les affectations du bénévole comptent, plus de
  filtre par statut
- API : AssignmentCreate/Update/Read sans status ; création → validation
  toujours exécutée (blocage si violation bloquante) ; PATCH ne porte plus que
  source/locked
- Matrice : MatrixShift.{confirmed, proposed} → {assigned} (compte unique)
- Seed : affectations sans statut, résumé « Affectations » (260)
- Tests mis à jour (matrice, loader, modèle, API) ; 128 tests au total
- CLAUDE.md : glossaire, modèle, sémantique de validation, périmètre

Frontend : les références au statut (propositions, badges confirmé/proposé,
écran de confirmation mobile, mock, schema.ts) restent à retirer séparément.

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-10 07:36:06 +02:00
co-authored by Claude Sonnet 5
parent 8a879ca5c3
commit 464635656a
15 changed files with 109 additions and 208 deletions
+8 -8
View File
@@ -31,7 +31,6 @@ colonne, route et variable utilise la colonne « Code / base ».
| Métier | Code | | Métier | Code |
|------------------------------------|-----------------------------------------------| |------------------------------------|-----------------------------------------------|
| Statut d'affectation | `PROPOSED` / `CONFIRMED` / `DECLINED` / `CANCELLED` |
| Origine d'affectation | `SOLVER` / `MANUAL` | | Origine d'affectation | `SOLVER` / `MANUAL` |
| Verrouillée | `Assignment.locked: bool` | | Verrouillée | `Assignment.locked: bool` |
| Rôle | `COORDINATOR` / `LEAD` / `VOLUNTEER` | | Rôle | `COORDINATOR` / `LEAD` / `VOLUNTEER` |
@@ -74,10 +73,10 @@ les tables sauf les tables d'association.
lendemain), `min_staff`, `ideal_staff`, compétences requises (N-N `Skill`). lendemain), `min_staff`, `ideal_staff`, compétences requises (N-N `Skill`).
- **`Availability`** : `volunteer_id`, `date`, `day_part_id`, `status` - **`Availability`** : `volunteer_id`, `date`, `day_part_id`, `status`
(`AvailabilityStatus`). Unique (`volunteer_id`, `date`, `day_part_id`). (`AvailabilityStatus`). Unique (`volunteer_id`, `date`, `day_part_id`).
- **`Assignment`** : `volunteer_id`, `shift_id`, `status` - **`Assignment`** : `volunteer_id`, `shift_id`, `source` (`AssignmentSource`,
(`AssignmentStatus`, défaut `PROPOSED`), `source` (`AssignmentSource`,
défaut `MANUAL`), `locked` (défaut `false`). Unique (`volunteer_id`, défaut `MANUAL`), `locked` (défaut `false`). Unique (`volunteer_id`,
`shift_id`). `shift_id`). **Pas de statut** : l'affectation est valide dès sa création
(aucune étape de confirmation) ; la retirer, c'est supprimer la ligne.
- **`Account`** : `email` (unique par édition), `role` (`Role`), `edition_id`, - **`Account`** : `email` (unique par édition), `role` (`Role`), `edition_id`,
`volunteer_id` (nullable, unique). `volunteer_id` (nullable, unique).
- **`MagicLinkToken`** : `account_id`, `token_hash` (unique), `expires_at`, - **`MagicLinkToken`** : `account_id`, `token_hash` (unique), `expires_at`,
@@ -164,8 +163,8 @@ traduction se fait via `locales/fr.json`).
Sémantique figée : Sémantique figée :
- Statuts pris en compte pour chevauchement / cumul / repos : - Toutes les affectations du bénévole comptent pour chevauchement / cumul /
`PROPOSED` + `CONFIRMED`. `DECLINED` et `CANCELLED` sont ignorés. repos (une affectation = une décision, toujours active).
- « Journée » (cumul 8 h) : du début du premier `DayPart` du jour au début du - « Journée » (cumul 8 h) : du début du premier `DayPart` du jour au début du
premier `DayPart` du lendemain. premier `DayPart` du lendemain.
- Cumul : `> 8 h 00` strict (8 h 00 pile passe). - Cumul : `> 8 h 00` strict (8 h 00 pile passe).
@@ -190,7 +189,7 @@ Sémantique figée :
3. API CRUD sur `Position`, `Shift`, `Volunteer`, `Assignment`. 3. API CRUD sur `Position`, `Shift`, `Volunteer`, `Assignment`.
4. Moteur de validation (section 3). 4. Moteur de validation (section 3).
5. Endpoint matrice d'une édition : 5. Endpoint matrice d'une édition :
`[{ position, shifts: [{ shift, min_staff, ideal_staff, confirmed, proposed }] }]`. `[{ position, shifts: [{ shift, min_staff, ideal_staff, assigned }] }]`.
6. Jeu de données de démonstration : 120 bénévoles, 15 postes, 4 jours, 6. Jeu de données de démonstration : 120 bénévoles, 15 postes, 4 jours,
quelques trous d'effectif, 2–3 avertissements actifs. quelques trous d'effectif, 2–3 avertissements actifs.
@@ -308,7 +307,8 @@ Aucune dépendance locale requise hors Docker.
| 5 | API CRUD (Position, Shift, Volunteer, Assignment) + matrice + validation branchée | validée | | 5 | API CRUD (Position, Shift, Volunteer, Assignment) + matrice + validation branchée | validée |
| 6 | Jeu de données de démonstration (`app/seed.py`) | validée | | 6 | Jeu de données de démonstration (`app/seed.py`) | validée |
| 7 | Authentification : lien magique + JWT + application des rôles | validée | | 7 | Authentification : lien magique + JWT + application des rôles | validée |
| 8 | Second système : connexion mot de passe (bcrypt) pour la coordination | à valider | | 8 | Second système : connexion mot de passe (bcrypt) pour la coordination | validée |
| 9 | Suppression du statut d'affectation (plus d'étape de confirmation) | à valider |
Premier incrément backend + authentification terminés. **Reste hors Premier incrément backend + authentification terminés. **Reste hors
périmètre** (modèle prêt à les accueillir) : envoi réel d'emails/SMS (le lien périmètre** (modèle prêt à les accueillir) : envoi réel d'emails/SMS (le lien
+29 -53
View File
@@ -1,5 +1,6 @@
"""CRUD des affectations. Toute écriture passe par le moteur de validation : """CRUD des affectations. Une affectation est valide dès sa création (pas
une violation bloquante interdit l'enregistrement, les avertissements sont d'étape de confirmation). La création passe par le moteur de validation : une
violation bloquante interdit l'enregistrement, les avertissements sont
renvoyés.""" renvoyés."""
import uuid import uuid
@@ -23,15 +24,7 @@ from app.auth.deps import (
require_staff, require_staff,
) )
from app.db import get_session from app.db import get_session
from app.models import ( from app.models import Account, Assignment, Edition, Role, Shift, Volunteer
ACTIVE_ASSIGNMENT_STATUSES,
Account,
Assignment,
Edition,
Role,
Shift,
Volunteer,
)
from app.validation import has_blocking, validate from app.validation import has_blocking, validate
from app.validation.loader import build_validation_input from app.validation.loader import build_validation_input
@@ -114,32 +107,27 @@ def create_assignment(
status_code=409, detail={"code": "assignment.duplicate"} status_code=409, detail={"code": "assignment.duplicate"}
) )
# La validation ne s'applique qu'aux affectations actives : créer violations = validate(
# directement une affectation DECLINEE ou ANNULEE n'a rien à valider. build_validation_input(
violations: list = [] session=session,
if payload.status in ACTIVE_ASSIGNMENT_STATUSES: edition=edition,
violations = validate( volunteer=volunteer,
build_validation_input( shift=shift,
session=session, )
edition=edition, )
volunteer=volunteer, if has_blocking(violations):
shift=shift, raise HTTPException(
) status_code=422,
detail={
"code": "error.validation_failed",
"violations": [v.model_dump() for v in violations],
},
) )
if has_blocking(violations):
raise HTTPException(
status_code=422,
detail={
"code": "error.validation_failed",
"violations": [v.model_dump() for v in violations],
},
)
assignment = Assignment( assignment = Assignment(
edition_id=edition.id, edition_id=edition.id,
volunteer_id=volunteer.id, volunteer_id=volunteer.id,
shift_id=shift.id, shift_id=shift.id,
status=payload.status,
source=payload.source, source=payload.source,
locked=payload.locked, locked=payload.locked,
) )
@@ -202,29 +190,17 @@ def update_assignment(
for field, value in changes.items(): for field, value in changes.items():
setattr(assignment, field, value) setattr(assignment, field, value)
warnings: list = [] # `source` / `locked` ne changent pas le résultat de validation ; on le
if assignment.status in ACTIVE_ASSIGNMENT_STATUSES: # rejoue seulement pour renvoyer les avertissements courants.
volunteer = session.get(Volunteer, assignment.volunteer_id) warnings = validate(
shift = session.get(Shift, assignment.shift_id) build_validation_input(
warnings = validate( session=session,
build_validation_input( edition=edition,
session=session, volunteer=session.get(Volunteer, assignment.volunteer_id),
edition=edition, shift=session.get(Shift, assignment.shift_id),
volunteer=volunteer, exclude_assignment_id=assignment.id,
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.commit()
session.refresh(assignment) session.refresh(assignment)
return AssignmentWriteResult(assignment=assignment, warnings=warnings) return AssignmentWriteResult(assignment=assignment, warnings=warnings)
+6 -20
View File
@@ -8,14 +8,7 @@ from app.api.deps import get_edition
from app.api.schemas import MatrixPosition, MatrixResponse, MatrixShift from app.api.schemas import MatrixPosition, MatrixResponse, MatrixShift
from app.auth.deps import require_staff from app.auth.deps import require_staff
from app.db import get_session from app.db import get_session
from app.models import ( from app.models import Account, Assignment, Edition, Position, Shift
Account,
Assignment,
AssignmentStatus,
Edition,
Position,
Shift,
)
router = APIRouter(prefix="/editions/{edition_id}/matrix", tags=["matrix"]) router = APIRouter(prefix="/editions/{edition_id}/matrix", tags=["matrix"])
@@ -41,13 +34,11 @@ def get_matrix(
) )
) )
counts = { counts = {
(shift_id, status): total shift_id: total
for shift_id, status, total in session.exec( for shift_id, total in session.exec(
select( select(Assignment.shift_id, func.count())
Assignment.shift_id, Assignment.status, func.count()
)
.where(Assignment.edition_id == edition.id) .where(Assignment.edition_id == edition.id)
.group_by(Assignment.shift_id, Assignment.status) .group_by(Assignment.shift_id)
) )
} }
@@ -60,12 +51,7 @@ def get_matrix(
ends_at=shift.ends_at, ends_at=shift.ends_at,
min_staff=shift.min_staff, min_staff=shift.min_staff,
ideal_staff=shift.ideal_staff, ideal_staff=shift.ideal_staff,
confirmed=counts.get( assigned=counts.get(shift.id, 0),
(shift.id, AssignmentStatus.CONFIRMED), 0
),
proposed=counts.get(
(shift.id, AssignmentStatus.PROPOSED), 0
),
) )
) )
+1 -6
View File
@@ -7,7 +7,6 @@ from pydantic import BaseModel, ConfigDict, Field
from app.models import ( from app.models import (
AssignmentSource, AssignmentSource,
AssignmentStatus,
PositionCategory, PositionCategory,
Role, Role,
TshirtSize, TshirtSize,
@@ -129,13 +128,11 @@ class ShiftRead(ApiModel):
class AssignmentCreate(ApiModel): class AssignmentCreate(ApiModel):
volunteer_id: uuid.UUID volunteer_id: uuid.UUID
shift_id: uuid.UUID shift_id: uuid.UUID
status: AssignmentStatus = AssignmentStatus.PROPOSED
source: AssignmentSource = AssignmentSource.MANUAL source: AssignmentSource = AssignmentSource.MANUAL
locked: bool = False locked: bool = False
class AssignmentUpdate(ApiModel): class AssignmentUpdate(ApiModel):
status: AssignmentStatus | None = None
source: AssignmentSource | None = None source: AssignmentSource | None = None
locked: bool | None = None locked: bool | None = None
@@ -145,7 +142,6 @@ class AssignmentRead(ApiModel):
edition_id: uuid.UUID edition_id: uuid.UUID
volunteer_id: uuid.UUID volunteer_id: uuid.UUID
shift_id: uuid.UUID shift_id: uuid.UUID
status: AssignmentStatus
source: AssignmentSource source: AssignmentSource
locked: bool locked: bool
created_at: dt.datetime created_at: dt.datetime
@@ -176,8 +172,7 @@ class MatrixShift(ApiModel):
ends_at: dt.datetime ends_at: dt.datetime
min_staff: int min_staff: int
ideal_staff: int ideal_staff: int
confirmed: int assigned: int
proposed: int
class MatrixPosition(ApiModel): class MatrixPosition(ApiModel):
@@ -0,0 +1,35 @@
"""Affectation : suppression du statut (plus d'étape de confirmation)
Revision ID: 0003_drop_assignment_status
Revises: 0002_account_password
Create Date: 2026-09-10
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0003_drop_assignment_status"
down_revision: str | None = "0002_account_password"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_STATUS = sa.Enum(
"PROPOSED", "CONFIRMED", "DECLINED", "CANCELLED",
name="assignmentstatus", native_enum=False,
)
def upgrade() -> None:
op.drop_column("assignment", "status")
def downgrade() -> None:
op.add_column(
"assignment",
sa.Column(
"status", _STATUS, server_default="PROPOSED", nullable=False
),
)
-4
View File
@@ -20,9 +20,7 @@ from app.models.domain import (
VolunteerSkill, VolunteerSkill,
) )
from app.models.enums import ( from app.models.enums import (
ACTIVE_ASSIGNMENT_STATUSES,
AssignmentSource, AssignmentSource,
AssignmentStatus,
AvailabilityStatus, AvailabilityStatus,
PositionCategory, PositionCategory,
Role, Role,
@@ -30,11 +28,9 @@ from app.models.enums import (
) )
__all__ = [ __all__ = [
"ACTIVE_ASSIGNMENT_STATUSES",
"Account", "Account",
"Assignment", "Assignment",
"AssignmentSource", "AssignmentSource",
"AssignmentStatus",
"Availability", "Availability",
"AvailabilityStatus", "AvailabilityStatus",
"DayPart", "DayPart",
+3 -10
View File
@@ -16,7 +16,6 @@ from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
from app.models.base import TimestampMixin from app.models.base import TimestampMixin
from app.models.enums import ( from app.models.enums import (
AssignmentSource, AssignmentSource,
AssignmentStatus,
AvailabilityStatus, AvailabilityStatus,
PositionCategory, PositionCategory,
Role, Role,
@@ -293,7 +292,9 @@ class Availability(TimestampMixin, table=True):
class Assignment(TimestampMixin, table=True): class Assignment(TimestampMixin, table=True):
"""Décision de l'organisation : « tu tiens tel créneau ». """Décision de l'organisation : « tu tiens tel créneau ».
À ne pas confondre avec Availability (déclaration du bénévole). Existe ⇒ le bénévole est affecté (pas d'étape de confirmation). Retirer une
affectation, c'est supprimer la ligne. À ne pas confondre avec Availability
(déclaration du bénévole).
""" """
__table_args__ = ( __table_args__ = (
@@ -312,14 +313,6 @@ class Assignment(TimestampMixin, table=True):
shift_id: uuid.UUID = Field( shift_id: uuid.UUID = Field(
foreign_key="shift.id", index=True, ondelete="CASCADE" foreign_key="shift.id", index=True, ondelete="CASCADE"
) )
status: AssignmentStatus = Field(
default=AssignmentStatus.PROPOSED,
sa_column=Column(
SAEnum(AssignmentStatus, native_enum=False),
nullable=False,
server_default=AssignmentStatus.PROPOSED.value,
),
)
source: AssignmentSource = Field( source: AssignmentSource = Field(
default=AssignmentSource.MANUAL, default=AssignmentSource.MANUAL,
sa_column=Column( sa_column=Column(
-14
View File
@@ -19,13 +19,6 @@ class AvailabilityStatus(enum.StrEnum):
UNAVAILABLE = "UNAVAILABLE" UNAVAILABLE = "UNAVAILABLE"
class AssignmentStatus(enum.StrEnum):
PROPOSED = "PROPOSED"
CONFIRMED = "CONFIRMED"
DECLINED = "DECLINED"
CANCELLED = "CANCELLED"
class AssignmentSource(enum.StrEnum): class AssignmentSource(enum.StrEnum):
SOLVER = "SOLVER" SOLVER = "SOLVER"
MANUAL = "MANUAL" MANUAL = "MANUAL"
@@ -51,10 +44,3 @@ class TshirtSize(enum.StrEnum):
XL = "XL" XL = "XL"
XXL = "XXL" XXL = "XXL"
XXXL = "XXXL" XXXL = "XXXL"
# Statuts d'affectation « actifs » : seuls ceux-ci comptent pour les règles de
# chevauchement, de cumul horaire et de repos (voir app/validation).
ACTIVE_ASSIGNMENT_STATUSES: frozenset[AssignmentStatus] = frozenset(
{AssignmentStatus.PROPOSED, AssignmentStatus.CONFIRMED}
)
+4 -13
View File
@@ -20,7 +20,6 @@ from app.models import (
Account, Account,
Assignment, Assignment,
AssignmentSource, AssignmentSource,
AssignmentStatus,
Availability, Availability,
AvailabilityStatus, AvailabilityStatus,
DayPart, DayPart,
@@ -225,7 +224,7 @@ class Summary:
f" Postes : {self.positions}", f" Postes : {self.positions}",
f" Créneaux : {self.shifts}", f" Créneaux : {self.shifts}",
f" Créneaux sous l'effectif minimal (trous) : {self.understaffed_shifts}", f" Créneaux sous l'effectif minimal (trous) : {self.understaffed_shifts}",
f" Affectations actives : {self.assignments}", f" Affectations : {self.assignments}",
f" Avertissements actifs : {sum(self.warnings.values())}", f" Avertissements actifs : {sum(self.warnings.values())}",
] ]
for code, count in sorted(self.warnings.items()): for code, count in sorted(self.warnings.items()):
@@ -490,11 +489,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
edition_id=edition.id, edition_id=edition.id,
volunteer_id=volunteer.id, volunteer_id=volunteer.id,
shift_id=shift.id, shift_id=shift.id,
status=(
AssignmentStatus.CONFIRMED
if rng.random() < 0.55
else AssignmentStatus.PROPOSED
),
source=AssignmentSource.MANUAL, source=AssignmentSource.MANUAL,
) )
) )
@@ -662,7 +656,6 @@ def _add_active(session, edition, volunteer, shift, assigned) -> None:
edition_id=edition.id, edition_id=edition.id,
volunteer_id=volunteer.id, volunteer_id=volunteer.id,
shift_id=shift.id, shift_id=shift.id,
status=AssignmentStatus.CONFIRMED,
source=AssignmentSource.MANUAL, source=AssignmentSource.MANUAL,
) )
) )
@@ -671,12 +664,10 @@ def _add_active(session, edition, volunteer, shift, assigned) -> None:
def _verify(session: Session, edition: Edition, summary: Summary) -> None: def _verify(session: Session, edition: Edition, summary: Summary) -> None:
session.flush() session.flush()
active = session.exec( assignments = session.exec(
select(Assignment) select(Assignment).where(Assignment.edition_id == edition.id)
.where(Assignment.edition_id == edition.id)
.where(Assignment.status.in_((AssignmentStatus.PROPOSED, AssignmentStatus.CONFIRMED)))
) )
for assignment in active: for assignment in assignments:
volunteer = session.get(Volunteer, assignment.volunteer_id) volunteer = session.get(Volunteer, assignment.volunteer_id)
shift = session.get(Shift, assignment.shift_id) shift = session.get(Shift, assignment.shift_id)
for violation in validate( for violation in validate(
+2 -4
View File
@@ -7,7 +7,6 @@ import uuid
from sqlmodel import Session, select from sqlmodel import Session, select
from app.models import ( from app.models import (
ACTIVE_ASSIGNMENT_STATUSES,
Assignment, Assignment,
Availability, Availability,
DayPart, DayPart,
@@ -65,13 +64,12 @@ def build_validation_input(
) )
code_by_day_part_id = {dp.id: dp.code for dp in day_parts} code_by_day_part_id = {dp.id: dp.code for dp in day_parts}
# Toute affectation existante du bénévole compte (une affectation = une
# décision, toujours active).
stmt = ( stmt = (
select(Shift) select(Shift)
.join(Assignment, Assignment.shift_id == Shift.id) .join(Assignment, Assignment.shift_id == Shift.id)
.where(Assignment.volunteer_id == volunteer.id) .where(Assignment.volunteer_id == volunteer.id)
.where(
Assignment.status.in_(tuple(ACTIVE_ASSIGNMENT_STATUSES))
)
.where(Shift.id != shift.id) .where(Shift.id != shift.id)
) )
if exclude_assignment_id is not None: if exclude_assignment_id is not None:
+2 -3
View File
@@ -76,9 +76,8 @@ class AvailabilityDeclaration:
class ValidationInput: class ValidationInput:
"""Tout ce qu'il faut pour valider une affectation candidate. """Tout ce qu'il faut pour valider une affectation candidate.
`existing_shifts` = les créneaux des autres affectations **actives** `existing_shifts` = les créneaux des autres affectations du même bénévole
(`PROPOSED` / `CONFIRMED`) du même bénévole ; le filtrage par statut est à (une affectation = une décision, il n'y a pas d'étape de confirmation).
la charge de l'appelant.
""" """
candidate_shift: ShiftInfo candidate_shift: ShiftInfo
+4 -37
View File
@@ -35,8 +35,8 @@ def test_create_assignment_clean(client, edition, session, day_parts):
assert resp.status_code == 201 assert resp.status_code == 201
body = resp.json() body = resp.json()
assert body["warnings"] == [] assert body["warnings"] == []
assert body["assignment"]["status"] == "PROPOSED"
assert body["assignment"]["source"] == "MANUAL" assert body["assignment"]["source"] == "MANUAL"
assert body["assignment"]["locked"] is False
def test_create_assignment_returns_warnings(client, edition, session, day_parts): def test_create_assignment_returns_warnings(client, edition, session, day_parts):
@@ -135,7 +135,7 @@ def test_validate_endpoint_does_not_persist(client, edition, session, day_parts)
assert client.get(_url(edition)).json() == [] assert client.get(_url(edition)).json() == []
def test_update_status_revalidates_and_returns_warnings( def test_patch_locked_keeps_assignment_and_returns_warnings(
client, edition, session, day_parts client, edition, session, day_parts
): ):
volunteer, shift = _setup(session, edition, day_parts, available=False) volunteer, shift = _setup(session, edition, day_parts, available=False)
@@ -144,47 +144,14 @@ def test_update_status_revalidates_and_returns_warnings(
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)}, json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
).json()["assignment"]["id"] ).json()["assignment"]["id"]
resp = client.patch( resp = client.patch(_url(edition, f"/{aid}"), json={"locked": True})
_url(edition, f"/{aid}"), json={"status": "CONFIRMED"}
)
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["assignment"]["status"] == "CONFIRMED" assert resp.json()["assignment"]["locked"] is True
assert any( assert any(
w["code"] == "OUTSIDE_AVAILABILITY" for w in resp.json()["warnings"] 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): def test_delete_assignment(client, edition, session, day_parts):
volunteer, shift = _setup(session, edition, day_parts) volunteer, shift = _setup(session, edition, day_parts)
aid = client.post( aid = client.post(
+5 -15
View File
@@ -1,5 +1,4 @@
from app.models import Assignment
from app.models import Assignment, AssignmentStatus
from tests.factories import dt_at, make_position, make_shift, make_volunteer from tests.factories import dt_at, make_position, make_shift, make_volunteer
@@ -18,14 +17,7 @@ def test_matrix_shape_and_counts(client, edition, session):
session, edition, parking, start=dt_at(10, 8), end=dt_at(10, 12) session, edition, parking, start=dt_at(10, 8), end=dt_at(10, 12)
) )
for i, status in enumerate( for i in range(3):
[
AssignmentStatus.CONFIRMED,
AssignmentStatus.CONFIRMED,
AssignmentStatus.PROPOSED,
AssignmentStatus.DECLINED,
]
):
volunteer = make_volunteer( volunteer = make_volunteer(
session, edition, first_name=f"V{i}", last_name="X", email=f"v{i}@ex.fr" session, edition, first_name=f"V{i}", last_name="X", email=f"v{i}@ex.fr"
) )
@@ -34,7 +26,6 @@ def test_matrix_shape_and_counts(client, edition, session):
edition_id=edition.id, edition_id=edition.id,
volunteer_id=volunteer.id, volunteer_id=volunteer.id,
shift_id=shift_a.id, shift_id=shift_a.id,
status=status,
) )
) )
session.commit() session.commit()
@@ -47,13 +38,12 @@ def test_matrix_shape_and_counts(client, edition, session):
assert [s["shift_id"] for s in bar_shifts] == [str(shift_a.id), str(shift_b.id)] assert [s["shift_id"] for s in bar_shifts] == [str(shift_a.id), str(shift_b.id)]
first = bar_shifts[0] first = bar_shifts[0]
assert first["confirmed"] == 2 assert first["assigned"] == 3
assert first["proposed"] == 1 # la DECLINEE n'est pas comptée
assert first["min_staff"] == 2 assert first["min_staff"] == 2
assert first["ideal_staff"] == 5 assert first["ideal_staff"] == 5
assert bar_shifts[1]["confirmed"] == 0 assert bar_shifts[1]["assigned"] == 0
assert body["positions"][1]["shifts"][0]["proposed"] == 0 assert body["positions"][1]["shifts"][0]["assigned"] == 0
def test_matrix_unknown_edition_is_404(client): def test_matrix_unknown_edition_is_404(client):
-2
View File
@@ -7,7 +7,6 @@ from sqlalchemy.exc import IntegrityError
from app.models import ( from app.models import (
Assignment, Assignment,
AssignmentSource, AssignmentSource,
AssignmentStatus,
Availability, Availability,
AvailabilityStatus, AvailabilityStatus,
DayPart, DayPart,
@@ -98,7 +97,6 @@ def test_create_core_graph(session):
session.refresh(assignment) session.refresh(assignment)
# Valeurs par défaut appliquées. # Valeurs par défaut appliquées.
assert assignment.status == AssignmentStatus.PROPOSED
assert assignment.source == AssignmentSource.MANUAL assert assignment.source == AssignmentSource.MANUAL
assert assignment.locked is False assert assignment.locked is False
# Relations. # Relations.
+10 -19
View File
@@ -1,6 +1,6 @@
import datetime as dt import datetime as dt
from app.models import Assignment, AssignmentStatus, AvailabilityStatus from app.models import Assignment, AvailabilityStatus
from app.validation import AvailabilityDeclaration from app.validation import AvailabilityDeclaration
from app.validation.loader import build_validation_input from app.validation.loader import build_validation_input
from tests.factories import ( from tests.factories import (
@@ -18,12 +18,9 @@ def test_build_input_gathers_everything_from_db(client, edition, session, day_pa
session, edition, position, start=dt_at(10, 14), end=dt_at(10, 18), session, edition, position, start=dt_at(10, 14), end=dt_at(10, 18),
skills=["SST"], skills=["SST"],
) )
active_shift = make_shift( other_shift = make_shift(
session, edition, position, start=dt_at(11, 9), end=dt_at(11, 12) 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( volunteer = make_volunteer(
session, edition, email="v@ex.fr", skills=["SST", "PERMIS_B"] session, edition, email="v@ex.fr", skills=["SST", "PERMIS_B"]
) )
@@ -31,17 +28,11 @@ def test_build_input_gathers_everything_from_db(client, edition, session, day_pa
session, edition, volunteer, day_parts["AFTERNOON"], session, edition, volunteer, day_parts["AFTERNOON"],
date=dt.date(2026, 7, 10), status=AvailabilityStatus.IF_NEEDED, date=dt.date(2026, 7, 10), status=AvailabilityStatus.IF_NEEDED,
) )
session.add_all( session.add(
[ Assignment(
Assignment( edition_id=edition.id, volunteer_id=volunteer.id,
edition_id=edition.id, volunteer_id=volunteer.id, shift_id=other_shift.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() session.commit()
@@ -52,8 +43,8 @@ def test_build_input_gathers_everything_from_db(client, edition, session, day_pa
assert data.candidate_shift.id == str(candidate.id) assert data.candidate_shift.id == str(candidate.id)
assert data.candidate_shift.required_skill_codes == frozenset({"SST"}) assert data.candidate_shift.required_skill_codes == frozenset({"SST"})
assert data.volunteer_skill_codes == frozenset({"SST", "PERMIS_B"}) assert data.volunteer_skill_codes == frozenset({"SST", "PERMIS_B"})
# Seul le créneau de l'affectation active, ni le candidat ni la DECLINEE. # Toute affectation existante compte, sauf le créneau candidat lui-même.
assert [s.id for s in data.existing_shifts] == [str(active_shift.id)] assert [s.id for s in data.existing_shifts] == [str(other_shift.id)]
assert len(data.day_parts) == 3 assert len(data.day_parts) == 3
assert data.availabilities == ( assert data.availabilities == (
AvailabilityDeclaration( AvailabilityDeclaration(
@@ -73,7 +64,7 @@ def test_exclude_assignment_id_is_honoured(client, edition, session, day_parts):
volunteer = make_volunteer(session, edition, email="v@ex.fr") volunteer = make_volunteer(session, edition, email="v@ex.fr")
own = Assignment( own = Assignment(
edition_id=edition.id, volunteer_id=volunteer.id, edition_id=edition.id, volunteer_id=volunteer.id,
shift_id=other_shift.id, status=AssignmentStatus.PROPOSED, shift_id=other_shift.id,
) )
session.add(own) session.add(own)
session.commit() session.commit()