Compétences supprimées de bout en bout :
- tables `skill`, `volunteer_skill`, `shift_required_skill` ;
- `Volunteer.skill_codes`, `Shift.required_skill_codes`, colonne `skills`
de l'import, règle de validation `MISSING_REQUIRED_SKILL`, jeu de démo.
Disponibilités repensées : `Availability` passe de (`day_part_id`,
`status` tri-état) à une **fenêtre horaire libre** (`date`, `start_time`,
`end_time` ; `end <= start` ⇒ passe minuit), plusieurs par jour.
- Nouveaux endpoints `GET`/`PUT /editions/{id}/volunteers/{vid}/availability`
(PUT = remplacement complet, COORDINATION) et
`GET /editions/{id}/availability` (toute l'édition, pour la grille).
- `OUTSIDE_AVAILABILITY` (toujours WARNING) : compare le créneau à l'union
des fenêtres du bénévole ce jour-là ; renvoie `date` + `uncovered_minutes`.
`DayPart` ne sert plus qu'à la borne de « journée » du cumul 8 h.
- L'import ne gère plus les disponibilités (fenêtres saisies dans l'app) :
`values.py` supprimé, mapping réduit à nom/email/téléphone/commentaire.
Migration 0007 (recrée `availability`). `AvailabilityStatus` supprimé.
158 tests, `alembic check` et `ruff` au vert. Front à suivre.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
199 lines
4.9 KiB
Python
199 lines
4.9 KiB
Python
import datetime as dt
|
|
|
|
import pytest
|
|
from sqlalchemy import text
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
from app.models import (
|
|
Assignment,
|
|
AssignmentSource,
|
|
Availability,
|
|
Edition,
|
|
Position,
|
|
PositionCategory,
|
|
Shift,
|
|
Volunteer,
|
|
)
|
|
|
|
EXPECTED_TABLES = {
|
|
"edition",
|
|
"day_part",
|
|
"artist",
|
|
"volunteer",
|
|
"volunteer_artist",
|
|
"volunteer_position_wish",
|
|
"account",
|
|
"magic_link_token",
|
|
"position",
|
|
"shift",
|
|
"availability",
|
|
"assignment",
|
|
}
|
|
|
|
|
|
def _edition(session) -> Edition:
|
|
e = Edition(
|
|
year=2026,
|
|
start_date=dt.date(2026, 7, 8),
|
|
end_date=dt.date(2026, 7, 11),
|
|
retention_days=365,
|
|
)
|
|
session.add(e)
|
|
session.commit()
|
|
session.refresh(e)
|
|
return e
|
|
|
|
|
|
def test_metadata_has_expected_tables():
|
|
from sqlmodel import SQLModel
|
|
|
|
assert set(SQLModel.metadata.tables) == EXPECTED_TABLES
|
|
|
|
|
|
def test_create_core_graph(session):
|
|
edition = _edition(session)
|
|
|
|
volunteer = Volunteer(
|
|
edition_id=edition.id,
|
|
email="alex@example.org",
|
|
first_name="Alex",
|
|
last_name="Martin",
|
|
)
|
|
position = Position(
|
|
edition_id=edition.id, name="Bar principal", category=PositionCategory.BAR
|
|
)
|
|
session.add(volunteer)
|
|
session.add(position)
|
|
session.commit()
|
|
session.refresh(volunteer)
|
|
session.refresh(position)
|
|
|
|
shift = Shift(
|
|
edition_id=edition.id,
|
|
position_id=position.id,
|
|
starts_at=dt.datetime(2026, 7, 9, 18, 0),
|
|
ends_at=dt.datetime(2026, 7, 9, 23, 0),
|
|
min_staff=2,
|
|
ideal_staff=4,
|
|
)
|
|
session.add(shift)
|
|
session.commit()
|
|
session.refresh(volunteer)
|
|
session.refresh(shift)
|
|
|
|
assignment = Assignment(
|
|
edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id
|
|
)
|
|
session.add(assignment)
|
|
session.commit()
|
|
session.refresh(assignment)
|
|
|
|
# Valeurs par défaut appliquées.
|
|
assert assignment.source == AssignmentSource.MANUAL
|
|
assert assignment.locked is False
|
|
# Relations.
|
|
assert shift.position.id == position.id
|
|
assert volunteer.assignments[0].id == assignment.id
|
|
|
|
|
|
def test_availability_window_round_trip(session):
|
|
edition = _edition(session)
|
|
volunteer = Volunteer(
|
|
edition_id=edition.id, first_name="Sam", last_name="Nuit"
|
|
)
|
|
session.add(volunteer)
|
|
session.commit()
|
|
session.refresh(volunteer)
|
|
|
|
avail = Availability(
|
|
edition_id=edition.id,
|
|
volunteer_id=volunteer.id,
|
|
date=dt.date(2026, 7, 9),
|
|
start_time=dt.time(19, 0),
|
|
end_time=dt.time(2, 0), # passe minuit
|
|
)
|
|
session.add(avail)
|
|
session.commit()
|
|
session.expire_all()
|
|
|
|
reloaded = session.get(Availability, avail.id)
|
|
assert reloaded.start_time == dt.time(19, 0)
|
|
assert reloaded.end_time == dt.time(2, 0)
|
|
|
|
|
|
def test_volunteer_email_unique_per_edition(session):
|
|
edition = _edition(session)
|
|
session.add(
|
|
Volunteer(
|
|
edition_id=edition.id,
|
|
email="dup@example.org",
|
|
first_name="A",
|
|
last_name="A",
|
|
)
|
|
)
|
|
session.commit()
|
|
session.add(
|
|
Volunteer(
|
|
edition_id=edition.id,
|
|
email="dup@example.org",
|
|
first_name="B",
|
|
last_name="B",
|
|
)
|
|
)
|
|
with pytest.raises(IntegrityError):
|
|
session.commit()
|
|
|
|
|
|
def test_multiple_volunteers_without_email_allowed(session):
|
|
edition = _edition(session)
|
|
session.add_all(
|
|
[
|
|
Volunteer(edition_id=edition.id, first_name="A", last_name="A"),
|
|
Volunteer(edition_id=edition.id, first_name="B", last_name="B"),
|
|
]
|
|
)
|
|
session.commit() # NULL n'entre pas dans la contrainte d'unicité
|
|
|
|
|
|
def test_deleting_volunteer_cascades_to_assignments(session):
|
|
edition = _edition(session)
|
|
volunteer = Volunteer(
|
|
edition_id=edition.id, first_name="Cascade", last_name="Test"
|
|
)
|
|
position = Position(
|
|
edition_id=edition.id, name="Accueil", category=PositionCategory.WELCOME
|
|
)
|
|
session.add_all([volunteer, position])
|
|
session.commit()
|
|
session.refresh(volunteer)
|
|
session.refresh(position)
|
|
|
|
shift = Shift(
|
|
edition_id=edition.id,
|
|
position_id=position.id,
|
|
starts_at=dt.datetime(2026, 7, 9, 9, 0),
|
|
ends_at=dt.datetime(2026, 7, 9, 12, 0),
|
|
)
|
|
session.add(shift)
|
|
session.commit()
|
|
session.refresh(shift)
|
|
|
|
assignment = Assignment(
|
|
edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id
|
|
)
|
|
session.add(assignment)
|
|
session.commit()
|
|
assignment_id = assignment.id
|
|
volunteer_id = volunteer.id
|
|
|
|
# Suppression au niveau base : la cascade ON DELETE doit s'appliquer.
|
|
session.execute(
|
|
text("DELETE FROM volunteer WHERE id = :id"), {"id": volunteer_id}
|
|
)
|
|
session.commit()
|
|
session.expire_all()
|
|
|
|
assert session.get(Assignment, assignment_id) is None
|
|
# Le créneau, lui, subsiste.
|
|
assert session.get(Shift, shift.id) is not None
|