Retrait des compétences ; disponibilités en fenêtres horaires libres

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
This commit is contained in:
AdamThomas
2026-09-10 22:46:45 +02:00
co-authored by Claude Sonnet 5
parent 81609eecc5
commit be51770b51
36 changed files with 800 additions and 1199 deletions
+37 -29
View File
@@ -17,11 +17,10 @@ colonne, route et variable utilise la colonne « Code / base ».
|------------------------------------|-----------------------------------------------|
| Édition | `Edition` |
| Bénévole | `Volunteer` |
| Compétence | `Skill` |
| Poste | `Position` |
| Référent d'un poste | `Position.lead_account_id` → `Account` |
| Créneau | `Shift` |
| Disponibilité | `Availability` |
| Disponibilité (fenêtre horaire libre) | `Availability` (`date`, `start_time`, `end_time`) |
| Affectation | `Assignment` |
| Artiste programmé | `Artist` (`name`, `performance_date`) |
| Vœu « je veux voir cet artiste » | `VolunteerArtist` (N-N `Volunteer` ↔ `Artist`) |
@@ -37,13 +36,12 @@ colonne, route et variable utilise la colonne « Code / base ».
| Origine d'affectation | `SOLVER` / `MANUAL` |
| Verrouillée | `Assignment.locked: bool` |
| Rôle | `COORDINATOR` / `LEAD` / `VOLUNTEER` |
| Disponibilité (tri-état) | `AVAILABLE` / `IF_NEEDED` / `UNAVAILABLE` |
| Catégorie de poste | `BAR` / `TICKETING` / `WELCOME` / `PARKING` / `CLEANING` / `CATERING` / `SETUP` / `STAGE` / `OTHER` |
### Distinction critique (ne jamais confondre, ni en base ni dans l'UI)
- **`Availability`** = une *déclaration* du bénévole : « je peux venir tel
jour, telle demi-journée ».
- **`Availability`** = une *déclaration* du bénévole : « le samedi, je peux
venir de 14 h à 23 h » — fenêtre horaire libre, plusieurs par jour.
- **`Assignment`** = une *décision* de l'organisation : « tu tiens le bar
samedi 18 h – 22 h ».
@@ -63,17 +61,16 @@ les tables sauf les tables d'association.
- **`DayPart`** (« créneau type ») : découpage d'une journée propre à l'édition
(nombre et bornes libres). `code`, `label`, `start_time`, `end_time`
(`end_time < start_time` ⇒ passe minuit), `order`. Unique (`edition_id`,
`code`) ; `code` figé après création (clé de l'import des disponibilités).
`code`) ; `code` figé après création. Sert à générer les créneaux 0/0 des
postes (pas les disponibilités, qui sont des fenêtres libres).
CRUD `GET/POST/PATCH/DELETE /editions/{id}/day-parts` (COORDINATION pour
l'écriture).
- **`Volunteer`** : `email` (unique **par édition**, nullable pour les
bénévoles créés à la main — plusieurs `NULL` autorisés), `first_name`,
`last_name`, `phone`, `comment`. Pas de date de naissance, ni de taille de
t-shirt / régime alimentaire. Porte trois listes de vœux, exposées par la
fiche (`skill_codes`, `artist_ids`, `wished_position_ids` ; `None`/absent =
inchangé, `[]` = vider).
- **`Skill`** : référentiel par édition (`code`, `label`). N-N `Volunteer`
(`volunteer_skill`). N-N `Shift` (`shift_required_skill`).
`last_name`, `phone`, `comment`. Pas de date de naissance, taille de
t-shirt, régime, ni compétences. Porte deux listes de vœux, exposées par la
fiche (`artist_ids`, `wished_position_ids` ; `None`/absent = inchangé,
`[]` = vider).
- **`Artist`** : programmation de l'édition (`name`, `performance_date`).
Unique (`edition_id`, `name`, `performance_date`). La coordination gère la
liste ; elle est présentée par jour. Les bénévoles y choisissent les
@@ -87,13 +84,18 @@ les tables sauf les tables d'association.
`lead_account_id` (→ `Account`, `SET NULL` à la suppression du compte).
- **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont
il est issu, `SET NULL` si le type est supprimé), `starts_at`, `ends_at`
(peut finir le lendemain), `min_staff`, `ideal_staff`, compétences requises
(N-N `Skill`). À la création d'un `Position`, un `Shift` à 0/0 est généré par
jour de l'édition et par `DayPart` ;
`POST /editions/{id}/positions/{pid}/generate-shifts` recrée les manquants
(après ajout d'un créneau type ou extension des dates).
- **`Availability`** : `volunteer_id`, `date`, `day_part_id`, `status`
(`AvailabilityStatus`). Unique (`volunteer_id`, `date`, `day_part_id`).
(peut finir le lendemain), `min_staff`, `ideal_staff`. À la création d'un
`Position`, un `Shift` à 0/0 est généré par jour de l'édition et par
`DayPart` ; `POST /editions/{id}/positions/{pid}/generate-shifts` recrée les
manquants (après ajout d'un créneau type ou extension des dates).
- **`Availability`** : `volunteer_id`, `date`, `start_time`, `end_time`
(`end_time <= start_time` ⇒ la fenêtre se termine le lendemain). Fenêtre
horaire **libre** ; plusieurs par (`volunteer`, `date`). Unique
(`volunteer_id`, `date`, `start_time`, `end_time`). Éditée en bloc :
`GET`/`PUT /editions/{id}/volunteers/{vid}/availability` (lecture : COORD /
LEAD ou le bénévole lui-même ; `PUT` : COORDINATION, remplace tout).
`GET /editions/{id}/availability` liste celles de toute l'édition (COORD /
LEAD) — pour la grille des besoins.
- **`Assignment`** : `volunteer_id`, `shift_id`, `source` (`AssignmentSource`,
défaut `MANUAL`), `locked` (défaut `false`). Unique (`volunteer_id`,
`shift_id`). **Pas de statut** : l'affectation est valide dès sa création
@@ -110,9 +112,9 @@ Conventions techniques :
type ENUM natif PostgreSQL.
- Suppression en cascade au niveau base (`ON DELETE CASCADE`) sur tous les
`edition_id` et sur les liens vers `volunteer`, `shift`, `position`,
`skill`, `artist`, `account`. La suppression complète d'un bénévole (RGPD)
efface donc ses `Availability`, `Assignment`, liens de compétence, vœux
(artistes, postes) et son `Account`.
`artist`, `account`. La suppression complète d'un bénévole (RGPD) efface
donc ses `Availability`, `Assignment`, vœux (artistes, postes) et son
`Account`.
- Dates/heures **naïves**, heure locale Europe/Paris (`TZ` du conteneur +
`tzdata`). `app.models.local_now()` pour les horodatages.
@@ -135,6 +137,7 @@ Matrice appliquée par les endpoints :
| `assignments` écriture | ✔ | ses postes | — |
| `assignments` lecture | ✔ | ✔ | les siennes |
| `artists` lecture / écriture | ✔ / ✔ | ✔ / — | ✔ / — |
| `availability` lecture / écriture | ✔ / ✔ | ✔ / — | la sienne / — |
| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — |
**Authentification — deux systèmes selon le compte**, une même session JWT
@@ -209,17 +212,20 @@ traduction se fait via `locales/fr.json`).
| Code | Sévérité | Règle |
|---------------------------|------------|-------------------------------------------------------------|
| `SHIFT_OVERLAP` | `BLOCKING` | Deux créneaux d'un même bénévole se chevauchent. |
| `OUTSIDE_AVAILABILITY` | `WARNING` | Un `DayPart` couvert par le créneau est `UNAVAILABLE` ou sans déclaration. |
| `OUTSIDE_AVAILABILITY` | `WARNING` | Le créneau n'est pas entièrement couvert par les fenêtres de disponibilité déclarées ce jour-là (params : `date`, `uncovered_minutes`). |
| `DAILY_HOURS_EXCEEDED` | `WARNING` | > 8 h 00 d'affectations cumulées sur une même journée. |
| `INSUFFICIENT_REST` | `WARNING` | < 8 h 00 entre la fin d'un créneau et le début du suivant. |
| `MISSING_REQUIRED_SKILL` | `WARNING` | Le bénévole n'a pas une compétence requise par le créneau. |
Sémantique figée :
- Toutes les affectations du bénévole comptent pour chevauchement / cumul /
repos (une affectation = une décision, toujours active).
- `OUTSIDE_AVAILABILITY` : on prend l'**union** des fenêtres `Availability` du
bénévole (converties en instants, minuit géré) ; les minutes du créneau hors
de cette union déclenchent l'avertissement. Aucune fenêtre déclarée ⇒
créneau entièrement à découvert.
- « 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 (le `DayPart` ne sert plus qu'à ça).
- Cumul : `> 8 h 00` strict (8 h 00 pile passe).
- Repos : `< 8 h 00` strict.
- Créneaux jointifs (`end_at == start_at`) : **pas** un chevauchement.
@@ -234,7 +240,8 @@ Sémantique figée :
### Premier incrément (en cours)
1. Modèle de données complet + migrations Alembic.
2. Import CSV/Excel (un seul fichier bénévoles + disponibilités), en deux
2. Import CSV/Excel (identité des bénévoles : nom, email, téléphone,
commentaire — pas les disponibilités, qui se déclarent dans l'app), en deux
temps : analyse (colonnes détectées, aperçu) puis exécution avec mapping
validé. Rapport ligne par ligne. Détection des doublons : upsert sur
`email` ; sans email, détection sur (`last_name`, `first_name`) signalée
@@ -277,12 +284,12 @@ fonctions d'IA. *(L'envoi d'emails par SMTP est en place — voir § 2.)*
│ │ ├── logging_config.py # RGPD : pas de PII dans les logs
│ │ ├── api/ # routers + schemas Pydantic + deps
│ │ │ # health, auth, accounts, editions,
│ │ │ # day_parts, imports, artists,
│ │ │ # volunteers, positions, shifts,
│ │ │ # day_parts, imports, artists, volunteers,
│ │ │ # availability, positions, shifts,
│ │ │ # assignments, matrix, notifications
│ │ ├── auth/ # mot de passe + lien magique + JWT + rôles
│ │ ├── email/ # SMTP (sender) + rendu des emails (templates)
│ │ ├── importing/ # import CSV/Excel (parser, values, service)
│ │ ├── importing/ # import CSV/Excel (parser, text, service)
│ │ ├── models/ # tables SQLModel
│ │ ├── validation/ # moteur pur (models, engine) + loader (base)
│ │ ├── bootstrap.py # instance vierge : édition + 1er compte
@@ -371,6 +378,7 @@ Aucune dépendance locale requise hors Docker.
| 11 | `GET`/`PATCH /editions/{id}` (nom, dates) — ajouter/retirer des jours ; UX config front | validée |
| 12 | Créneaux types (`DayPart` CRUD) + génération auto des créneaux 0/0 à la création d'un poste ; case « à définir » / « sureffectif » sur la grille | à valider |
| 13 | Bénévole : retrait taille de t-shirt / régime ; entité `Artist` (CRUD, par jour) + vœux des bénévoles (artistes, postes) | à valider |
| 14 | Retrait des compétences (`Skill` et règle `MISSING_REQUIRED_SKILL`) ; disponibilités = fenêtres horaires libres par jour (`GET`/`PUT` availability), prises en compte par `OUTSIDE_AVAILABILITY` | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : solveur OR-Tools,
SMS, constructeur de formulaire, pointage sur site, IA. Frontend :
+1 -31
View File
@@ -1,13 +1,10 @@
"""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
from sqlmodel import Session
def owned_or_404(instance, edition_id: uuid.UUID):
@@ -17,33 +14,6 @@ def owned_or_404(instance, edition_id: uuid.UUID):
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:
+132
View File
@@ -0,0 +1,132 @@
"""Disponibilités déclarées par les bénévoles.
Une disponibilité = « le <date>, je peux de <start_time> à <end_time> ».
Fenêtre horaire libre (pas liée aux créneaux types) ; plusieurs par jour
possibles. `end_time <= start_time` ⇒ la fenêtre se termine le lendemain.
Ces fenêtres sont prises en compte par le moteur de validation (règle
`OUTSIDE_AVAILABILITY`) au moment d'affecter un bénévole à un créneau.
"""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import delete
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 AvailabilityRead, AvailabilitySet
from app.auth.deps import (
current_account_in_edition,
require_coordinator,
require_staff,
)
from app.db import get_session
from app.models import Account, Availability, Edition, Role, Volunteer
router = APIRouter(prefix="/editions/{edition_id}", tags=["availability"])
def _ensure_can_read(account: Account, volunteer_id: uuid.UUID) -> None:
if account.role in (Role.COORDINATOR, Role.LEAD):
return
if account.volunteer_id == volunteer_id:
return
raise HTTPException(status_code=403, detail={"code": "error.forbidden"})
def _windows(session: Session, volunteer_id: uuid.UUID) -> list[Availability]:
return list(
session.exec(
select(Availability)
.where(Availability.volunteer_id == volunteer_id)
.order_by(Availability.date, Availability.start_time)
)
)
@router.get(
"/volunteers/{volunteer_id}/availability",
response_model=list[AvailabilityRead],
)
def list_availability(
volunteer_id: uuid.UUID,
edition: Edition = Depends(get_edition),
account: Account = Depends(current_account_in_edition),
session: Session = Depends(get_session),
) -> list[Availability]:
_ensure_can_read(account, volunteer_id)
owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
return _windows(session, volunteer_id)
@router.put(
"/volunteers/{volunteer_id}/availability",
response_model=list[AvailabilityRead],
)
def replace_availability(
volunteer_id: uuid.UUID,
payload: AvailabilitySet,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> list[Availability]:
owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
for w in payload.windows:
if not edition.start_date <= w.date <= edition.end_date:
raise HTTPException(
status_code=422,
detail={
"code": "availability.date_outside_edition",
"params": {"date": w.date.isoformat()},
},
)
if w.start_time == w.end_time:
raise HTTPException(
status_code=422,
detail={"code": "availability.empty_window"},
)
session.execute(
delete(Availability).where(Availability.volunteer_id == volunteer_id)
)
# Dé-doublonne (date, start, end) : la contrainte d'unicité l'imposerait.
seen: set[tuple] = set()
for w in payload.windows:
key = (w.date, w.start_time, w.end_time)
if key in seen:
continue
seen.add(key)
session.add(
Availability(
edition_id=edition.id,
volunteer_id=volunteer_id,
date=w.date,
start_time=w.start_time,
end_time=w.end_time,
)
)
session.commit()
return _windows(session, volunteer_id)
@router.get("/availability", response_model=list[AvailabilityRead])
def list_edition_availability(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_staff),
session: Session = Depends(get_session),
) -> list[Availability]:
"""Toutes les disponibilités de l'édition (pour la grille des besoins)."""
return list(
session.exec(
select(Availability)
.where(Availability.edition_id == edition.id)
.order_by(
Availability.volunteer_id,
Availability.date,
Availability.start_time,
)
)
)
+1 -1
View File
@@ -94,7 +94,7 @@ def delete_day_part(
session: Session = Depends(get_session),
) -> Response:
day_part = owned_or_404(session.get(DayPart, day_part_id), edition.id)
# Cascade base : disponibilités supprimées, day_part_id des créneaux -> NULL.
# Cascade base : day_part_id des créneaux issus de ce type repasse à NULL.
session.delete(day_part)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
+4 -36
View File
@@ -1,10 +1,8 @@
"""Endpoints d'import : analyse puis exécution."""
import datetime as dt
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from pydantic import ValidationError
from sqlmodel import Session, select
from sqlmodel import Session
from app.api.deps import get_edition
from app.auth.deps import require_coordinator
@@ -12,13 +10,12 @@ from app.db import get_session
from app.importing.parser import ImportParseError, parse_upload
from app.importing.schemas import (
AnalyzeResponse,
DayPartInfo,
ImportMapping,
ImportReport,
PreviewRow,
)
from app.importing.service import run_import, suggest_mapping, validate_mapping
from app.models import Account, DayPart, Edition
from app.models import Account, Edition
router = APIRouter(prefix="/editions/{edition_id}/import", tags=["import"])
@@ -45,27 +42,11 @@ def _is_xlsx(filename: str, content: bytes) -> bool:
)
def _edition_dates(edition: Edition) -> list[dt.date]:
span = (edition.end_date - edition.start_date).days
return [edition.start_date + dt.timedelta(days=i) for i in range(span + 1)]
def _day_parts(session: Session, edition: Edition) -> list[DayPart]:
return list(
session.exec(
select(DayPart)
.where(DayPart.edition_id == edition.id)
.order_by(DayPart.order)
)
)
@router.post("/analyze", response_model=AnalyzeResponse)
async def analyze(
file: UploadFile = File(...),
edition: Edition = Depends(get_edition),
_edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> AnalyzeResponse:
content = await _read_upload(file)
try:
@@ -87,17 +68,6 @@ async def analyze(
row_count=len(table.rows),
preview=preview,
suggested_mapping=suggest_mapping(table.headers),
edition_dates=_edition_dates(edition),
day_parts=[
DayPartInfo(
code=dp.code,
label=dp.label,
start_time=dp.start_time,
end_time=dp.end_time,
order=dp.order,
)
for dp in _day_parts(session, edition)
],
)
@@ -127,9 +97,7 @@ async def execute(
detail={"code": exc.code, "params": exc.params},
) from exc
mapping_errors = validate_mapping(
table, parsed_mapping, edition, _day_parts(session, edition)
)
mapping_errors = validate_mapping(table, parsed_mapping)
if mapping_errors:
raise HTTPException(
status_code=422,
+27 -6
View File
@@ -49,7 +49,6 @@ class VolunteerCreate(ApiModel):
email: str | None = None
phone: str | None = None
comment: str | None = None
skill_codes: list[str] = Field(default_factory=list)
# Vœux du bénévole : artistes à voir, postes souhaités.
artist_ids: list[uuid.UUID] = Field(default_factory=list)
wished_position_ids: list[uuid.UUID] = Field(default_factory=list)
@@ -62,7 +61,6 @@ class VolunteerUpdate(ApiModel):
phone: str | None = None
comment: str | None = None
# None = inchangé ; [] = vider. Idem pour artist_ids / wished_position_ids.
skill_codes: list[str] | None = None
artist_ids: list[uuid.UUID] | None = None
wished_position_ids: list[uuid.UUID] | None = None
@@ -75,7 +73,6 @@ class VolunteerRead(ApiModel):
email: str | None
phone: str | None
comment: str | None
skill_codes: list[str]
artist_ids: list[uuid.UUID]
wished_position_ids: list[uuid.UUID]
created_at: dt.datetime
@@ -141,7 +138,6 @@ class ShiftCreate(ApiModel):
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):
@@ -150,7 +146,6 @@ class ShiftUpdate(ApiModel):
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):
@@ -162,7 +157,6 @@ class ShiftRead(ApiModel):
ends_at: dt.datetime
min_staff: int
ideal_staff: int
required_skill_codes: list[str]
created_at: dt.datetime
updated_at: dt.datetime
@@ -202,6 +196,33 @@ class DayPartUpdate(ApiModel):
order: int | None = None
# --------------------------------------------------------------------------- #
# Availability (disponibilités déclarées par le bénévole) #
# --------------------------------------------------------------------------- #
class AvailabilityWindowIn(ApiModel):
date: dt.date
start_time: dt.time
# end_time <= start_time ⇒ la fenêtre se termine le lendemain.
end_time: dt.time
class AvailabilitySet(ApiModel):
"""Remplace l'intégralité des disponibilités d'un bénévole."""
windows: list[AvailabilityWindowIn] = Field(default_factory=list)
class AvailabilityRead(ApiModel):
id: uuid.UUID
edition_id: uuid.UUID
volunteer_id: uuid.UUID
date: dt.date
start_time: dt.time
end_time: dt.time
created_at: dt.datetime
updated_at: dt.datetime
# --------------------------------------------------------------------------- #
# Assignment #
# --------------------------------------------------------------------------- #
+9 -48
View File
@@ -5,7 +5,7 @@ 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._common import owned_or_404
from app.api.deps import get_edition
from app.api.schemas import ShiftCreate, ShiftRead, ShiftUpdate
from app.auth.deps import (
@@ -15,44 +15,13 @@ from app.auth.deps import (
require_staff,
)
from app.db import get_session
from app.models import (
Account,
Edition,
Position,
Shift,
ShiftRequiredSkill,
Skill,
)
from app.models import Account, Edition, Position, Shift
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 _read(shift: Shift) -> ShiftRead:
return ShiftRead(**shift.model_dump())
def _check_range(starts_at, ends_at) -> None:
@@ -84,16 +53,11 @@ def create_shift(
_check_range(payload.starts_at, payload.ends_at)
position = _require_position(session, payload.position_id, edition.id)
ensure_can_write_position(account, position)
shift = Shift(
edition_id=edition.id,
**payload.model_dump(exclude={"required_skill_codes"}),
)
shift = Shift(edition_id=edition.id, **payload.model_dump())
session.add(shift)
session.flush()
_set_skills(session, shift, payload.required_skill_codes)
session.commit()
session.refresh(shift)
return _read(session, shift)
return _read(shift)
@router.get("", response_model=list[ShiftRead])
@@ -110,7 +74,7 @@ def list_shifts(
)
if position_id is not None:
stmt = stmt.where(Shift.position_id == position_id)
return [_read(session, s) for s in session.exec(stmt)]
return [_read(s) for s in session.exec(stmt)]
@router.get("/{shift_id}", response_model=ShiftRead)
@@ -121,7 +85,7 @@ def get_shift(
session: Session = Depends(get_session),
) -> ShiftRead:
shift = owned_or_404(session.get(Shift, shift_id), edition.id)
return _read(session, shift)
return _read(shift)
@router.patch("/{shift_id}", response_model=ShiftRead)
@@ -135,18 +99,15 @@ def update_shift(
shift = owned_or_404(session.get(Shift, shift_id), edition.id)
ensure_can_write_shift(account, session, shift)
changes = payload.model_dump(exclude_unset=True)
skill_codes = changes.pop("required_skill_codes", None)
if "position_id" in changes:
target = _require_position(session, changes["position_id"], edition.id)
ensure_can_write_position(account, target)
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)
return _read(shift)
@router.delete("/{shift_id}", status_code=status.HTTP_204_NO_CONTENT)
+3 -37
View File
@@ -6,7 +6,7 @@ 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._common import owned_or_404, replace_links
from app.api.deps import get_edition
from app.api.schemas import VolunteerCreate, VolunteerRead, VolunteerUpdate
from app.auth.deps import (
@@ -21,11 +21,9 @@ from app.models import (
Edition,
Position,
Role,
Skill,
Volunteer,
VolunteerArtist,
VolunteerPositionWish,
VolunteerSkill,
)
router = APIRouter(
@@ -41,16 +39,6 @@ def _ensure_can_read(account: Account, volunteer_id: uuid.UUID) -> None:
raise HTTPException(status_code=403, detail={"code": "error.forbidden"})
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 _artist_ids(session: Session, volunteer_id: uuid.UUID) -> list[uuid.UUID]:
return list(
session.exec(
@@ -81,27 +69,11 @@ def _wished_position_ids(
def _read(session: Session, volunteer: Volunteer) -> VolunteerRead:
return VolunteerRead(
**volunteer.model_dump(),
skill_codes=_skill_codes(session, volunteer.id),
artist_ids=_artist_ids(session, volunteer.id),
wished_position_ids=_wished_position_ids(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
],
)
def _resolve_owned_ids(
session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID], code: str
) -> list[uuid.UUID]:
@@ -168,9 +140,7 @@ def create_volunteer(
) -> VolunteerRead:
volunteer = Volunteer(
edition_id=edition.id,
**payload.model_dump(
exclude={"skill_codes", "artist_ids", "wished_position_ids"}
),
**payload.model_dump(exclude={"artist_ids", "wished_position_ids"}),
)
session.add(volunteer)
try:
@@ -180,7 +150,6 @@ def create_volunteer(
raise HTTPException(
status_code=409, detail={"code": "volunteer.duplicate_email"}
) from exc
_set_skills(session, volunteer, payload.skill_codes)
_set_artists(session, volunteer, payload.artist_ids)
_set_wished_positions(session, volunteer, payload.wished_position_ids)
session.commit()
@@ -224,7 +193,6 @@ def update_volunteer(
) -> 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)
artist_ids = changes.pop("artist_ids", None)
wished_position_ids = changes.pop("wished_position_ids", None)
for field, value in changes.items():
@@ -236,8 +204,6 @@ def update_volunteer(
raise HTTPException(
status_code=409, detail={"code": "volunteer.duplicate_email"}
) from exc
if skill_codes is not None:
_set_skills(session, volunteer, skill_codes)
if artist_ids is not None:
_set_artists(session, volunteer, artist_ids)
if wished_position_ids is not None:
@@ -256,7 +222,7 @@ def delete_volunteer(
) -> 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.
# affectations, vœux et compte éventuel.
session.delete(volunteer)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
+4 -14
View File
@@ -24,40 +24,30 @@
"account.volunteer_wrong_edition": "Le bénévole n'appartient pas à cette édition.",
"account.password_required": "Un compte coordination requiert un mot de passe d'au moins {min} caractères.",
"account.password_not_allowed": "Un compte référent ou bénévole se connecte par lien magique, sans mot de passe.",
"import.error.empty_file": "Le fichier est vide.",
"import.error.encoding": "L'encodage du fichier n'est pas reconnu.",
"import.error.unreadable_file": "Fichier illisible ou format non pris en charge.",
"import.error.file_too_large": "Le fichier est trop volumineux.",
"import.mapping.invalid": "Le paramétrage des colonnes est invalide.",
"import.mapping.required_field_missing": "Le champ « {field} » doit être associé à une colonne.",
"import.mapping.column_not_found": "La colonne « {column} » est absente du fichier.",
"import.mapping.unknown_day_part": "Demi-journée « {day_part_code} » inconnue pour cette édition (colonne « {column} »).",
"import.mapping.date_outside_edition": "La date {date} de la colonne « {column} » est hors des dates de l'édition.",
"import.mapping.duplicate_availability_column": "La colonne « {column} » est associée plusieurs fois.",
"import.row.missing_required_field": "Champ obligatoire manquant : {field}.",
"import.row.invalid_email": "Adresse email invalide : {value}.",
"import.row.duplicate_email_in_file": "Email déjà présent à la ligne {first_line} du fichier.",
"import.row.duplicate_name_in_file": "Nom et prénom déjà présents à la ligne {first_line} du fichier.",
"import.row.updated_existing": "Bénévole existant mis à jour.",
"import.row.possible_duplicate_name": "Un bénévole portant le même nom existe déjà dans l'édition.",
"import.row.skill_created": "Nouvelle compétence créée : {label}.",
"import.row.unknown_availability_value": "Valeur de disponibilité non comprise « {value} » (colonne « {column} »).",
"validation.SHIFT_OVERLAP": "Chevauchement avec un autre créneau du bénévole.",
"validation.OUTSIDE_AVAILABILITY": "Hors des disponibilités déclarées ({date}, {day_part_code}).",
"validation.OUTSIDE_AVAILABILITY": "Hors des disponibilités déclarées le {date} ({uncovered_minutes} min non couvertes).",
"validation.DAILY_HOURS_EXCEEDED": "Plus de 8 h d'affectation cumulées le {date}.",
"validation.INSUFFICIENT_REST": "Moins de 8 h de repos entre deux créneaux.",
"validation.MISSING_REQUIRED_SKILL": "Compétence requise absente : {skill_code}.",
"notification.no_recipients": "Aucun bénévole ciblé.",
"email.magic_link.subject": "Ton lien de connexion",
"email.magic_link.body": "Bonjour,\n\nVoici ton lien de connexion, valable {minutes} minutes :\n\n{link}\n\nSi tu n'es pas à l'origine de cette demande, ignore simplement cet email.",
"email.planning.default_subject": "Ton planning bénévole",
"email.planning.default_body": "Bonjour {prenom},\n\nTon planning a été mis à jour : {nb_creneaux} créneau(x), soit {nb_heures} h au total.\n\n{creneaux}\n\nÀ très vite sur le festival !",
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
"email.planning.shift_line": "- {position} : {start} → {end}"
"email.planning.shift_line": "- {position} : {start} → {end}",
"availability.date_outside_edition": "La date {date} est hors des dates de l'édition.",
"availability.empty_window": "Une disponibilité doit avoir une heure de début et de fin différentes."
}
-23
View File
@@ -4,7 +4,6 @@ Le rapport ne contient que des **codes** de traduction + des paramètres
structurés, jamais de phrase (voir `locales/fr.json`).
"""
import datetime as dt
import uuid
from typing import Literal
@@ -20,21 +19,11 @@ class ColumnMapping(BaseModel):
email: str | None = None
phone: str | None = None
comment: str | None = None
skills: str | None = None
class AvailabilityColumnMapping(BaseModel):
header: str
date: dt.date
day_part_code: str
class ImportMapping(BaseModel):
dry_run: bool = True
columns: ColumnMapping
availability_columns: list[AvailabilityColumnMapping] = Field(
default_factory=list
)
class Issue(BaseModel):
@@ -57,8 +46,6 @@ class ImportReport(BaseModel):
created: int = 0
updated: int = 0
skipped: int = 0
availabilities_written: int = 0
skills_created: list[str] = Field(default_factory=list)
rows: list[RowReport] = Field(default_factory=list)
@@ -67,14 +54,6 @@ class PreviewRow(BaseModel):
values: dict[str, str]
class DayPartInfo(BaseModel):
code: str
label: str
start_time: dt.time
end_time: dt.time
order: int
class AnalyzeResponse(BaseModel):
filename: str
format: Literal["csv", "xlsx"]
@@ -82,5 +61,3 @@ class AnalyzeResponse(BaseModel):
row_count: int
preview: list[PreviewRow]
suggested_mapping: dict[str, str]
edition_dates: list[dt.date]
day_parts: list[DayPartInfo]
+9 -153
View File
@@ -1,4 +1,8 @@
"""Cœur de l'import : suggestion de mapping, validation, exécution."""
"""Cœur de l'import : suggestion de mapping, validation, exécution.
L'import ne porte que l'identité des bénévoles (nom, email, téléphone,
commentaire). Les disponibilités se déclarent dans l'application.
"""
import re
@@ -11,16 +15,8 @@ from app.importing.schemas import (
Issue,
RowReport,
)
from app.importing.text import normalize, slug_code, split_skills
from app.importing.values import parse_availability_cell
from app.models import (
Availability,
DayPart,
Edition,
Skill,
Volunteer,
VolunteerSkill,
)
from app.importing.text import normalize
from app.models import Edition, Volunteer
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
@@ -30,7 +26,6 @@ _FIELD_HINTS: dict[str, tuple[str, ...]] = {
"last_name": ("nom", "last name", "lastname", "surname", "famille"),
"phone": ("tel", "phone", "portable", "mobile", "gsm"),
"comment": ("commentaire", "remarque", "note", "precision"),
"skills": ("competence", "skill", "habilitation", "brevet", "diplome"),
}
@@ -50,10 +45,7 @@ def suggest_mapping(headers: list[str]) -> dict[str, str]:
def validate_mapping(
table: ParsedTable,
mapping: ImportMapping,
edition: Edition,
day_parts: list[DayPart],
table: ParsedTable, mapping: ImportMapping
) -> list[Issue]:
"""Contrôles préalables. Retourne une liste vide si le mapping est correct."""
errors: list[Issue] = []
@@ -80,7 +72,7 @@ def validate_mapping(
)
)
for field in ("email", "phone", "comment", "skills"):
for field in ("email", "phone", "comment"):
header = getattr(columns, field)
if header and header not in headers:
errors.append(
@@ -91,50 +83,6 @@ def validate_mapping(
params={"column": header},
)
)
valid_codes = {dp.code for dp in day_parts}
seen: set[str] = set()
for column in mapping.availability_columns:
if column.header not in headers:
errors.append(
Issue(
code="import.mapping.column_not_found",
severity="ERROR",
field="availability",
params={"column": column.header},
)
)
if column.header in seen:
errors.append(
Issue(
code="import.mapping.duplicate_availability_column",
severity="ERROR",
params={"column": column.header},
)
)
seen.add(column.header)
if column.day_part_code not in valid_codes:
errors.append(
Issue(
code="import.mapping.unknown_day_part",
severity="ERROR",
params={
"column": column.header,
"day_part_code": column.day_part_code,
},
)
)
if not edition.start_date <= column.date <= edition.end_date:
errors.append(
Issue(
code="import.mapping.date_outside_edition",
severity="ERROR",
params={
"column": column.header,
"date": column.date.isoformat(),
},
)
)
return errors
@@ -150,12 +98,6 @@ def run_import(
Le mapping est supposé déjà validé par `validate_mapping`.
"""
columns = mapping.columns
day_part_by_code = {
dp.code: dp
for dp in session.exec(
select(DayPart).where(DayPart.edition_id == edition.id)
)
}
existing = list(
session.exec(
@@ -169,14 +111,6 @@ def run_import(
key = (normalize(volunteer.last_name), normalize(volunteer.first_name))
by_name.setdefault(key, volunteer)
skills_by_code = {
s.code: s
for s in session.exec(
select(Skill).where(Skill.edition_id == edition.id)
)
}
created_skills: set[str] = set()
report = ImportReport(dry_run=mapping.dry_run, total_rows=len(table.rows))
email_seen: dict[str, int] = {}
name_seen: dict[tuple[str, str], int] = {}
@@ -284,82 +218,6 @@ def run_import(
session.flush()
if columns.skills:
linked = set(
session.exec(
select(VolunteerSkill.skill_id).where(
VolunteerSkill.volunteer_id == volunteer.id
)
)
)
for token in split_skills(row.get(columns.skills) or ""):
code = slug_code(token)
if not code:
continue
skill = skills_by_code.get(code)
if skill is None:
skill = Skill(
edition_id=edition.id, code=code, label=token.strip()
)
session.add(skill)
session.flush()
skills_by_code[code] = skill
created_skills.add(code)
entry.issues.append(
Issue(
code="import.row.skill_created",
severity="WARNING",
params={"code": code, "label": skill.label},
)
)
if skill.id not in linked:
session.add(
VolunteerSkill(
volunteer_id=volunteer.id, skill_id=skill.id
)
)
linked.add(skill.id)
for column in mapping.availability_columns:
status, recognized = parse_availability_cell(
row.get(column.header, "") or ""
)
if not recognized:
entry.issues.append(
Issue(
code="import.row.unknown_availability_value",
severity="WARNING",
field=column.header,
params={
"column": column.header,
"value": (row.get(column.header, "") or "").strip(),
},
)
)
continue
if status is None:
continue
day_part = day_part_by_code[column.day_part_code]
current = session.exec(
select(Availability)
.where(Availability.volunteer_id == volunteer.id)
.where(Availability.date == column.date)
.where(Availability.day_part_id == day_part.id)
).first()
if current is not None:
current.status = status
else:
session.add(
Availability(
edition_id=edition.id,
volunteer_id=volunteer.id,
date=column.date,
day_part_id=day_part.id,
status=status,
)
)
report.availabilities_written += 1
entry.volunteer_id = volunteer.id
report.rows.append(entry)
if entry.action == "CREATED":
@@ -367,8 +225,6 @@ def run_import(
else:
report.updated += 1
report.skills_created = sorted(created_skills)
if mapping.dry_run:
session.rollback()
else:
+3 -8
View File
@@ -1,9 +1,8 @@
"""Normalisation de texte partagée par l'import (accents, casse, découpage)."""
"""Normalisation de texte partagée par l'import (accents, casse)."""
import re
import unicodedata
_SKILL_SEPARATORS = re.compile(r"[;,/\n]+")
_MULTI_UNDERSCORE = re.compile(r"_+")
@@ -15,12 +14,8 @@ def normalize(value: str) -> str:
def slug_code(value: str) -> str:
"""Code stable pour une compétence : MAJUSCULES, séparateurs en `_`."""
"""Code stable (MAJUSCULES, séparateurs en `_`) — utilisé pour les
créneaux types (`DayPart.code`)."""
base = normalize(value).upper()
chars = [c if c.isalnum() else "_" for c in base]
return _MULTI_UNDERSCORE.sub("_", "".join(chars)).strip("_")
def split_skills(value: str) -> list[str]:
parts = (p.strip() for p in _SKILL_SEPARATORS.split(value or ""))
return [p for p in parts if p]
-38
View File
@@ -1,38 +0,0 @@
"""Interprétation des valeurs de cellule : disponibilités."""
from app.importing.text import normalize
from app.models import AvailabilityStatus
_AVAILABLE = {
"x", "o", "oui", "y", "yes", "ok", "1", "vrai", "true",
"dispo", "disponible", "present", "presente",
"matin", "aprem", "apres-midi", "apres midi", "midi", "soir", "soiree",
"journee", "jour",
}
_IF_NEEDED = {
"si besoin", "sib", "peut-etre", "peut etre", "?", "2",
"backup", "reserve", "au besoin",
}
_UNAVAILABLE = {
"non", "n", "no", "0", "faux", "false", "indispo", "indisponible", "absent",
"-", "x-",
}
_EMPTY = {"", "na", "n/a", "nc"}
def parse_availability_cell(value: str) -> tuple[AvailabilityStatus | None, bool]:
"""(statut, reconnu).
- statut `None` + reconnu `True` → cellule vide, pas de déclaration.
- statut `None` + reconnu `False` → valeur non comprise, à signaler.
"""
norm = normalize(value)
if norm in _EMPTY:
return None, True
if norm in _AVAILABLE:
return AvailabilityStatus.AVAILABLE, True
if norm in _IF_NEEDED:
return AvailabilityStatus.IF_NEEDED, True
if norm in _UNAVAILABLE:
return AvailabilityStatus.UNAVAILABLE, True
return None, False
+2
View File
@@ -5,6 +5,7 @@ from app.api.accounts import router as account_router
from app.api.artists import router as artist_router
from app.api.assignments import router as assignment_router
from app.api.auth import router as auth_router
from app.api.availability import router as availability_router
from app.api.day_parts import router as day_part_router
from app.api.editions import router as edition_router
from app.api.health import router as health_router
@@ -39,6 +40,7 @@ app.include_router(account_router)
app.include_router(import_router)
app.include_router(artist_router)
app.include_router(volunteer_router)
app.include_router(availability_router)
app.include_router(position_router)
app.include_router(shift_router)
app.include_router(assignment_router)
@@ -0,0 +1,155 @@
"""Retrait des compétences ; disponibilités en fenêtres horaires libres
Revision ID: 0007_drop_skills_avail_windows
Revises: 0006_artist_and_volunteer_wishes
Create Date: 2026-09-10
- Suppression des tables `skill`, `volunteer_skill`, `shift_required_skill`.
- `availability` : on abandonne (`day_part_id`, `status`) au profit de
(`start_time`, `end_time`) — fenêtre horaire libre, plusieurs par jour.
La table est recréée (les anciennes lignes ne sont pas convertibles).
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0007_drop_skills_avail_windows"
down_revision: str | None = "0006_artist_and_volunteer_wishes"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_STATUS = sa.Enum(
"AVAILABLE", "IF_NEEDED", "UNAVAILABLE",
name="availabilitystatus", native_enum=False,
)
def upgrade() -> None:
op.drop_table("shift_required_skill")
op.drop_table("volunteer_skill")
op.drop_index("ix_skill_edition_id", table_name="skill")
op.drop_table("skill")
op.drop_table("availability")
op.create_table(
"availability",
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("edition_id", sa.Uuid(), nullable=False),
sa.Column("volunteer_id", sa.Uuid(), nullable=False),
sa.Column("date", sa.Date(), nullable=False),
sa.Column("start_time", sa.Time(), nullable=False),
sa.Column("end_time", sa.Time(), nullable=False),
sa.ForeignKeyConstraint(
["edition_id"], ["edition.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["volunteer_id"], ["volunteer.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"volunteer_id", "date", "start_time", "end_time",
name="uq_availability_window",
),
)
op.create_index(
op.f("ix_availability_edition_id"),
"availability",
["edition_id"],
unique=False,
)
op.create_index(
op.f("ix_availability_volunteer_id"),
"availability",
["volunteer_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index(op.f("ix_availability_volunteer_id"), table_name="availability")
op.drop_index(op.f("ix_availability_edition_id"), table_name="availability")
op.drop_table("availability")
op.create_table(
"availability",
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("edition_id", sa.Uuid(), nullable=False),
sa.Column("volunteer_id", sa.Uuid(), nullable=False),
sa.Column("date", sa.Date(), nullable=False),
sa.Column("day_part_id", sa.Uuid(), nullable=False),
sa.Column("status", _STATUS, nullable=False),
sa.ForeignKeyConstraint(
["edition_id"], ["edition.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["volunteer_id"], ["volunteer.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["day_part_id"], ["day_part.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"volunteer_id", "date", "day_part_id", name="uq_availability_slot"
),
)
op.create_index(
op.f("ix_availability_edition_id"),
"availability", ["edition_id"], unique=False,
)
op.create_index(
op.f("ix_availability_volunteer_id"),
"availability", ["volunteer_id"], unique=False,
)
op.create_index(
op.f("ix_availability_day_part_id"),
"availability", ["day_part_id"], unique=False,
)
op.create_table(
"skill",
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("edition_id", sa.Uuid(), nullable=False),
sa.Column("code", sa.String(), nullable=False),
sa.Column("label", sa.String(), nullable=False),
sa.ForeignKeyConstraint(
["edition_id"], ["edition.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"edition_id", "code", name="uq_skill_edition_code"
),
)
op.create_index(
op.f("ix_skill_edition_id"), "skill", ["edition_id"], unique=False
)
op.create_table(
"volunteer_skill",
sa.Column("volunteer_id", sa.Uuid(), nullable=False),
sa.Column("skill_id", sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(
["skill_id"], ["skill.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["volunteer_id"], ["volunteer.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("volunteer_id", "skill_id"),
)
op.create_table(
"shift_required_skill",
sa.Column("shift_id", sa.Uuid(), nullable=False),
sa.Column("skill_id", sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(
["shift_id"], ["shift.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["skill_id"], ["skill.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("shift_id", "skill_id"),
)
-7
View File
@@ -15,16 +15,12 @@ from app.models.domain import (
MagicLinkToken,
Position,
Shift,
ShiftRequiredSkill,
Skill,
Volunteer,
VolunteerArtist,
VolunteerPositionWish,
VolunteerSkill,
)
from app.models.enums import (
AssignmentSource,
AvailabilityStatus,
PositionCategory,
Role,
)
@@ -35,7 +31,6 @@ __all__ = [
"Assignment",
"AssignmentSource",
"Availability",
"AvailabilityStatus",
"DayPart",
"Edition",
"MagicLinkToken",
@@ -43,8 +38,6 @@ __all__ = [
"PositionCategory",
"Role",
"Shift",
"ShiftRequiredSkill",
"Skill",
"TimestampMixin",
"Volunteer",
"VolunteerArtist",
+11 -73
View File
@@ -16,7 +16,6 @@ from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
from app.models.base import TimestampMixin
from app.models.enums import (
AssignmentSource,
AvailabilityStatus,
PositionCategory,
Role,
)
@@ -27,28 +26,6 @@ _PASSIVE = {"passive_deletes": True}
# --------------------------------------------------------------------------- #
# Tables d'association (sans champs d'audit) #
# --------------------------------------------------------------------------- #
class VolunteerSkill(SQLModel, table=True):
__tablename__ = "volunteer_skill"
volunteer_id: uuid.UUID = Field(
foreign_key="volunteer.id", primary_key=True, ondelete="CASCADE"
)
skill_id: uuid.UUID = Field(
foreign_key="skill.id", primary_key=True, ondelete="CASCADE"
)
class ShiftRequiredSkill(SQLModel, table=True):
__tablename__ = "shift_required_skill"
shift_id: uuid.UUID = Field(
foreign_key="shift.id", primary_key=True, ondelete="CASCADE"
)
skill_id: uuid.UUID = Field(
foreign_key="skill.id", primary_key=True, ondelete="CASCADE"
)
class VolunteerArtist(SQLModel, table=True):
"""Vœu d'un bénévole : « je veux voir cet artiste »."""
@@ -114,38 +91,11 @@ class DayPart(TimestampMixin, table=True):
)
edition: Edition = Relationship(back_populates="day_parts")
availabilities: list["Availability"] = Relationship(
back_populates="day_part", sa_relationship_kwargs=_PASSIVE
)
# --------------------------------------------------------------------------- #
# Personnes : bénévoles, compétences, comptes #
# Personnes : bénévoles, comptes #
# --------------------------------------------------------------------------- #
class Skill(TimestampMixin, table=True):
__table_args__ = (
UniqueConstraint("edition_id", "code", name="uq_skill_edition_code"),
)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
edition_id: uuid.UUID = Field(
foreign_key="edition.id", index=True, ondelete="CASCADE"
)
code: str
label: str
volunteers: list["Volunteer"] = Relationship(
back_populates="skills",
link_model=VolunteerSkill,
sa_relationship_kwargs=_PASSIVE,
)
shifts_requiring: list["Shift"] = Relationship(
back_populates="required_skills",
link_model=ShiftRequiredSkill,
sa_relationship_kwargs=_PASSIVE,
)
class Artist(TimestampMixin, table=True):
"""Artiste programmé sur l'édition.
@@ -194,11 +144,6 @@ class Volunteer(TimestampMixin, table=True):
phone: str | None = None
comment: str | None = None
skills: list[Skill] = Relationship(
back_populates="volunteers",
link_model=VolunteerSkill,
sa_relationship_kwargs=_PASSIVE,
)
wished_artists: list[Artist] = Relationship(
back_populates="fans",
link_model=VolunteerArtist,
@@ -319,25 +264,25 @@ class Shift(TimestampMixin, table=True):
)
position: Position = Relationship(back_populates="shifts")
required_skills: list[Skill] = Relationship(
back_populates="shifts_requiring",
link_model=ShiftRequiredSkill,
sa_relationship_kwargs=_PASSIVE,
)
assignments: list["Assignment"] = Relationship(
back_populates="shift", sa_relationship_kwargs=_PASSIVE
)
class Availability(TimestampMixin, table=True):
"""Déclaration du bénévole : « je peux venir tel jour, telle demi-journée ».
"""Déclaration du bénévole : « tel jour, je peux venir de telle à telle
heure ». Fenêtre horaire libre (pas liée aux créneaux types). Plusieurs
fenêtres par jour possibles.
À ne pas confondre avec Assignment (décision de l'organisation).
Heures naïves, locales Europe/Paris. `end_time <= start_time` ⇒ la fenêtre
passe minuit (se termine le lendemain). À ne pas confondre avec Assignment
(décision de l'organisation).
"""
__table_args__ = (
UniqueConstraint(
"volunteer_id", "date", "day_part_id", name="uq_availability_slot"
"volunteer_id", "date", "start_time", "end_time",
name="uq_availability_window",
),
)
@@ -349,17 +294,10 @@ class Availability(TimestampMixin, table=True):
foreign_key="volunteer.id", index=True, ondelete="CASCADE"
)
date: dt.date
day_part_id: uuid.UUID = Field(
foreign_key="day_part.id", index=True, ondelete="CASCADE"
)
status: AvailabilityStatus = Field(
sa_column=Column(
SAEnum(AvailabilityStatus, native_enum=False), nullable=False
)
)
start_time: dt.time
end_time: dt.time
volunteer: Volunteer = Relationship(back_populates="availabilities")
day_part: DayPart = Relationship(back_populates="availabilities")
class Assignment(TimestampMixin, table=True):
-6
View File
@@ -13,12 +13,6 @@ class Role(enum.StrEnum):
VOLUNTEER = "VOLUNTEER"
class AvailabilityStatus(enum.StrEnum):
AVAILABLE = "AVAILABLE"
IF_NEEDED = "IF_NEEDED"
UNAVAILABLE = "UNAVAILABLE"
class AssignmentSource(enum.StrEnum):
SOLVER = "SOLVER"
MANUAL = "MANUAL"
+55 -148
View File
@@ -11,6 +11,7 @@ import random
from collections import Counter
from dataclasses import dataclass, field
from sqlalchemy import delete
from sqlmodel import Session, select
from app.auth.passwords import hash_password
@@ -21,27 +22,22 @@ from app.models import (
Assignment,
AssignmentSource,
Availability,
AvailabilityStatus,
DayPart,
Edition,
Position,
PositionCategory,
Role,
Shift,
ShiftRequiredSkill,
Skill,
Volunteer,
VolunteerSkill,
)
from app.validation import (
AvailabilityDeclaration,
AvailabilityWindow,
DayPartWindow,
Severity,
ShiftInfo,
ValidationInput,
validate,
)
from app.validation.engine import covered_slots
from app.validation.loader import build_validation_input
SEED = 20260709
@@ -136,19 +132,8 @@ LAST_NAMES = [
"Nicolas",
]
SKILLS: list[tuple[str, str]] = [
("SST", "Sauveteur secouriste du travail"),
("PERMIS_B", "Permis B"),
("REGIE_SON", "Régie son"),
("REGIE_LUM", "Régie lumière"),
("CACES", "CACES nacelle"),
("ELEC", "Habilitation électrique"),
("EN", "Anglais courant"),
("ES", "Espagnol courant"),
]
_C = PositionCategory
# (nom, catégorie, lieu, compétences requises sur ses créneaux)
# (nom, catégorie, lieu, _ inutilisé)
POSITIONS: list[tuple[str, PositionCategory, str, list[str]]] = [
("Bar central", _C.BAR, "Chapiteau", []),
("Bar scène", _C.BAR, "Grande scène", []),
@@ -291,11 +276,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
dp_windows = tuple(
DayPartWindow(dp.code, dp.start_time, dp.end_time, dp.order) for dp in day_parts
)
dp_by_code = {dp.code: dp for dp in day_parts}
skills = {code: Skill(edition_id=edition.id, code=code, label=label) for code, label in SKILLS}
session.add_all(skills.values())
session.flush()
coordination = Account(
edition_id=edition.id,
@@ -337,7 +317,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
}
volunteers: list[Volunteer] = []
vol_skill_codes: dict[object, set[str]] = {}
used_names: set[tuple[str, str]] = set()
for i in range(120):
while True:
@@ -366,45 +345,29 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
volunteer_id=volunteer.id,
)
)
count = rng.choices([0, 1, 2, 3], weights=[0.3, 0.34, 0.24, 0.12])[0]
codes = set(rng.sample(list(skills), k=count))
# Le permis B est courant : on en garantit chez ~40 % des bénévoles
# pour que le parking soit gréable.
if i % 5 in (0, 1):
codes.add("PERMIS_B")
for code in codes:
session.add(VolunteerSkill(volunteer_id=volunteer.id, skill_id=skills[code].id))
vol_skill_codes[volunteer.id] = codes
availabilities: dict[object, dict[tuple[dt.date, str], AvailabilityStatus]] = {}
# Disponibilités : chaque bénévole déclare, pour ~80 % des jours, une
# large fenêtre couvrant la journée et la nuit (06:00 → 04:00 le lendemain).
_FULL_DAY = (dt.time(6, 0), dt.time(4, 0))
availabilities: dict[object, list[AvailabilityWindow]] = {}
for volunteer in volunteers:
declared: dict[tuple[dt.date, str], AvailabilityStatus] = {}
windows: list[AvailabilityWindow] = []
for day in dates:
for dp in day_parts:
if rng.random() < 0.72:
status = rng.choices(
[
AvailabilityStatus.AVAILABLE,
AvailabilityStatus.IF_NEEDED,
AvailabilityStatus.UNAVAILABLE,
],
weights=[0.78, 0.14, 0.08],
)[0]
if rng.random() < 0.8:
start, end = _FULL_DAY
session.add(
Availability(
edition_id=edition.id,
volunteer_id=volunteer.id,
date=day,
day_part_id=dp.id,
status=status,
start_time=start,
end_time=end,
)
)
declared[(day, dp.code)] = status
availabilities[volunteer.id] = declared
windows.append(AvailabilityWindow(day, start, end))
availabilities[volunteer.id] = windows
shifts: list[Shift] = []
shift_required: dict[object, set[str]] = {}
for position, (_n, category, _l, required) in zip(positions, POSITIONS, strict=True):
for position, (_n, category, _l, _r) in zip(positions, POSITIONS, strict=True):
for day in dates:
for start_hour, end_hour in _WINDOWS[category][phase_of[day]]:
min_staff, ideal_staff = _STAFF[category]
@@ -420,21 +383,13 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
)
session.add(shift)
session.flush()
for code in required:
session.add(ShiftRequiredSkill(shift_id=shift.id, skill_id=skills[code].id))
shifts.append(shift)
shift_required[shift.id] = set(required)
assigned: dict[object, list[Shift]] = {v.id: [] for v in volunteers}
# Trois bénévoles réservés aux scénarios d'avertissement : exclus de
# l'affectation générale pour rester libres.
reserved_unskilled = next(
v for v in volunteers if not vol_skill_codes[v.id]
)
reserved = [reserved_unskilled] + [
v for v in volunteers if v is not reserved_unskilled
][:2]
reserved = volunteers[:3]
reserved_ids = {v.id for v in reserved}
assignable = [v for v in volunteers if v.id not in reserved_ids]
@@ -444,17 +399,12 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
id=str(shift.id),
starts_at=shift.starts_at,
ends_at=shift.ends_at,
required_skill_codes=frozenset(shift_required[shift.id]),
),
volunteer_skill_codes=frozenset(vol_skill_codes[volunteer.id]),
existing_shifts=tuple(
ShiftInfo(id=str(s.id), starts_at=s.starts_at, ends_at=s.ends_at)
for s in assigned[volunteer.id]
),
availabilities=tuple(
AvailabilityDeclaration(day, code, status)
for (day, code), status in availabilities[volunteer.id].items()
),
availabilities=tuple(availabilities[volunteer.id]),
day_parts=dp_windows,
)
return not validate(data)
@@ -490,15 +440,7 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
n_assignments += 1
n_assignments += _add_demo_warnings(
session,
edition,
shifts,
reserved,
assigned,
availabilities,
dp_by_code,
day_parts,
shift_required,
session, edition, shifts, reserved, assigned, availabilities
)
session.flush()
@@ -526,97 +468,65 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
return summary
def _set_availability(session, edition, volunteer, day, day_part, status, availabilities) -> None:
row = session.exec(
select(Availability)
def _clear_day(session, volunteer, day, availabilities) -> None:
session.execute(
delete(Availability)
.where(Availability.volunteer_id == volunteer.id)
.where(Availability.date == day)
.where(Availability.day_part_id == day_part.id)
).first()
if row is not None:
row.status = status
else:
)
availabilities[volunteer.id] = [
w for w in availabilities[volunteer.id] if w.date != day
]
def _declare_window(
session, edition, volunteer, day, start, end, availabilities
) -> None:
session.add(
Availability(
edition_id=edition.id,
volunteer_id=volunteer.id,
date=day,
day_part_id=day_part.id,
status=status,
start_time=start,
end_time=end,
)
)
availabilities[volunteer.id][(day, day_part.code)] = status
availabilities[volunteer.id].append(AvailabilityWindow(day, start, end))
def _add_demo_warnings(
session,
edition,
shifts,
reserved,
assigned,
availabilities,
dp_by_code,
day_parts,
shift_required,
session, edition, shifts, reserved, assigned, availabilities
) -> int:
"""Trois affectations qui déclenchent chacune un avertissement, et rien
d'autre : compétence absente, hors disponibilité, repos insuffisant.
`reserved` = [bénévole sans compétence, deux autres], tenus à l'écart de
l'affectation générale.
"""Quelques affectations qui déclenchent des avertissements (et rien
d'autre) : hors disponibilité, repos insuffisant. `reserved` = trois
bénévoles tenus à l'écart de l'affectation générale.
"""
unskilled, reluctant, tired = reserved
a, b, tired = reserved
added = 0
# 1. MISSING_REQUIRED_SKILL : sur la régie, un bénévole sans compétence.
regie = next(s for s in shifts if "REGIE_SON" in shift_required[s.id])
for day, code in covered_slots(regie, day_parts):
_set_availability(
session,
edition,
unskilled,
day,
dp_by_code[code],
AvailabilityStatus.AVAILABLE,
availabilities,
)
_add_active(session, edition, unskilled, regie, assigned)
# OUTSIDE_AVAILABILITY : deux bénévoles affectés un jour où ils n'ont
# déclaré aucune disponibilité.
plain = sorted(shifts, key=lambda s: s.starts_at)
for volunteer, shift in ((a, plain[3]), (b, plain[7])):
_clear_day(session, volunteer, shift.starts_at.date(), availabilities)
_add_active(session, edition, volunteer, shift, assigned)
added += 1
# 2. OUTSIDE_AVAILABILITY : un créneau de bar sur une demi-journée déclarée
# indisponible.
bar = next(
s
for s in shifts
if not shift_required[s.id] and (s.ends_at - s.starts_at) <= dt.timedelta(hours=8)
)
for offset, (day, code) in enumerate(covered_slots(bar, day_parts)):
_set_availability(
session,
edition,
reluctant,
day,
dp_by_code[code],
AvailabilityStatus.UNAVAILABLE if offset == 0 else AvailabilityStatus.AVAILABLE,
availabilities,
)
_add_active(session, edition, reluctant, bar, assigned)
added += 1
# 3. INSUFFICIENT_REST : deux créneaux sans compétence espacés de moins de
# 8 h, sur deux journées (pour ne pas déclencher le cumul).
pair = _find_rest_pair(shifts, shift_required)
# INSUFFICIENT_REST : deux créneaux espacés de moins de 8 h, sur deux
# journées (pour ne pas déclencher le cumul), tous deux couverts par une
# disponibilité.
pair = _find_rest_pair(shifts)
if pair is not None:
first, second = pair
for shift in (first, second):
for day, code in covered_slots(shift, day_parts):
_set_availability(
_clear_day(session, tired, shift.starts_at.date(), availabilities)
_declare_window(
session,
edition,
tired,
day,
dp_by_code[code],
AvailabilityStatus.AVAILABLE,
shift.starts_at.date(),
dt.time(6, 0),
dt.time(4, 0),
availabilities,
)
_add_active(session, edition, tired, first, assigned)
@@ -626,11 +536,8 @@ def _add_demo_warnings(
return added
def _find_rest_pair(shifts: list[Shift], shift_required: dict) -> tuple[Shift, Shift] | None:
plain = sorted(
(s for s in shifts if not shift_required[s.id]),
key=lambda s: s.starts_at,
)
def _find_rest_pair(shifts: list[Shift]) -> tuple[Shift, Shift] | None:
plain = sorted(shifts, key=lambda s: s.starts_at)
for i, first in enumerate(plain):
for second in plain[i + 1 :]:
gap = second.starts_at - first.ends_at
+2 -2
View File
@@ -5,7 +5,7 @@ Voir CLAUDE.md § 3 pour les règles et la sémantique.
from app.validation.engine import has_blocking, validate
from app.validation.models import (
AvailabilityDeclaration,
AvailabilityWindow,
DayPartWindow,
Severity,
ShiftInfo,
@@ -15,7 +15,7 @@ from app.validation.models import (
)
__all__ = [
"AvailabilityDeclaration",
"AvailabilityWindow",
"DayPartWindow",
"Severity",
"ShiftInfo",
+38 -61
View File
@@ -6,8 +6,8 @@ sortie. Règles et sémantique figées : voir CLAUDE.md § 3.
import datetime as dt
from app.models import AvailabilityStatus
from app.validation.models import (
AvailabilityWindow,
Severity,
ShiftInfo,
ValidationInput,
@@ -24,7 +24,6 @@ def validate(data: ValidationInput) -> list[Violation]:
violations.extend(_check_outside_availability(data))
violations.extend(_check_daily_hours(data))
violations.extend(_check_insufficient_rest(data))
violations.extend(_check_missing_skills(data))
return violations
@@ -58,59 +57,51 @@ def _check_overlap(data: ValidationInput) -> list[Violation]:
# --------------------------------------------------------------------------- #
# OUTSIDE_AVAILABILITY (avertissement) #
# --------------------------------------------------------------------------- #
def covered_slots(
shift: ShiftInfo, day_parts: tuple
) -> list[tuple[dt.date, str]]:
"""Demi-journées (date, code) dont la fenêtre horaire recoupe le créneau."""
if not day_parts:
return []
slots: list[tuple[dt.date, str]] = []
day = shift.starts_at.date() - dt.timedelta(days=1)
last = shift.ends_at.date()
while day <= last:
for part in day_parts:
window_start = dt.datetime.combine(day, part.start_time)
if part.end_time > part.start_time:
window_end = dt.datetime.combine(day, part.end_time)
else:
window_end = dt.datetime.combine(
day + dt.timedelta(days=1), part.end_time
def _window_interval(w: AvailabilityWindow) -> tuple[dt.datetime, dt.datetime]:
start = dt.datetime.combine(w.date, w.start_time)
end_day = w.date if w.end_time > w.start_time else w.date + dt.timedelta(days=1)
return start, dt.datetime.combine(end_day, w.end_time)
def uncovered_minutes(
shift: ShiftInfo, windows: tuple[AvailabilityWindow, ...]
) -> int:
"""Minutes du créneau qui ne tombent dans aucune fenêtre de disponibilité.
Le bénévole peut déclarer plusieurs fenêtres ; on regarde leur union.
"""
cursor = shift.starts_at
intervals = sorted(
(iv for iv in map(_window_interval, windows) if iv[1] > iv[0]),
key=lambda iv: iv[0],
)
if shift.starts_at < window_end and window_start < shift.ends_at:
slots.append((day, part.code))
day += dt.timedelta(days=1)
return slots
gap = dt.timedelta()
for start, end in intervals:
if end <= cursor:
continue
if start > shift.ends_at:
break
if start > cursor:
gap += min(start, shift.ends_at) - cursor
cursor = max(cursor, end)
if cursor >= shift.ends_at:
return int(gap.total_seconds() // 60)
if cursor < shift.ends_at:
gap += shift.ends_at - cursor
return int(gap.total_seconds() // 60)
def _check_outside_availability(data: ValidationInput) -> list[Violation]:
slots = covered_slots(data.candidate_shift, data.day_parts)
if not slots:
missing = uncovered_minutes(data.candidate_shift, data.availabilities)
if missing == 0:
return []
declared = {
(a.date, a.day_part_code): a.status for a in data.availabilities
}
out: list[Violation] = []
for day, code in slots:
status = declared.get((day, code))
if status is None:
out.append(
return [
Violation.of(
ViolationCode.OUTSIDE_AVAILABILITY,
date=day.isoformat(),
day_part_code=code,
status="UNDECLARED",
date=data.candidate_shift.starts_at.date().isoformat(),
uncovered_minutes=missing,
)
)
elif status is AvailabilityStatus.UNAVAILABLE:
out.append(
Violation.of(
ViolationCode.OUTSIDE_AVAILABILITY,
date=day.isoformat(),
day_part_code=code,
status="UNAVAILABLE",
)
)
return out
]
# --------------------------------------------------------------------------- #
@@ -176,17 +167,3 @@ def _check_insufficient_rest(data: ValidationInput) -> list[Violation]:
)
)
return out
# --------------------------------------------------------------------------- #
# MISSING_REQUIRED_SKILL (avertissement) #
# --------------------------------------------------------------------------- #
def _check_missing_skills(data: ValidationInput) -> list[Violation]:
missing = sorted(
data.candidate_shift.required_skill_codes
- data.volunteer_skill_codes
)
return [
Violation.of(ViolationCode.MISSING_REQUIRED_SKILL, skill_code=code)
for code in missing
]
+3 -37
View File
@@ -12,45 +12,16 @@ from app.models import (
DayPart,
Edition,
Shift,
ShiftRequiredSkill,
Skill,
Volunteer,
VolunteerSkill,
)
from app.validation.models import (
AvailabilityDeclaration,
AvailabilityWindow,
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,
@@ -62,7 +33,6 @@ def build_validation_input(
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}
# Toute affectation existante du bénévole compte (une affectation = une
# décision, toujours active).
@@ -80,10 +50,8 @@ def build_validation_input(
)
declarations = tuple(
AvailabilityDeclaration(
date=a.date,
day_part_code=code_by_day_part_id.get(a.day_part_id, ""),
status=a.status,
AvailabilityWindow(
date=a.date, start_time=a.start_time, end_time=a.end_time
)
for a in session.exec(
select(Availability).where(
@@ -97,9 +65,7 @@ def build_validation_input(
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(
+12 -10
View File
@@ -10,8 +10,6 @@ from enum import StrEnum
from pydantic import BaseModel, Field
from app.models import AvailabilityStatus
class Severity(StrEnum):
BLOCKING = "BLOCKING"
@@ -23,7 +21,6 @@ class ViolationCode(StrEnum):
OUTSIDE_AVAILABILITY = "OUTSIDE_AVAILABILITY"
DAILY_HOURS_EXCEEDED = "DAILY_HOURS_EXCEEDED"
INSUFFICIENT_REST = "INSUFFICIENT_REST"
MISSING_REQUIRED_SKILL = "MISSING_REQUIRED_SKILL"
SEVERITY_BY_CODE: dict[ViolationCode, Severity] = {
@@ -31,7 +28,6 @@ SEVERITY_BY_CODE: dict[ViolationCode, Severity] = {
ViolationCode.OUTSIDE_AVAILABILITY: Severity.WARNING,
ViolationCode.DAILY_HOURS_EXCEEDED: Severity.WARNING,
ViolationCode.INSUFFICIENT_REST: Severity.WARNING,
ViolationCode.MISSING_REQUIRED_SKILL: Severity.WARNING,
}
@@ -53,11 +49,13 @@ class ShiftInfo:
id: str
starts_at: dt.datetime
ends_at: dt.datetime
required_skill_codes: frozenset[str] = frozenset()
@dataclass(frozen=True)
class DayPartWindow:
"""Créneau type. Ne sert plus qu'à fixer la borne de « journée » pour le
cumul des 8 h (début du premier créneau type)."""
code: str
start_time: dt.time
# end_time <= start_time ⇒ la demi-journée passe minuit.
@@ -66,10 +64,15 @@ class DayPartWindow:
@dataclass(frozen=True)
class AvailabilityDeclaration:
class AvailabilityWindow:
"""« Le <date>, je peux de <start_time> à <end_time>. »
`end_time <= start_time` ⇒ la fenêtre se termine le lendemain.
"""
date: dt.date
day_part_code: str
status: AvailabilityStatus
start_time: dt.time
end_time: dt.time
@dataclass(frozen=True)
@@ -81,7 +84,6 @@ class ValidationInput:
"""
candidate_shift: ShiftInfo
volunteer_skill_codes: frozenset[str] = frozenset()
existing_shifts: tuple[ShiftInfo, ...] = ()
availabilities: tuple[AvailabilityDeclaration, ...] = ()
availabilities: tuple[AvailabilityWindow, ...] = ()
day_parts: tuple[DayPartWindow, ...] = ()
+13 -47
View File
@@ -5,13 +5,10 @@ import datetime as dt
from app.models import (
Artist,
Availability,
AvailabilityStatus,
Position,
PositionCategory,
Shift,
Skill,
Volunteer,
VolunteerSkill,
)
@@ -36,7 +33,7 @@ def make_artist(
def make_shift(
session, edition, position, *, start, end, min_staff=1, ideal_staff=2, skills=()
session, edition, position, *, start, end, min_staff=1, ideal_staff=2
):
shift = Shift(
edition_id=edition.id,
@@ -47,16 +44,13 @@ def make_shift(
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=()
session, edition, *, first_name="Alex", last_name="Martin", email=None
):
volunteer = Volunteer(
edition_id=edition.id,
@@ -65,65 +59,37 @@ def make_volunteer(
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):
def make_availability(
session, edition, volunteer, *, date, start_time, end_time
):
"""Fenêtre de disponibilité libre. `end_time <= start_time` ⇒ passe minuit."""
row = Availability(
edition_id=edition.id,
volunteer_id=volunteer.id,
day_part_id=day_part.id,
date=date,
status=status,
start_time=start_time,
end_time=end_time,
)
session.add(row)
session.commit()
return row
def declare_available_all_day(session, edition, volunteer, day_parts, date):
for part in day_parts.values():
def declare_available_all_day(session, edition, volunteer, date):
"""Disponible de 00:00 à 23:59 le jour donné (couvre tout un créneau
diurne ; pour la nuit, déclarer une fenêtre dédiée)."""
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)
start_time=dt.time(0, 0),
end_time=dt.time(23, 59),
)
+13 -26
View File
@@ -13,21 +13,21 @@ def _url(edition, suffix=""):
return f"/editions/{edition.id}/assignments{suffix}"
def _setup(session, edition, day_parts, *, available=True, skills=()):
def _setup(session, edition, *, available=True):
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)
volunteer = make_volunteer(session, edition, email="v@ex.fr")
if available:
declare_available_all_day(
session, edition, volunteer, day_parts, dt.date(2026, 7, 10)
session, edition, volunteer, 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)
volunteer, shift = _setup(session, edition)
resp = client.post(
_url(edition),
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
@@ -40,32 +40,19 @@ def test_create_assignment_clean(client, edition, session, day_parts):
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"],
)
# Bénévole non déclaré disponible sur le créneau.
volunteer, shift = _setup(session, edition, available=False)
resp = client.post(
_url(edition),
json={
"volunteer_id": str(volunteer.id),
"shift_id": str(shift_with_skill.id),
},
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.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)
volunteer, shift = _setup(session, edition)
assert client.post(
_url(edition),
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
@@ -94,7 +81,7 @@ def test_create_assignment_blocked_by_overlap(client, edition, session, day_part
def test_create_duplicate_assignment_conflicts(client, edition, session, day_parts):
volunteer, shift = _setup(session, edition, day_parts)
volunteer, shift = _setup(session, edition)
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)
@@ -112,7 +99,7 @@ def test_assignment_wrong_edition_is_422(client, edition, session, day_parts):
session.commit()
session.refresh(other)
stranger = make_volunteer(session, other, email="s@ex.fr")
_, shift = _setup(session, edition, day_parts)
_, shift = _setup(session, edition)
resp = client.post(
_url(edition),
@@ -123,7 +110,7 @@ def test_assignment_wrong_edition_is_422(client, edition, session, day_parts):
def test_validate_endpoint_does_not_persist(client, edition, session, day_parts):
volunteer, shift = _setup(session, edition, day_parts, available=False)
volunteer, shift = _setup(session, edition, available=False)
resp = client.post(
_url(edition, "/validate"),
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
@@ -138,7 +125,7 @@ def test_validate_endpoint_does_not_persist(client, edition, session, day_parts)
def test_patch_locked_keeps_assignment_and_returns_warnings(
client, edition, session, day_parts
):
volunteer, shift = _setup(session, edition, day_parts, available=False)
volunteer, shift = _setup(session, edition, available=False)
aid = client.post(
_url(edition),
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
@@ -153,7 +140,7 @@ def test_patch_locked_keeps_assignment_and_returns_warnings(
def test_delete_assignment(client, edition, session, day_parts):
volunteer, shift = _setup(session, edition, day_parts)
volunteer, shift = _setup(session, edition)
aid = client.post(
_url(edition),
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
+114
View File
@@ -0,0 +1,114 @@
from app.models import Role
from tests.factories import make_volunteer
def _url(edition, volunteer, suffix=""):
return f"/editions/{edition.id}/volunteers/{volunteer.id}/availability{suffix}"
def test_put_then_get_returns_windows_sorted(client, edition, session):
v = make_volunteer(session, edition, email="v@e.fr")
resp = client.put(
_url(edition, v),
json={
"windows": [
{"date": "2026-07-11", "start_time": "14:00", "end_time": "19:00"},
{"date": "2026-07-10", "start_time": "08:00", "end_time": "13:00"},
{"date": "2026-07-10", "start_time": "19:00", "end_time": "02:00"},
]
},
)
assert resp.status_code == 200
got = [(w["date"], w["start_time"], w["end_time"]) for w in resp.json()]
assert got == [
("2026-07-10", "08:00:00", "13:00:00"),
("2026-07-10", "19:00:00", "02:00:00"),
("2026-07-11", "14:00:00", "19:00:00"),
]
assert client.get(_url(edition, v)).json() == resp.json()
def test_put_replaces_the_whole_set_and_dedupes(client, edition, session):
v = make_volunteer(session, edition, email="v@e.fr")
client.put(
_url(edition, v),
json={
"windows": [
{"date": "2026-07-10", "start_time": "08:00", "end_time": "12:00"}
]
},
)
resp = client.put(
_url(edition, v),
json={
"windows": [
{"date": "2026-07-11", "start_time": "09:00", "end_time": "17:00"},
{"date": "2026-07-11", "start_time": "09:00", "end_time": "17:00"},
]
},
)
body = resp.json()
assert len(body) == 1
assert body[0]["date"] == "2026-07-11"
def test_put_rejects_date_outside_edition(client, edition, session):
v = make_volunteer(session, edition, email="v@e.fr")
resp = client.put(
_url(edition, v),
json={
"windows": [
{"date": "2026-08-01", "start_time": "08:00", "end_time": "12:00"}
]
},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "availability.date_outside_edition"
def test_put_rejects_empty_window(client, edition, session):
v = make_volunteer(session, edition, email="v@e.fr")
resp = client.put(
_url(edition, v),
json={
"windows": [
{"date": "2026-07-10", "start_time": "09:00", "end_time": "09:00"}
]
},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "availability.empty_window"
def test_edition_wide_listing(client, edition, session):
v1 = make_volunteer(session, edition, first_name="A", email="a@e.fr")
v2 = make_volunteer(session, edition, first_name="B", email="b@e.fr")
for v in (v1, v2):
client.put(
_url(edition, v),
json={
"windows": [
{"date": "2026-07-10", "start_time": "08:00", "end_time": "13:00"}
]
},
)
body = client.get(f"/editions/{edition.id}/availability").json()
assert {w["volunteer_id"] for w in body} == {str(v1.id), str(v2.id)}
def test_lead_can_read_but_not_write(edition, session, make_account, as_account):
v = make_volunteer(session, edition, email="v@e.fr")
lead = make_account(Role.LEAD, email="lead@e.fr")
api = as_account(lead)
assert api.get(_url(edition, v)).status_code == 200
resp = api.put(_url(edition, v), json={"windows": []})
assert resp.status_code == 403
def test_volunteer_reads_only_their_own(edition, session, make_account, as_account):
me = make_volunteer(session, edition, email="me@e.fr")
other = make_volunteer(session, edition, email="other@e.fr")
account = make_account(Role.VOLUNTEER, volunteer_id=me.id, email="acc@e.fr")
api = as_account(account)
assert api.get(_url(edition, me)).status_code == 200
assert api.get(_url(edition, other)).status_code == 403
+3 -18
View File
@@ -1,9 +1,5 @@
import datetime as dt
from sqlmodel import select
from app.models import Availability, AvailabilityStatus, Role, Shift
from tests.factories import dt_at, make_position, make_shift, make_volunteer
from app.models import Role, Shift
from tests.factories import dt_at, make_position, make_shift
def _url(edition, suffix=""):
@@ -46,29 +42,18 @@ def test_update_day_part(client, edition):
assert resp.json()["end_time"] == "12:00:00"
def test_delete_unlinks_shifts_and_removes_availabilities(
client, edition, session, day_parts
):
def test_delete_sets_shift_day_part_to_null(client, edition, session, day_parts):
morning = day_parts["MORNING"]
position = make_position(session, edition)
shift = make_shift(
session, edition, position, start=dt_at(10, 6), end=dt_at(10, 13)
)
shift.day_part_id = morning.id
volunteer = make_volunteer(session, edition, email="v@e.fr")
session.add(
Availability(
edition_id=edition.id, volunteer_id=volunteer.id,
date=dt.date(2026, 7, 10), day_part_id=morning.id,
status=AvailabilityStatus.AVAILABLE,
)
)
session.commit()
assert client.delete(_url(edition, f"/{morning.id}")).status_code == 204
session.expire_all()
assert session.get(Shift, shift.id).day_part_id is None
assert session.exec(select(Availability)).all() == []
def test_write_is_coordinator_only(edition, make_account, as_account):
+3 -9
View File
@@ -14,18 +14,16 @@ def _payload(position, **overrides):
"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):
def test_create_shift(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)
@@ -75,20 +73,16 @@ def test_list_filter_by_position(client, edition, session):
assert len(client.get(_url(edition)).json()) == 2
def test_patch_shift_times_and_skills(client, edition, session):
def test_patch_shift_times(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"],
},
json={"ends_at": "2026-07-10T23:30:00"},
)
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):
+10 -15
View File
@@ -3,7 +3,7 @@ import uuid
from sqlmodel import select
from app.models import Assignment, Availability, AvailabilityStatus, Volunteer
from app.models import Assignment, Availability, Volunteer
from tests.factories import (
dt_at,
make_artist,
@@ -18,20 +18,20 @@ def _url(edition, suffix=""):
return f"/editions/{edition.id}/volunteers{suffix}"
def test_create_volunteer_with_skills(client, edition):
def test_create_volunteer(client, edition):
resp = client.post(
_url(edition),
json={
"first_name": "Alex",
"last_name": "Martin",
"email": "alex@ex.fr",
"skill_codes": ["SST", "PERMIS_B", "SST"],
},
)
assert resp.status_code == 201
body = resp.json()
assert body["skill_codes"] == ["PERMIS_B", "SST"]
assert body["edition_id"] == str(edition.id)
assert body["artist_ids"] == []
assert body["wished_position_ids"] == []
def test_create_duplicate_email_conflicts(client, edition):
@@ -131,23 +131,18 @@ def test_patch_replaces_and_clears_wishes(client, edition, session):
assert r3.json()["artist_ids"] == []
def test_patch_replaces_skills_and_can_clear(client, edition, session):
def test_patch_updates_simple_fields(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"]},
json={"first_name": "Neo", "comment": "dispo tôt"},
)
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"] == []
assert r1.json()["comment"] == "dispo tôt"
def test_delete_is_a_full_cascade(client, edition, session, day_parts):
def test_delete_is_a_full_cascade(client, edition, session):
volunteer = make_volunteer(session, edition, email="gone@ex.fr")
position = make_position(session, edition)
shift = make_shift(
@@ -157,9 +152,9 @@ def test_delete_is_a_full_cascade(client, edition, session, day_parts):
session,
edition,
volunteer,
day_parts["MORNING"],
date=dt.date(2026, 7, 10),
status=AvailabilityStatus.AVAILABLE,
start_time=dt.time(8, 0),
end_time=dt.time(13, 0),
)
session.add(
Assignment(
+10 -31
View File
@@ -6,9 +6,9 @@ from sqlmodel import select
from app.models import Volunteer
_CSV = (
"Prénom;Nom;Email;Compétences;Ven matin\n"
"Alex;Martin;alex@ex.fr;SST, Permis B;x\n"
"Sam;Nguyen;sam@ex.fr;SST;si besoin\n"
"Prénom;Nom;Email;Téléphone\n"
"Alex;Martin;alex@ex.fr;0600000000\n"
"Sam;Nguyen;sam@ex.fr;\n"
).encode()
@@ -20,20 +20,13 @@ def _mapping(dry_run: bool) -> str:
"first_name": "Prénom",
"last_name": "Nom",
"email": "Email",
"skills": "Compétences",
"phone": "Téléphone",
},
"availability_columns": [
{
"header": "Ven matin",
"date": "2026-07-10",
"day_part_code": "MORNING",
}
],
}
)
def test_analyze_returns_headers_preview_and_day_parts(client, edition):
def test_analyze_returns_headers_preview_and_suggestion(client, edition):
resp = client.post(
f"/editions/{edition.id}/import/analyze",
files={"file": ("benevoles.csv", _CSV, "text/csv")},
@@ -41,16 +34,11 @@ def test_analyze_returns_headers_preview_and_day_parts(client, edition):
assert resp.status_code == 200
body = resp.json()
assert body["format"] == "csv"
assert body["headers"] == ["Prénom", "Nom", "Email", "Compétences", "Ven matin"]
assert body["headers"] == ["Prénom", "Nom", "Email", "Téléphone"]
assert body["row_count"] == 2
assert body["preview"][0]["values"]["Nom"] == "Martin"
assert body["suggested_mapping"]["first_name"] == "Prénom"
assert {d["code"] for d in body["day_parts"]} == {
"MORNING",
"AFTERNOON",
"EVENING",
}
assert body["edition_dates"][0] == "2026-07-09"
assert body["suggested_mapping"]["phone"] == "Téléphone"
def test_execute_dry_run_then_real(client, session, edition):
@@ -69,9 +57,7 @@ def test_execute_dry_run_then_real(client, session, edition):
data={"mapping": _mapping(dry_run=False)},
)
assert real.status_code == 200
report = real.json()
assert report["created"] == 2
assert sorted(report["skills_created"]) == ["PERMIS_B", "SST"]
assert real.json()["created"] == 2
assert len(session.exec(select(Volunteer)).all()) == 2
@@ -79,14 +65,7 @@ def test_execute_rejects_invalid_mapping(client, edition):
bad_mapping = json.dumps(
{
"dry_run": True,
"columns": {"first_name": "Prénom", "last_name": "Nom"},
"availability_columns": [
{
"header": "Ven matin",
"date": "2026-07-10",
"day_part_code": "NIGHT",
}
],
"columns": {"first_name": "Prénom", "last_name": "Absente"},
}
)
resp = client.post(
@@ -98,7 +77,7 @@ def test_execute_rejects_invalid_mapping(client, edition):
detail = resp.json()["detail"]
assert detail["code"] == "import.mapping.invalid"
assert any(
e["code"] == "import.mapping.unknown_day_part" for e in detail["errors"]
e["code"] == "import.mapping.column_not_found" for e in detail["errors"]
)
+22 -74
View File
@@ -1,17 +1,11 @@
import datetime as dt
from sqlmodel import select
from app.importing.parser import ParsedTable
from app.importing.schemas import (
AvailabilityColumnMapping,
ColumnMapping,
ImportMapping,
)
from app.importing.schemas import ColumnMapping, ImportMapping
from app.importing.service import run_import, suggest_mapping, validate_mapping
from app.models import Availability, Skill, Volunteer
from app.models import Volunteer
HEADERS = ["Prénom", "Nom", "Email", "Taille", "Compétences", "Ven matin", "Ven aprem"]
HEADERS = ["Prénom", "Nom", "Email", "Téléphone", "Remarque"]
def _mapping(dry_run: bool = False) -> ImportMapping:
@@ -21,18 +15,9 @@ def _mapping(dry_run: bool = False) -> ImportMapping:
first_name="Prénom",
last_name="Nom",
email="Email",
skills="Compétences",
phone="Téléphone",
comment="Remarque",
),
availability_columns=[
AvailabilityColumnMapping(
header="Ven matin", date=dt.date(2026, 7, 10),
day_part_code="MORNING",
),
AvailabilityColumnMapping(
header="Ven aprem", date=dt.date(2026, 7, 10),
day_part_code="AFTERNOON",
),
],
)
@@ -58,46 +43,36 @@ def test_suggest_mapping_matches_common_headers():
assert suggestion["phone"] == "Numéro de portable"
def test_happy_path_creates_volunteers_skills_and_availabilities(session, edition):
def test_happy_path_creates_volunteers(session, edition):
report = run_import(
session=session,
edition=edition,
table=_table(
[
["Alex", "Martin", "alex@ex.fr", "M", "SST, Permis B", "x", "non"],
["Sam", "Nguyen", "sam@ex.fr", "L", "SST", "si besoin", ""],
["Alex", "Martin", "alex@ex.fr", "0600000000", "arrive tôt"],
["Sam", "Nguyen", "sam@ex.fr", "", ""],
]
),
mapping=_mapping(),
)
assert (report.created, report.updated, report.skipped) == (2, 0, 0)
assert report.skills_created == ["PERMIS_B", "SST"]
assert report.availabilities_written == 3 # x, non, si besoin
volunteers = {v.email: v for v in _volunteers(session, edition)}
assert set(volunteers) == {"alex@ex.fr", "sam@ex.fr"}
assert {s.code for s in volunteers["alex@ex.fr"].skills} == {"SST", "PERMIS_B"}
avails = session.exec(
select(Availability).where(
Availability.volunteer_id == volunteers["alex@ex.fr"].id
)
).all()
assert {a.status.value for a in avails} == {"AVAILABLE", "UNAVAILABLE"}
assert volunteers["alex@ex.fr"].phone == "0600000000"
assert volunteers["alex@ex.fr"].comment == "arrive tôt"
def test_dry_run_persists_nothing(session, edition):
report = run_import(
session=session,
edition=edition,
table=_table([["Alex", "Martin", "alex@ex.fr", "M", "SST", "x", "x"]]),
table=_table([["Alex", "Martin", "alex@ex.fr", "", ""]]),
mapping=_mapping(dry_run=True),
)
assert report.created == 1
assert report.dry_run is True
assert _volunteers(session, edition) == []
assert session.exec(select(Skill)).all() == []
def test_existing_email_is_updated_not_duplicated(session, edition):
@@ -115,7 +90,7 @@ def test_existing_email_is_updated_not_duplicated(session, edition):
report = run_import(
session=session,
edition=edition,
table=_table([["Alex", "Martin", "ALEX@ex.fr", "M", "SST", "x", ""]]),
table=_table([["Alex", "Martin", "ALEX@ex.fr", "", ""]]),
mapping=_mapping(),
)
@@ -133,8 +108,8 @@ def test_duplicate_email_within_file_is_skipped(session, edition):
edition=edition,
table=_table(
[
["Alex", "Martin", "dup@ex.fr", "M", "", "x", ""],
["Alex", "Doublon", "dup@ex.fr", "L", "", "x", ""],
["Alex", "Martin", "dup@ex.fr", "", ""],
["Alex", "Doublon", "dup@ex.fr", "", ""],
]
),
mapping=_mapping(),
@@ -161,7 +136,7 @@ def test_row_without_email_matching_existing_name_warns_but_creates(
report = run_import(
session=session,
edition=edition,
table=_table([["jean", "DUPONT", "", "", "", "x", ""]]),
table=_table([["jean", "DUPONT", "", "", ""]]),
mapping=_mapping(),
)
assert report.created == 1
@@ -173,7 +148,7 @@ def test_missing_required_field_is_skipped(session, edition):
report = run_import(
session=session,
edition=edition,
table=_table([["", "Martin", "x@ex.fr", "", "", "x", ""]]),
table=_table([["", "Martin", "x@ex.fr", "", ""]]),
mapping=_mapping(),
)
assert report.skipped == 1
@@ -185,45 +160,18 @@ def test_invalid_email_is_skipped(session, edition):
report = run_import(
session=session,
edition=edition,
table=_table([["Alex", "Martin", "pas-un-email", "", "", "x", ""]]),
table=_table([["Alex", "Martin", "pas-un-email", "", ""]]),
mapping=_mapping(),
)
assert report.skipped == 1
assert report.rows[0].issues[0].code == "import.row.invalid_email"
def test_unknown_availability_value_is_flagged(session, edition):
report = run_import(
session=session,
edition=edition,
table=_table([["Alex", "Martin", "a@ex.fr", "", "", "bof", "x"]]),
mapping=_mapping(),
)
codes = [i.code for i in report.rows[0].issues]
assert "import.row.unknown_availability_value" in codes
assert report.availabilities_written == 1 # seule "Ven aprem" a été retenue
def test_validate_mapping_reports_bad_day_part_and_date(session, edition):
from app.models import DayPart
day_parts = list(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
)
def test_validate_mapping_reports_missing_and_unknown_columns():
mapping = ImportMapping(
columns=ColumnMapping(first_name="Prénom", last_name="Nom"),
availability_columns=[
AvailabilityColumnMapping(
header="Ven matin", date=dt.date(2026, 7, 10),
day_part_code="NIGHT",
),
AvailabilityColumnMapping(
header="Ven aprem", date=dt.date(2030, 1, 1),
day_part_code="MORNING",
),
],
columns=ColumnMapping(first_name="Prénom", last_name="", email="Absente")
)
errors = validate_mapping(_table([]), mapping, edition, day_parts)
errors = validate_mapping(_table([]), mapping)
codes = {e.code for e in errors}
assert "import.mapping.unknown_day_part" in codes
assert "import.mapping.date_outside_edition" in codes
assert "import.mapping.required_field_missing" in codes
assert "import.mapping.column_not_found" in codes
+12
View File
@@ -0,0 +1,12 @@
from app.importing.text import normalize, slug_code
def test_normalize_strips_accents_case_and_spaces():
assert normalize(" Régie SON ") == "regie son"
assert normalize("") == ""
def test_slug_code_is_stable_across_accents_and_case():
assert slug_code("Régie son") == "REGIE_SON"
assert slug_code(" apres midi ") == "APRES_MIDI"
assert slug_code("SST") == "SST"
-48
View File
@@ -1,48 +0,0 @@
import pytest
from app.importing.text import slug_code, split_skills
from app.importing.values import parse_availability_cell
from app.models import AvailabilityStatus
@pytest.mark.parametrize(
("cell", "expected"),
[
("x", AvailabilityStatus.AVAILABLE),
("OUI", AvailabilityStatus.AVAILABLE),
(" Disponible ", AvailabilityStatus.AVAILABLE),
("si besoin", AvailabilityStatus.IF_NEEDED),
("Peut-être", AvailabilityStatus.IF_NEEDED),
("non", AvailabilityStatus.UNAVAILABLE),
("0", AvailabilityStatus.UNAVAILABLE),
],
)
def test_recognized_availability_values(cell, expected):
status, recognized = parse_availability_cell(cell)
assert recognized is True
assert status is expected
def test_empty_cell_is_recognized_but_no_declaration():
assert parse_availability_cell(" ") == (None, True)
assert parse_availability_cell("N/A") == (None, True)
def test_unknown_cell_is_flagged():
assert parse_availability_cell("bof") == (None, False)
def test_slug_code_is_stable_across_accents_and_case():
assert slug_code("Régie son") == "REGIE_SON"
assert slug_code(" permis B ") == "PERMIS_B"
assert slug_code("SST") == "SST"
def test_split_skills():
assert split_skills("SST, Permis B / régie son\nlangues") == [
"SST",
"Permis B",
"régie son",
"langues",
]
assert split_skills("") == []
+6 -25
View File
@@ -8,30 +8,24 @@ from app.models import (
Assignment,
AssignmentSource,
Availability,
AvailabilityStatus,
DayPart,
Edition,
Position,
PositionCategory,
Shift,
Skill,
Volunteer,
)
EXPECTED_TABLES = {
"edition",
"day_part",
"skill",
"artist",
"volunteer",
"volunteer_skill",
"volunteer_artist",
"volunteer_position_wish",
"account",
"magic_link_token",
"position",
"shift",
"shift_required_skill",
"availability",
"assignment",
}
@@ -65,8 +59,6 @@ def test_create_core_graph(session):
first_name="Alex",
last_name="Martin",
)
skill = Skill(edition_id=edition.id, code="SST", label="Secourisme")
volunteer.skills.append(skill)
position = Position(
edition_id=edition.id, name="Bar principal", category=PositionCategory.BAR
)
@@ -76,9 +68,6 @@ def test_create_core_graph(session):
session.refresh(volunteer)
session.refresh(position)
# Lien N-N bénévole <-> compétence.
assert [s.code for s in volunteer.skills] == ["SST"]
shift = Shift(
edition_id=edition.id,
position_id=position.id,
@@ -107,37 +96,29 @@ def test_create_core_graph(session):
assert volunteer.assignments[0].id == assignment.id
def test_enum_round_trip(session):
def test_availability_window_round_trip(session):
edition = _edition(session)
day_part = DayPart(
edition_id=edition.id,
code="EVENING",
label="Soirée",
start_time=dt.time(19, 0),
end_time=dt.time(6, 0),
order=2,
)
volunteer = Volunteer(
edition_id=edition.id, first_name="Sam", last_name="Nuit"
)
session.add_all([day_part, volunteer])
session.add(volunteer)
session.commit()
session.refresh(day_part)
session.refresh(volunteer)
avail = Availability(
edition_id=edition.id,
volunteer_id=volunteer.id,
date=dt.date(2026, 7, 9),
day_part_id=day_part.id,
status=AvailabilityStatus.IF_NEEDED,
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.status is AvailabilityStatus.IF_NEEDED
assert reloaded.start_time == dt.time(19, 0)
assert reloaded.end_time == dt.time(2, 0)
def test_volunteer_email_unique_per_edition(session):
+50 -96
View File
@@ -3,9 +3,8 @@ règle, plus les cas limites de la sémantique figée (CLAUDE.md § 3)."""
import datetime as dt
from app.models import AvailabilityStatus
from app.validation import (
AvailabilityDeclaration,
AvailabilityWindow,
DayPartWindow,
Severity,
ShiftInfo,
@@ -26,18 +25,20 @@ def _dt(day: int, hour: int, minute: int = 0) -> dt.datetime:
return dt.datetime(2026, 7, day, hour, minute)
def _shift(sid: str, start: dt.datetime, end: dt.datetime, skills=()) -> ShiftInfo:
return ShiftInfo(
id=sid, starts_at=start, ends_at=end, required_skill_codes=frozenset(skills)
)
def _shift(sid: str, start: dt.datetime, end: dt.datetime) -> ShiftInfo:
return ShiftInfo(id=sid, starts_at=start, ends_at=end)
def _codes(violations) -> list[ViolationCode]:
return [v.code for v in violations]
def _avail(day: int, code: str, status: AvailabilityStatus) -> AvailabilityDeclaration:
return AvailabilityDeclaration(dt.date(2026, 7, day), code, status)
def _win(day: int, start_h: int, end_h: int) -> AvailabilityWindow:
"""Fenêtre de dispo le <day> juillet, de start_h:00 à end_h:00.
`end_h <= start_h` ⇒ se termine le lendemain."""
return AvailabilityWindow(
dt.date(2026, 7, day), dt.time(start_h % 24, 0), dt.time(end_h % 24, 0)
)
# --------------------------------------------------------------------------- #
@@ -47,10 +48,7 @@ def test_overlap_pass_when_shifts_are_adjacent():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 22)),),
day_parts=DAY_PARTS,
availabilities=(
_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),
),
availabilities=(_win(10, 8, 23),),
)
assert ViolationCode.SHIFT_OVERLAP not in _codes(validate(data))
@@ -59,6 +57,7 @@ def test_overlap_triggers_and_is_blocking():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),),
availabilities=(_win(10, 8, 23),),
)
violations = validate(data)
overlap = [v for v in violations if v.code is ViolationCode.SHIFT_OVERLAP]
@@ -71,73 +70,68 @@ def test_overlap_triggers_and_is_blocking():
# --------------------------------------------------------------------------- #
# OUTSIDE_AVAILABILITY #
# --------------------------------------------------------------------------- #
def test_availability_pass_when_declared_available():
def test_availability_pass_when_shift_within_declared_window():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
day_parts=DAY_PARTS,
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),),
availabilities=(_win(10, 13, 19),),
)
assert validate(data) == []
def test_availability_pass_when_declared_if_needed():
def test_availability_pass_when_window_exactly_matches_shift():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
day_parts=DAY_PARTS,
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.IF_NEEDED),),
availabilities=(_win(10, 14, 18),),
)
assert validate(data) == []
def test_availability_triggers_when_slot_unavailable():
def test_availability_triggers_when_nothing_declared():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
day_parts=DAY_PARTS,
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.UNAVAILABLE),),
)
violations = validate(data)
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
assert violations[0].severity is Severity.WARNING
assert violations[0].params["status"] == "UNAVAILABLE"
assert violations[0].params["day_part_code"] == "AFTERNOON"
assert violations[0].params["date"] == "2026-07-10"
assert violations[0].params["uncovered_minutes"] == 240
def test_availability_triggers_when_slot_undeclared():
def test_availability_triggers_on_partial_coverage_and_counts_the_gap():
# Créneau 16:00 -> 20:00 ; dispo seulement 13:00 -> 19:00 ⇒ 60 min à découvert.
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
day_parts=DAY_PARTS,
candidate_shift=_shift("c", _dt(10, 16), _dt(10, 20)),
availabilities=(_win(10, 13, 19),),
)
violations = validate(data)
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
assert violations[0].params["status"] == "UNDECLARED"
assert violations[0].params["uncovered_minutes"] == 60
def test_availability_covers_two_slots_and_flags_only_the_missing_one():
# 18:00 -> 22:00 recoupe AFTERNOON (jusqu'à 19:00) et EVENING (dès 19:00).
def test_availability_union_of_several_windows_covers_the_shift():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 18), _dt(10, 22)),
day_parts=DAY_PARTS,
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),),
)
violations = validate(data)
assert len(violations) == 1
assert violations[0].params["day_part_code"] == "EVENING"
def test_availability_midnight_crossing_shift_uses_evening_slot_only():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 22), _dt(11, 2)),
day_parts=DAY_PARTS,
availabilities=(_avail(10, "EVENING", AvailabilityStatus.AVAILABLE),),
candidate_shift=_shift("c", _dt(10, 12), _dt(10, 20)),
availabilities=(_win(10, 12, 16), _win(10, 15, 20)),
)
assert validate(data) == []
def test_availability_rule_skipped_without_day_parts():
def test_availability_midnight_crossing_window_covers_night_shift():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
candidate_shift=_shift("c", _dt(10, 22), _dt(11, 2)),
availabilities=(_win(10, 19, 4),), # 19:00 -> 04:00 le lendemain
)
assert ViolationCode.OUTSIDE_AVAILABILITY not in _codes(validate(data))
assert validate(data) == []
def test_availability_gap_between_two_windows_is_flagged():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 10), _dt(10, 18)),
availabilities=(_win(10, 10, 12), _win(10, 14, 18)), # trou 12:00-14:00
)
violations = validate(data)
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
assert violations[0].params["uncovered_minutes"] == 120
# --------------------------------------------------------------------------- #
@@ -147,10 +141,7 @@ def test_daily_hours_pass_at_exactly_eight():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 9), _dt(10, 17)), # 8 h pile
day_parts=DAY_PARTS,
availabilities=(
_avail(10, "MORNING", AvailabilityStatus.AVAILABLE),
_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),
),
availabilities=(_win(10, 8, 18),),
)
assert ViolationCode.DAILY_HOURS_EXCEEDED not in _codes(validate(data))
@@ -200,8 +191,7 @@ def test_rest_pass_with_more_than_eight_hours_gap():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 8), _dt(10, 12)),
existing_shifts=(_shift("e", _dt(9, 18), _dt(9, 23)),), # écart 9 h
day_parts=DAY_PARTS,
availabilities=(_avail(10, "MORNING", AvailabilityStatus.AVAILABLE),),
availabilities=(_win(10, 8, 12),),
)
assert ViolationCode.INSUFFICIENT_REST not in _codes(validate(data))
@@ -243,81 +233,45 @@ def test_back_to_back_shifts_are_rest_violation_not_overlap():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 22)),),
availabilities=(_win(10, 8, 23),),
)
codes = _codes(validate(data))
assert ViolationCode.SHIFT_OVERLAP not in codes
assert ViolationCode.INSUFFICIENT_REST in codes
# --------------------------------------------------------------------------- #
# MISSING_REQUIRED_SKILL #
# --------------------------------------------------------------------------- #
def test_skill_pass_when_volunteer_has_all_required():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18), skills=["SST"]),
volunteer_skill_codes=frozenset({"SST", "PERMIS_B"}),
day_parts=DAY_PARTS,
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),),
)
assert validate(data) == []
def test_skill_triggers_for_each_missing_code_sorted():
data = ValidationInput(
candidate_shift=_shift(
"c", _dt(10, 14), _dt(10, 18), skills=["SOUND", "SST", "PERMIS_B"]
),
volunteer_skill_codes=frozenset({"SST"}),
day_parts=DAY_PARTS,
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),),
)
violations = validate(data)
assert [v.params["skill_code"] for v in violations] == ["PERMIS_B", "SOUND"]
assert all(v.severity is Severity.WARNING for v in violations)
# --------------------------------------------------------------------------- #
# Combinaisons #
# --------------------------------------------------------------------------- #
def test_clean_assignment_has_no_violation():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18), skills=["SST"]),
volunteer_skill_codes=frozenset({"SST"}),
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
existing_shifts=(_shift("e", _dt(9, 8), _dt(9, 12)),),
day_parts=DAY_PARTS,
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),),
availabilities=(_win(10, 13, 19),),
)
assert validate(data) == []
def test_multiple_rules_fire_together():
data = ValidationInput(
candidate_shift=_shift(
"c", _dt(10, 14), _dt(10, 18), skills=["SOUND"]
),
volunteer_skill_codes=frozenset(),
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),), # chevauchement
day_parts=DAY_PARTS,
availabilities=(
_avail(10, "AFTERNOON", AvailabilityStatus.UNAVAILABLE),
),
# aucune dispo déclarée ⇒ OUTSIDE_AVAILABILITY
)
codes = set(_codes(validate(data)))
assert {
ViolationCode.SHIFT_OVERLAP,
ViolationCode.OUTSIDE_AVAILABILITY,
ViolationCode.MISSING_REQUIRED_SKILL,
} <= codes
assert has_blocking(validate(data)) is True
def test_only_warnings_is_not_blocking():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18), skills=["SOUND"]),
volunteer_skill_codes=frozenset(),
day_parts=DAY_PARTS,
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),),
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
# pas de dispo ⇒ un seul avertissement OUTSIDE_AVAILABILITY
)
violations = validate(data)
assert _codes(violations) == [ViolationCode.MISSING_REQUIRED_SKILL]
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
assert has_blocking(violations) is False
+8 -14
View File
@@ -1,7 +1,7 @@
import datetime as dt
from app.models import Assignment, AvailabilityStatus
from app.validation import AvailabilityDeclaration
from app.models import Assignment
from app.validation import AvailabilityWindow
from app.validation.loader import build_validation_input
from tests.factories import (
dt_at,
@@ -15,18 +15,16 @@ from tests.factories import (
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"],
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", skills=["SST", "PERMIS_B"]
)
volunteer = make_volunteer(session, edition, email="v@ex.fr")
make_availability(
session, edition, volunteer, day_parts["AFTERNOON"],
date=dt.date(2026, 7, 10), status=AvailabilityStatus.IF_NEEDED,
session, edition, volunteer,
date=dt.date(2026, 7, 10),
start_time=dt.time(13, 0), end_time=dt.time(19, 0),
)
session.add(
Assignment(
@@ -41,15 +39,11 @@ 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.required_skill_codes == frozenset({"SST"})
assert data.volunteer_skill_codes == frozenset({"SST", "PERMIS_B"})
# Toute affectation existante compte, sauf le créneau candidat lui-même.
assert [s.id for s in data.existing_shifts] == [str(other_shift.id)]
assert len(data.day_parts) == 3
assert data.availabilities == (
AvailabilityDeclaration(
dt.date(2026, 7, 10), "AFTERNOON", AvailabilityStatus.IF_NEEDED
),
AvailabilityWindow(dt.date(2026, 7, 10), dt.time(13, 0), dt.time(19, 0)),
)