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:
co-authored by
Claude Sonnet 5
parent
81609eecc5
commit
be51770b51
@@ -17,11 +17,10 @@ colonne, route et variable utilise la colonne « Code / base ».
|
|||||||
|------------------------------------|-----------------------------------------------|
|
|------------------------------------|-----------------------------------------------|
|
||||||
| Édition | `Edition` |
|
| Édition | `Edition` |
|
||||||
| Bénévole | `Volunteer` |
|
| Bénévole | `Volunteer` |
|
||||||
| Compétence | `Skill` |
|
|
||||||
| Poste | `Position` |
|
| Poste | `Position` |
|
||||||
| Référent d'un poste | `Position.lead_account_id` → `Account` |
|
| Référent d'un poste | `Position.lead_account_id` → `Account` |
|
||||||
| Créneau | `Shift` |
|
| Créneau | `Shift` |
|
||||||
| Disponibilité | `Availability` |
|
| Disponibilité (fenêtre horaire libre) | `Availability` (`date`, `start_time`, `end_time`) |
|
||||||
| Affectation | `Assignment` |
|
| Affectation | `Assignment` |
|
||||||
| Artiste programmé | `Artist` (`name`, `performance_date`) |
|
| Artiste programmé | `Artist` (`name`, `performance_date`) |
|
||||||
| Vœu « je veux voir cet artiste » | `VolunteerArtist` (N-N `Volunteer` ↔ `Artist`) |
|
| 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` |
|
| Origine d'affectation | `SOLVER` / `MANUAL` |
|
||||||
| Verrouillée | `Assignment.locked: bool` |
|
| Verrouillée | `Assignment.locked: bool` |
|
||||||
| Rôle | `COORDINATOR` / `LEAD` / `VOLUNTEER` |
|
| Rôle | `COORDINATOR` / `LEAD` / `VOLUNTEER` |
|
||||||
| Disponibilité (tri-état) | `AVAILABLE` / `IF_NEEDED` / `UNAVAILABLE` |
|
|
||||||
| Catégorie de poste | `BAR` / `TICKETING` / `WELCOME` / `PARKING` / `CLEANING` / `CATERING` / `SETUP` / `STAGE` / `OTHER` |
|
| 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)
|
### Distinction critique (ne jamais confondre, ni en base ni dans l'UI)
|
||||||
|
|
||||||
- **`Availability`** = une *déclaration* du bénévole : « je peux venir tel
|
- **`Availability`** = une *déclaration* du bénévole : « le samedi, je peux
|
||||||
jour, telle demi-journée ».
|
venir de 14 h à 23 h » — fenêtre horaire libre, plusieurs par jour.
|
||||||
- **`Assignment`** = une *décision* de l'organisation : « tu tiens le bar
|
- **`Assignment`** = une *décision* de l'organisation : « tu tiens le bar
|
||||||
samedi 18 h – 22 h ».
|
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
|
- **`DayPart`** (« créneau type ») : découpage d'une journée propre à l'édition
|
||||||
(nombre et bornes libres). `code`, `label`, `start_time`, `end_time`
|
(nombre et bornes libres). `code`, `label`, `start_time`, `end_time`
|
||||||
(`end_time < start_time` ⇒ passe minuit), `order`. Unique (`edition_id`,
|
(`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
|
CRUD `GET/POST/PATCH/DELETE /editions/{id}/day-parts` (COORDINATION pour
|
||||||
l'écriture).
|
l'écriture).
|
||||||
- **`Volunteer`** : `email` (unique **par édition**, nullable pour les
|
- **`Volunteer`** : `email` (unique **par édition**, nullable pour les
|
||||||
bénévoles créés à la main — plusieurs `NULL` autorisés), `first_name`,
|
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
|
`last_name`, `phone`, `comment`. Pas de date de naissance, taille de
|
||||||
t-shirt / régime alimentaire. Porte trois listes de vœux, exposées par la
|
t-shirt, régime, ni compétences. Porte deux listes de vœux, exposées par la
|
||||||
fiche (`skill_codes`, `artist_ids`, `wished_position_ids` ; `None`/absent =
|
fiche (`artist_ids`, `wished_position_ids` ; `None`/absent = inchangé,
|
||||||
inchangé, `[]` = vider).
|
`[]` = vider).
|
||||||
- **`Skill`** : référentiel par édition (`code`, `label`). N-N `Volunteer`
|
|
||||||
(`volunteer_skill`). N-N `Shift` (`shift_required_skill`).
|
|
||||||
- **`Artist`** : programmation de l'édition (`name`, `performance_date`).
|
- **`Artist`** : programmation de l'édition (`name`, `performance_date`).
|
||||||
Unique (`edition_id`, `name`, `performance_date`). La coordination gère la
|
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
|
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).
|
`lead_account_id` (→ `Account`, `SET NULL` à la suppression du compte).
|
||||||
- **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont
|
- **`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`
|
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
|
(peut finir le lendemain), `min_staff`, `ideal_staff`. À la création d'un
|
||||||
(N-N `Skill`). À la création d'un `Position`, un `Shift` à 0/0 est généré par
|
`Position`, un `Shift` à 0/0 est généré par jour de l'édition et par
|
||||||
jour de l'édition et par `DayPart` ;
|
`DayPart` ; `POST /editions/{id}/positions/{pid}/generate-shifts` recrée les
|
||||||
`POST /editions/{id}/positions/{pid}/generate-shifts` recrée les manquants
|
manquants (après ajout d'un créneau type ou extension des dates).
|
||||||
(après ajout d'un créneau type ou extension des dates).
|
- **`Availability`** : `volunteer_id`, `date`, `start_time`, `end_time`
|
||||||
- **`Availability`** : `volunteer_id`, `date`, `day_part_id`, `status`
|
(`end_time <= start_time` ⇒ la fenêtre se termine le lendemain). Fenêtre
|
||||||
(`AvailabilityStatus`). Unique (`volunteer_id`, `date`, `day_part_id`).
|
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`,
|
- **`Assignment`** : `volunteer_id`, `shift_id`, `source` (`AssignmentSource`,
|
||||||
défaut `MANUAL`), `locked` (défaut `false`). Unique (`volunteer_id`,
|
défaut `MANUAL`), `locked` (défaut `false`). Unique (`volunteer_id`,
|
||||||
`shift_id`). **Pas de statut** : l'affectation est valide dès sa création
|
`shift_id`). **Pas de statut** : l'affectation est valide dès sa création
|
||||||
@@ -110,9 +112,9 @@ Conventions techniques :
|
|||||||
type ENUM natif PostgreSQL.
|
type ENUM natif PostgreSQL.
|
||||||
- Suppression en cascade au niveau base (`ON DELETE CASCADE`) sur tous les
|
- Suppression en cascade au niveau base (`ON DELETE CASCADE`) sur tous les
|
||||||
`edition_id` et sur les liens vers `volunteer`, `shift`, `position`,
|
`edition_id` et sur les liens vers `volunteer`, `shift`, `position`,
|
||||||
`skill`, `artist`, `account`. La suppression complète d'un bénévole (RGPD)
|
`artist`, `account`. La suppression complète d'un bénévole (RGPD) efface
|
||||||
efface donc ses `Availability`, `Assignment`, liens de compétence, vœux
|
donc ses `Availability`, `Assignment`, vœux (artistes, postes) et son
|
||||||
(artistes, postes) et son `Account`.
|
`Account`.
|
||||||
- Dates/heures **naïves**, heure locale Europe/Paris (`TZ` du conteneur +
|
- Dates/heures **naïves**, heure locale Europe/Paris (`TZ` du conteneur +
|
||||||
`tzdata`). `app.models.local_now()` pour les horodatages.
|
`tzdata`). `app.models.local_now()` pour les horodatages.
|
||||||
|
|
||||||
@@ -135,6 +137,7 @@ Matrice appliquée par les endpoints :
|
|||||||
| `assignments` écriture | ✔ | ses postes | — |
|
| `assignments` écriture | ✔ | ses postes | — |
|
||||||
| `assignments` lecture | ✔ | ✔ | les siennes |
|
| `assignments` lecture | ✔ | ✔ | les siennes |
|
||||||
| `artists` lecture / écriture | ✔ / ✔ | ✔ / — | ✔ / — |
|
| `artists` lecture / écriture | ✔ / ✔ | ✔ / — | ✔ / — |
|
||||||
|
| `availability` lecture / écriture | ✔ / ✔ | ✔ / — | la sienne / — |
|
||||||
| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — |
|
| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — |
|
||||||
|
|
||||||
**Authentification — deux systèmes selon le compte**, une même session JWT
|
**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 |
|
| Code | Sévérité | Règle |
|
||||||
|---------------------------|------------|-------------------------------------------------------------|
|
|---------------------------|------------|-------------------------------------------------------------|
|
||||||
| `SHIFT_OVERLAP` | `BLOCKING` | Deux créneaux d'un même bénévole se chevauchent. |
|
| `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. |
|
| `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. |
|
| `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 :
|
Sémantique figée :
|
||||||
|
|
||||||
- Toutes les affectations du bénévole comptent pour chevauchement / cumul /
|
- Toutes les affectations du bénévole comptent pour chevauchement / cumul /
|
||||||
repos (une affectation = une décision, toujours active).
|
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
|
- « 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).
|
- Cumul : `> 8 h 00` strict (8 h 00 pile passe).
|
||||||
- Repos : `< 8 h 00` strict.
|
- Repos : `< 8 h 00` strict.
|
||||||
- Créneaux jointifs (`end_at == start_at`) : **pas** un chevauchement.
|
- Créneaux jointifs (`end_at == start_at`) : **pas** un chevauchement.
|
||||||
@@ -234,7 +240,8 @@ Sémantique figée :
|
|||||||
### Premier incrément (en cours)
|
### Premier incrément (en cours)
|
||||||
|
|
||||||
1. Modèle de données complet + migrations Alembic.
|
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
|
temps : analyse (colonnes détectées, aperçu) puis exécution avec mapping
|
||||||
validé. Rapport ligne par ligne. Détection des doublons : upsert sur
|
validé. Rapport ligne par ligne. Détection des doublons : upsert sur
|
||||||
`email` ; sans email, détection sur (`last_name`, `first_name`) signalée
|
`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
|
│ │ ├── logging_config.py # RGPD : pas de PII dans les logs
|
||||||
│ │ ├── api/ # routers + schemas Pydantic + deps
|
│ │ ├── api/ # routers + schemas Pydantic + deps
|
||||||
│ │ │ # health, auth, accounts, editions,
|
│ │ │ # health, auth, accounts, editions,
|
||||||
│ │ │ # day_parts, imports, artists,
|
│ │ │ # day_parts, imports, artists, volunteers,
|
||||||
│ │ │ # volunteers, positions, shifts,
|
│ │ │ # availability, positions, shifts,
|
||||||
│ │ │ # assignments, matrix, notifications
|
│ │ │ # assignments, matrix, notifications
|
||||||
│ │ ├── auth/ # mot de passe + lien magique + JWT + rôles
|
│ │ ├── auth/ # mot de passe + lien magique + JWT + rôles
|
||||||
│ │ ├── email/ # SMTP (sender) + rendu des emails (templates)
|
│ │ ├── email/ # SMTP (sender) + rendu des emails (templates)
|
||||||
│ │ ├── importing/ # import CSV/Excel (parser, values, service)
|
│ │ ├── importing/ # import CSV/Excel (parser, text, service)
|
||||||
│ │ ├── models/ # tables SQLModel
|
│ │ ├── models/ # tables SQLModel
|
||||||
│ │ ├── validation/ # moteur pur (models, engine) + loader (base)
|
│ │ ├── validation/ # moteur pur (models, engine) + loader (base)
|
||||||
│ │ ├── bootstrap.py # instance vierge : édition + 1er compte
|
│ │ ├── 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 |
|
| 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 |
|
| 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 |
|
| 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,
|
**Reste hors périmètre** (modèle prêt à les accueillir) : solveur OR-Tools,
|
||||||
SMS, constructeur de formulaire, pointage sur site, IA. Frontend :
|
SMS, constructeur de formulaire, pointage sur site, IA. Frontend :
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
"""Helpers partagés par les routers CRUD."""
|
"""Helpers partagés par les routers CRUD."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Iterable
|
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import delete
|
from sqlalchemy import delete
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.models import Skill
|
|
||||||
|
|
||||||
|
|
||||||
def owned_or_404(instance, edition_id: uuid.UUID):
|
def owned_or_404(instance, edition_id: uuid.UUID):
|
||||||
@@ -17,33 +14,6 @@ def owned_or_404(instance, edition_id: uuid.UUID):
|
|||||||
return instance
|
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(
|
def replace_links(
|
||||||
session: Session, link_model, *, filter_column, filter_value, pairs
|
session: Session, link_model, *, filter_column, filter_value, pairs
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -94,7 +94,7 @@ def delete_day_part(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
day_part = owned_or_404(session.get(DayPart, day_part_id), edition.id)
|
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.delete(day_part)
|
||||||
session.commit()
|
session.commit()
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
"""Endpoints d'import : analyse puis exécution."""
|
"""Endpoints d'import : analyse puis exécution."""
|
||||||
|
|
||||||
import datetime as dt
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.api.deps import get_edition
|
from app.api.deps import get_edition
|
||||||
from app.auth.deps import require_coordinator
|
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.parser import ImportParseError, parse_upload
|
||||||
from app.importing.schemas import (
|
from app.importing.schemas import (
|
||||||
AnalyzeResponse,
|
AnalyzeResponse,
|
||||||
DayPartInfo,
|
|
||||||
ImportMapping,
|
ImportMapping,
|
||||||
ImportReport,
|
ImportReport,
|
||||||
PreviewRow,
|
PreviewRow,
|
||||||
)
|
)
|
||||||
from app.importing.service import run_import, suggest_mapping, validate_mapping
|
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"])
|
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)
|
@router.post("/analyze", response_model=AnalyzeResponse)
|
||||||
async def analyze(
|
async def analyze(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
edition: Edition = Depends(get_edition),
|
_edition: Edition = Depends(get_edition),
|
||||||
_guard: Account = Depends(require_coordinator),
|
_guard: Account = Depends(require_coordinator),
|
||||||
session: Session = Depends(get_session),
|
|
||||||
) -> AnalyzeResponse:
|
) -> AnalyzeResponse:
|
||||||
content = await _read_upload(file)
|
content = await _read_upload(file)
|
||||||
try:
|
try:
|
||||||
@@ -87,17 +68,6 @@ async def analyze(
|
|||||||
row_count=len(table.rows),
|
row_count=len(table.rows),
|
||||||
preview=preview,
|
preview=preview,
|
||||||
suggested_mapping=suggest_mapping(table.headers),
|
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},
|
detail={"code": exc.code, "params": exc.params},
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
mapping_errors = validate_mapping(
|
mapping_errors = validate_mapping(table, parsed_mapping)
|
||||||
table, parsed_mapping, edition, _day_parts(session, edition)
|
|
||||||
)
|
|
||||||
if mapping_errors:
|
if mapping_errors:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=422,
|
status_code=422,
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ class VolunteerCreate(ApiModel):
|
|||||||
email: str | None = None
|
email: str | None = None
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
comment: 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.
|
# Vœux du bénévole : artistes à voir, postes souhaités.
|
||||||
artist_ids: list[uuid.UUID] = Field(default_factory=list)
|
artist_ids: list[uuid.UUID] = Field(default_factory=list)
|
||||||
wished_position_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
|
phone: str | None = None
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
# None = inchangé ; [] = vider. Idem pour artist_ids / wished_position_ids.
|
# None = inchangé ; [] = vider. Idem pour artist_ids / wished_position_ids.
|
||||||
skill_codes: list[str] | None = None
|
|
||||||
artist_ids: list[uuid.UUID] | None = None
|
artist_ids: list[uuid.UUID] | None = None
|
||||||
wished_position_ids: list[uuid.UUID] | None = None
|
wished_position_ids: list[uuid.UUID] | None = None
|
||||||
|
|
||||||
@@ -75,7 +73,6 @@ class VolunteerRead(ApiModel):
|
|||||||
email: str | None
|
email: str | None
|
||||||
phone: str | None
|
phone: str | None
|
||||||
comment: str | None
|
comment: str | None
|
||||||
skill_codes: list[str]
|
|
||||||
artist_ids: list[uuid.UUID]
|
artist_ids: list[uuid.UUID]
|
||||||
wished_position_ids: list[uuid.UUID]
|
wished_position_ids: list[uuid.UUID]
|
||||||
created_at: dt.datetime
|
created_at: dt.datetime
|
||||||
@@ -141,7 +138,6 @@ class ShiftCreate(ApiModel):
|
|||||||
ends_at: dt.datetime
|
ends_at: dt.datetime
|
||||||
min_staff: int = Field(default=0, ge=0)
|
min_staff: int = Field(default=0, ge=0)
|
||||||
ideal_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):
|
class ShiftUpdate(ApiModel):
|
||||||
@@ -150,7 +146,6 @@ class ShiftUpdate(ApiModel):
|
|||||||
ends_at: dt.datetime | None = None
|
ends_at: dt.datetime | None = None
|
||||||
min_staff: int | None = Field(default=None, ge=0)
|
min_staff: int | None = Field(default=None, ge=0)
|
||||||
ideal_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):
|
class ShiftRead(ApiModel):
|
||||||
@@ -162,7 +157,6 @@ class ShiftRead(ApiModel):
|
|||||||
ends_at: dt.datetime
|
ends_at: dt.datetime
|
||||||
min_staff: int
|
min_staff: int
|
||||||
ideal_staff: int
|
ideal_staff: int
|
||||||
required_skill_codes: list[str]
|
|
||||||
created_at: dt.datetime
|
created_at: dt.datetime
|
||||||
updated_at: dt.datetime
|
updated_at: dt.datetime
|
||||||
|
|
||||||
@@ -202,6 +196,33 @@ class DayPartUpdate(ApiModel):
|
|||||||
order: int | None = None
|
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 #
|
# Assignment #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import uuid
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
from sqlmodel import Session, select
|
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.deps import get_edition
|
||||||
from app.api.schemas import ShiftCreate, ShiftRead, ShiftUpdate
|
from app.api.schemas import ShiftCreate, ShiftRead, ShiftUpdate
|
||||||
from app.auth.deps import (
|
from app.auth.deps import (
|
||||||
@@ -15,44 +15,13 @@ from app.auth.deps import (
|
|||||||
require_staff,
|
require_staff,
|
||||||
)
|
)
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.models import (
|
from app.models import Account, Edition, Position, Shift
|
||||||
Account,
|
|
||||||
Edition,
|
|
||||||
Position,
|
|
||||||
Shift,
|
|
||||||
ShiftRequiredSkill,
|
|
||||||
Skill,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/editions/{edition_id}/shifts", tags=["shifts"])
|
router = APIRouter(prefix="/editions/{edition_id}/shifts", tags=["shifts"])
|
||||||
|
|
||||||
|
|
||||||
def _skill_codes(session: Session, shift_id: uuid.UUID) -> list[str]:
|
def _read(shift: Shift) -> ShiftRead:
|
||||||
return sorted(
|
return ShiftRead(**shift.model_dump())
|
||||||
session.exec(
|
|
||||||
select(Skill.code)
|
|
||||||
.join(ShiftRequiredSkill, ShiftRequiredSkill.skill_id == Skill.id)
|
|
||||||
.where(ShiftRequiredSkill.shift_id == shift_id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _read(session: Session, shift: Shift) -> ShiftRead:
|
|
||||||
return ShiftRead(
|
|
||||||
**shift.model_dump(),
|
|
||||||
required_skill_codes=_skill_codes(session, shift.id),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _set_skills(session: Session, shift: Shift, codes: list[str]) -> None:
|
|
||||||
skills = resolve_skills(session, shift.edition_id, codes)
|
|
||||||
replace_links(
|
|
||||||
session,
|
|
||||||
ShiftRequiredSkill,
|
|
||||||
filter_column=ShiftRequiredSkill.shift_id,
|
|
||||||
filter_value=shift.id,
|
|
||||||
pairs=[{"shift_id": shift.id, "skill_id": s.id} for s in skills],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _check_range(starts_at, ends_at) -> None:
|
def _check_range(starts_at, ends_at) -> None:
|
||||||
@@ -84,16 +53,11 @@ def create_shift(
|
|||||||
_check_range(payload.starts_at, payload.ends_at)
|
_check_range(payload.starts_at, payload.ends_at)
|
||||||
position = _require_position(session, payload.position_id, edition.id)
|
position = _require_position(session, payload.position_id, edition.id)
|
||||||
ensure_can_write_position(account, position)
|
ensure_can_write_position(account, position)
|
||||||
shift = Shift(
|
shift = Shift(edition_id=edition.id, **payload.model_dump())
|
||||||
edition_id=edition.id,
|
|
||||||
**payload.model_dump(exclude={"required_skill_codes"}),
|
|
||||||
)
|
|
||||||
session.add(shift)
|
session.add(shift)
|
||||||
session.flush()
|
|
||||||
_set_skills(session, shift, payload.required_skill_codes)
|
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(shift)
|
session.refresh(shift)
|
||||||
return _read(session, shift)
|
return _read(shift)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ShiftRead])
|
@router.get("", response_model=list[ShiftRead])
|
||||||
@@ -110,7 +74,7 @@ def list_shifts(
|
|||||||
)
|
)
|
||||||
if position_id is not None:
|
if position_id is not None:
|
||||||
stmt = stmt.where(Shift.position_id == position_id)
|
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)
|
@router.get("/{shift_id}", response_model=ShiftRead)
|
||||||
@@ -121,7 +85,7 @@ def get_shift(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> ShiftRead:
|
) -> ShiftRead:
|
||||||
shift = owned_or_404(session.get(Shift, shift_id), edition.id)
|
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)
|
@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)
|
shift = owned_or_404(session.get(Shift, shift_id), edition.id)
|
||||||
ensure_can_write_shift(account, session, shift)
|
ensure_can_write_shift(account, session, shift)
|
||||||
changes = payload.model_dump(exclude_unset=True)
|
changes = payload.model_dump(exclude_unset=True)
|
||||||
skill_codes = changes.pop("required_skill_codes", None)
|
|
||||||
if "position_id" in changes:
|
if "position_id" in changes:
|
||||||
target = _require_position(session, changes["position_id"], edition.id)
|
target = _require_position(session, changes["position_id"], edition.id)
|
||||||
ensure_can_write_position(account, target)
|
ensure_can_write_position(account, target)
|
||||||
for field, value in changes.items():
|
for field, value in changes.items():
|
||||||
setattr(shift, field, value)
|
setattr(shift, field, value)
|
||||||
_check_range(shift.starts_at, shift.ends_at)
|
_check_range(shift.starts_at, shift.ends_at)
|
||||||
if skill_codes is not None:
|
|
||||||
_set_skills(session, shift, skill_codes)
|
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(shift)
|
session.refresh(shift)
|
||||||
return _read(session, shift)
|
return _read(shift)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{shift_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{shift_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Response, status
|
|||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlmodel import Session, select
|
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.deps import get_edition
|
||||||
from app.api.schemas import VolunteerCreate, VolunteerRead, VolunteerUpdate
|
from app.api.schemas import VolunteerCreate, VolunteerRead, VolunteerUpdate
|
||||||
from app.auth.deps import (
|
from app.auth.deps import (
|
||||||
@@ -21,11 +21,9 @@ from app.models import (
|
|||||||
Edition,
|
Edition,
|
||||||
Position,
|
Position,
|
||||||
Role,
|
Role,
|
||||||
Skill,
|
|
||||||
Volunteer,
|
Volunteer,
|
||||||
VolunteerArtist,
|
VolunteerArtist,
|
||||||
VolunteerPositionWish,
|
VolunteerPositionWish,
|
||||||
VolunteerSkill,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(
|
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"})
|
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]:
|
def _artist_ids(session: Session, volunteer_id: uuid.UUID) -> list[uuid.UUID]:
|
||||||
return list(
|
return list(
|
||||||
session.exec(
|
session.exec(
|
||||||
@@ -81,27 +69,11 @@ def _wished_position_ids(
|
|||||||
def _read(session: Session, volunteer: Volunteer) -> VolunteerRead:
|
def _read(session: Session, volunteer: Volunteer) -> VolunteerRead:
|
||||||
return VolunteerRead(
|
return VolunteerRead(
|
||||||
**volunteer.model_dump(),
|
**volunteer.model_dump(),
|
||||||
skill_codes=_skill_codes(session, volunteer.id),
|
|
||||||
artist_ids=_artist_ids(session, volunteer.id),
|
artist_ids=_artist_ids(session, volunteer.id),
|
||||||
wished_position_ids=_wished_position_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(
|
def _resolve_owned_ids(
|
||||||
session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID], code: str
|
session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID], code: str
|
||||||
) -> list[uuid.UUID]:
|
) -> list[uuid.UUID]:
|
||||||
@@ -168,9 +140,7 @@ def create_volunteer(
|
|||||||
) -> VolunteerRead:
|
) -> VolunteerRead:
|
||||||
volunteer = Volunteer(
|
volunteer = Volunteer(
|
||||||
edition_id=edition.id,
|
edition_id=edition.id,
|
||||||
**payload.model_dump(
|
**payload.model_dump(exclude={"artist_ids", "wished_position_ids"}),
|
||||||
exclude={"skill_codes", "artist_ids", "wished_position_ids"}
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
session.add(volunteer)
|
session.add(volunteer)
|
||||||
try:
|
try:
|
||||||
@@ -180,7 +150,6 @@ def create_volunteer(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=409, detail={"code": "volunteer.duplicate_email"}
|
status_code=409, detail={"code": "volunteer.duplicate_email"}
|
||||||
) from exc
|
) from exc
|
||||||
_set_skills(session, volunteer, payload.skill_codes)
|
|
||||||
_set_artists(session, volunteer, payload.artist_ids)
|
_set_artists(session, volunteer, payload.artist_ids)
|
||||||
_set_wished_positions(session, volunteer, payload.wished_position_ids)
|
_set_wished_positions(session, volunteer, payload.wished_position_ids)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -224,7 +193,6 @@ def update_volunteer(
|
|||||||
) -> VolunteerRead:
|
) -> VolunteerRead:
|
||||||
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
||||||
changes = payload.model_dump(exclude_unset=True)
|
changes = payload.model_dump(exclude_unset=True)
|
||||||
skill_codes = changes.pop("skill_codes", None)
|
|
||||||
artist_ids = changes.pop("artist_ids", None)
|
artist_ids = changes.pop("artist_ids", None)
|
||||||
wished_position_ids = changes.pop("wished_position_ids", None)
|
wished_position_ids = changes.pop("wished_position_ids", None)
|
||||||
for field, value in changes.items():
|
for field, value in changes.items():
|
||||||
@@ -236,8 +204,6 @@ def update_volunteer(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=409, detail={"code": "volunteer.duplicate_email"}
|
status_code=409, detail={"code": "volunteer.duplicate_email"}
|
||||||
) from exc
|
) from exc
|
||||||
if skill_codes is not None:
|
|
||||||
_set_skills(session, volunteer, skill_codes)
|
|
||||||
if artist_ids is not None:
|
if artist_ids is not None:
|
||||||
_set_artists(session, volunteer, artist_ids)
|
_set_artists(session, volunteer, artist_ids)
|
||||||
if wished_position_ids is not None:
|
if wished_position_ids is not None:
|
||||||
@@ -256,7 +222,7 @@ def delete_volunteer(
|
|||||||
) -> Response:
|
) -> Response:
|
||||||
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
||||||
# Effacement complet : la cascade base supprime disponibilités,
|
# 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.delete(volunteer)
|
||||||
session.commit()
|
session.commit()
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|||||||
@@ -24,40 +24,30 @@
|
|||||||
"account.volunteer_wrong_edition": "Le bénévole n'appartient pas à cette édition.",
|
"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_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.",
|
"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.empty_file": "Le fichier est vide.",
|
||||||
"import.error.encoding": "L'encodage du fichier n'est pas reconnu.",
|
"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.unreadable_file": "Fichier illisible ou format non pris en charge.",
|
||||||
"import.error.file_too_large": "Le fichier est trop volumineux.",
|
"import.error.file_too_large": "Le fichier est trop volumineux.",
|
||||||
|
|
||||||
"import.mapping.invalid": "Le paramétrage des colonnes est invalide.",
|
"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.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.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.missing_required_field": "Champ obligatoire manquant : {field}.",
|
||||||
"import.row.invalid_email": "Adresse email invalide : {value}.",
|
"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_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.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.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.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.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.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.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é.",
|
"notification.no_recipients": "Aucun bénévole ciblé.",
|
||||||
|
|
||||||
"email.magic_link.subject": "Ton lien de connexion",
|
"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.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_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.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.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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`).
|
structurés, jamais de phrase (voir `locales/fr.json`).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import datetime as dt
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
@@ -20,21 +19,11 @@ class ColumnMapping(BaseModel):
|
|||||||
email: str | None = None
|
email: str | None = None
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
comment: 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):
|
class ImportMapping(BaseModel):
|
||||||
dry_run: bool = True
|
dry_run: bool = True
|
||||||
columns: ColumnMapping
|
columns: ColumnMapping
|
||||||
availability_columns: list[AvailabilityColumnMapping] = Field(
|
|
||||||
default_factory=list
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Issue(BaseModel):
|
class Issue(BaseModel):
|
||||||
@@ -57,8 +46,6 @@ class ImportReport(BaseModel):
|
|||||||
created: int = 0
|
created: int = 0
|
||||||
updated: int = 0
|
updated: int = 0
|
||||||
skipped: int = 0
|
skipped: int = 0
|
||||||
availabilities_written: int = 0
|
|
||||||
skills_created: list[str] = Field(default_factory=list)
|
|
||||||
rows: list[RowReport] = Field(default_factory=list)
|
rows: list[RowReport] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@@ -67,14 +54,6 @@ class PreviewRow(BaseModel):
|
|||||||
values: dict[str, str]
|
values: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
class DayPartInfo(BaseModel):
|
|
||||||
code: str
|
|
||||||
label: str
|
|
||||||
start_time: dt.time
|
|
||||||
end_time: dt.time
|
|
||||||
order: int
|
|
||||||
|
|
||||||
|
|
||||||
class AnalyzeResponse(BaseModel):
|
class AnalyzeResponse(BaseModel):
|
||||||
filename: str
|
filename: str
|
||||||
format: Literal["csv", "xlsx"]
|
format: Literal["csv", "xlsx"]
|
||||||
@@ -82,5 +61,3 @@ class AnalyzeResponse(BaseModel):
|
|||||||
row_count: int
|
row_count: int
|
||||||
preview: list[PreviewRow]
|
preview: list[PreviewRow]
|
||||||
suggested_mapping: dict[str, str]
|
suggested_mapping: dict[str, str]
|
||||||
edition_dates: list[dt.date]
|
|
||||||
day_parts: list[DayPartInfo]
|
|
||||||
|
|||||||
@@ -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
|
import re
|
||||||
|
|
||||||
@@ -11,16 +15,8 @@ from app.importing.schemas import (
|
|||||||
Issue,
|
Issue,
|
||||||
RowReport,
|
RowReport,
|
||||||
)
|
)
|
||||||
from app.importing.text import normalize, slug_code, split_skills
|
from app.importing.text import normalize
|
||||||
from app.importing.values import parse_availability_cell
|
from app.models import Edition, Volunteer
|
||||||
from app.models import (
|
|
||||||
Availability,
|
|
||||||
DayPart,
|
|
||||||
Edition,
|
|
||||||
Skill,
|
|
||||||
Volunteer,
|
|
||||||
VolunteerSkill,
|
|
||||||
)
|
|
||||||
|
|
||||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
_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"),
|
"last_name": ("nom", "last name", "lastname", "surname", "famille"),
|
||||||
"phone": ("tel", "phone", "portable", "mobile", "gsm"),
|
"phone": ("tel", "phone", "portable", "mobile", "gsm"),
|
||||||
"comment": ("commentaire", "remarque", "note", "precision"),
|
"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(
|
def validate_mapping(
|
||||||
table: ParsedTable,
|
table: ParsedTable, mapping: ImportMapping
|
||||||
mapping: ImportMapping,
|
|
||||||
edition: Edition,
|
|
||||||
day_parts: list[DayPart],
|
|
||||||
) -> list[Issue]:
|
) -> list[Issue]:
|
||||||
"""Contrôles préalables. Retourne une liste vide si le mapping est correct."""
|
"""Contrôles préalables. Retourne une liste vide si le mapping est correct."""
|
||||||
errors: list[Issue] = []
|
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)
|
header = getattr(columns, field)
|
||||||
if header and header not in headers:
|
if header and header not in headers:
|
||||||
errors.append(
|
errors.append(
|
||||||
@@ -91,50 +83,6 @@ def validate_mapping(
|
|||||||
params={"column": header},
|
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
|
return errors
|
||||||
|
|
||||||
|
|
||||||
@@ -150,12 +98,6 @@ def run_import(
|
|||||||
Le mapping est supposé déjà validé par `validate_mapping`.
|
Le mapping est supposé déjà validé par `validate_mapping`.
|
||||||
"""
|
"""
|
||||||
columns = mapping.columns
|
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(
|
existing = list(
|
||||||
session.exec(
|
session.exec(
|
||||||
@@ -169,14 +111,6 @@ def run_import(
|
|||||||
key = (normalize(volunteer.last_name), normalize(volunteer.first_name))
|
key = (normalize(volunteer.last_name), normalize(volunteer.first_name))
|
||||||
by_name.setdefault(key, volunteer)
|
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))
|
report = ImportReport(dry_run=mapping.dry_run, total_rows=len(table.rows))
|
||||||
email_seen: dict[str, int] = {}
|
email_seen: dict[str, int] = {}
|
||||||
name_seen: dict[tuple[str, str], int] = {}
|
name_seen: dict[tuple[str, str], int] = {}
|
||||||
@@ -284,82 +218,6 @@ def run_import(
|
|||||||
|
|
||||||
session.flush()
|
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
|
entry.volunteer_id = volunteer.id
|
||||||
report.rows.append(entry)
|
report.rows.append(entry)
|
||||||
if entry.action == "CREATED":
|
if entry.action == "CREATED":
|
||||||
@@ -367,8 +225,6 @@ def run_import(
|
|||||||
else:
|
else:
|
||||||
report.updated += 1
|
report.updated += 1
|
||||||
|
|
||||||
report.skills_created = sorted(created_skills)
|
|
||||||
|
|
||||||
if mapping.dry_run:
|
if mapping.dry_run:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -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 re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
|
||||||
_SKILL_SEPARATORS = re.compile(r"[;,/\n]+")
|
|
||||||
_MULTI_UNDERSCORE = re.compile(r"_+")
|
_MULTI_UNDERSCORE = re.compile(r"_+")
|
||||||
|
|
||||||
|
|
||||||
@@ -15,12 +14,8 @@ def normalize(value: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def slug_code(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()
|
base = normalize(value).upper()
|
||||||
chars = [c if c.isalnum() else "_" for c in base]
|
chars = [c if c.isalnum() else "_" for c in base]
|
||||||
return _MULTI_UNDERSCORE.sub("_", "".join(chars)).strip("_")
|
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]
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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.artists import router as artist_router
|
||||||
from app.api.assignments import router as assignment_router
|
from app.api.assignments import router as assignment_router
|
||||||
from app.api.auth import router as auth_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.day_parts import router as day_part_router
|
||||||
from app.api.editions import router as edition_router
|
from app.api.editions import router as edition_router
|
||||||
from app.api.health import router as health_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(import_router)
|
||||||
app.include_router(artist_router)
|
app.include_router(artist_router)
|
||||||
app.include_router(volunteer_router)
|
app.include_router(volunteer_router)
|
||||||
|
app.include_router(availability_router)
|
||||||
app.include_router(position_router)
|
app.include_router(position_router)
|
||||||
app.include_router(shift_router)
|
app.include_router(shift_router)
|
||||||
app.include_router(assignment_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"),
|
||||||
|
)
|
||||||
@@ -15,16 +15,12 @@ from app.models.domain import (
|
|||||||
MagicLinkToken,
|
MagicLinkToken,
|
||||||
Position,
|
Position,
|
||||||
Shift,
|
Shift,
|
||||||
ShiftRequiredSkill,
|
|
||||||
Skill,
|
|
||||||
Volunteer,
|
Volunteer,
|
||||||
VolunteerArtist,
|
VolunteerArtist,
|
||||||
VolunteerPositionWish,
|
VolunteerPositionWish,
|
||||||
VolunteerSkill,
|
|
||||||
)
|
)
|
||||||
from app.models.enums import (
|
from app.models.enums import (
|
||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
AvailabilityStatus,
|
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
Role,
|
Role,
|
||||||
)
|
)
|
||||||
@@ -35,7 +31,6 @@ __all__ = [
|
|||||||
"Assignment",
|
"Assignment",
|
||||||
"AssignmentSource",
|
"AssignmentSource",
|
||||||
"Availability",
|
"Availability",
|
||||||
"AvailabilityStatus",
|
|
||||||
"DayPart",
|
"DayPart",
|
||||||
"Edition",
|
"Edition",
|
||||||
"MagicLinkToken",
|
"MagicLinkToken",
|
||||||
@@ -43,8 +38,6 @@ __all__ = [
|
|||||||
"PositionCategory",
|
"PositionCategory",
|
||||||
"Role",
|
"Role",
|
||||||
"Shift",
|
"Shift",
|
||||||
"ShiftRequiredSkill",
|
|
||||||
"Skill",
|
|
||||||
"TimestampMixin",
|
"TimestampMixin",
|
||||||
"Volunteer",
|
"Volunteer",
|
||||||
"VolunteerArtist",
|
"VolunteerArtist",
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
|
|||||||
from app.models.base import TimestampMixin
|
from app.models.base import TimestampMixin
|
||||||
from app.models.enums import (
|
from app.models.enums import (
|
||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
AvailabilityStatus,
|
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
Role,
|
Role,
|
||||||
)
|
)
|
||||||
@@ -27,28 +26,6 @@ _PASSIVE = {"passive_deletes": True}
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Tables d'association (sans champs d'audit) #
|
# 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):
|
class VolunteerArtist(SQLModel, table=True):
|
||||||
"""Vœu d'un bénévole : « je veux voir cet artiste »."""
|
"""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")
|
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):
|
class Artist(TimestampMixin, table=True):
|
||||||
"""Artiste programmé sur l'édition.
|
"""Artiste programmé sur l'édition.
|
||||||
|
|
||||||
@@ -194,11 +144,6 @@ class Volunteer(TimestampMixin, table=True):
|
|||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
comment: 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(
|
wished_artists: list[Artist] = Relationship(
|
||||||
back_populates="fans",
|
back_populates="fans",
|
||||||
link_model=VolunteerArtist,
|
link_model=VolunteerArtist,
|
||||||
@@ -319,25 +264,25 @@ class Shift(TimestampMixin, table=True):
|
|||||||
)
|
)
|
||||||
|
|
||||||
position: Position = Relationship(back_populates="shifts")
|
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(
|
assignments: list["Assignment"] = Relationship(
|
||||||
back_populates="shift", sa_relationship_kwargs=_PASSIVE
|
back_populates="shift", sa_relationship_kwargs=_PASSIVE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Availability(TimestampMixin, table=True):
|
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__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint(
|
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"
|
foreign_key="volunteer.id", index=True, ondelete="CASCADE"
|
||||||
)
|
)
|
||||||
date: dt.date
|
date: dt.date
|
||||||
day_part_id: uuid.UUID = Field(
|
start_time: dt.time
|
||||||
foreign_key="day_part.id", index=True, ondelete="CASCADE"
|
end_time: dt.time
|
||||||
)
|
|
||||||
status: AvailabilityStatus = Field(
|
|
||||||
sa_column=Column(
|
|
||||||
SAEnum(AvailabilityStatus, native_enum=False), nullable=False
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
volunteer: Volunteer = Relationship(back_populates="availabilities")
|
volunteer: Volunteer = Relationship(back_populates="availabilities")
|
||||||
day_part: DayPart = Relationship(back_populates="availabilities")
|
|
||||||
|
|
||||||
|
|
||||||
class Assignment(TimestampMixin, table=True):
|
class Assignment(TimestampMixin, table=True):
|
||||||
|
|||||||
@@ -13,12 +13,6 @@ class Role(enum.StrEnum):
|
|||||||
VOLUNTEER = "VOLUNTEER"
|
VOLUNTEER = "VOLUNTEER"
|
||||||
|
|
||||||
|
|
||||||
class AvailabilityStatus(enum.StrEnum):
|
|
||||||
AVAILABLE = "AVAILABLE"
|
|
||||||
IF_NEEDED = "IF_NEEDED"
|
|
||||||
UNAVAILABLE = "UNAVAILABLE"
|
|
||||||
|
|
||||||
|
|
||||||
class AssignmentSource(enum.StrEnum):
|
class AssignmentSource(enum.StrEnum):
|
||||||
SOLVER = "SOLVER"
|
SOLVER = "SOLVER"
|
||||||
MANUAL = "MANUAL"
|
MANUAL = "MANUAL"
|
||||||
|
|||||||
+73
-166
@@ -11,6 +11,7 @@ import random
|
|||||||
from collections import Counter
|
from collections import Counter
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from sqlalchemy import delete
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.auth.passwords import hash_password
|
from app.auth.passwords import hash_password
|
||||||
@@ -21,27 +22,22 @@ from app.models import (
|
|||||||
Assignment,
|
Assignment,
|
||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
Availability,
|
Availability,
|
||||||
AvailabilityStatus,
|
|
||||||
DayPart,
|
DayPart,
|
||||||
Edition,
|
Edition,
|
||||||
Position,
|
Position,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
Role,
|
Role,
|
||||||
Shift,
|
Shift,
|
||||||
ShiftRequiredSkill,
|
|
||||||
Skill,
|
|
||||||
Volunteer,
|
Volunteer,
|
||||||
VolunteerSkill,
|
|
||||||
)
|
)
|
||||||
from app.validation import (
|
from app.validation import (
|
||||||
AvailabilityDeclaration,
|
AvailabilityWindow,
|
||||||
DayPartWindow,
|
DayPartWindow,
|
||||||
Severity,
|
Severity,
|
||||||
ShiftInfo,
|
ShiftInfo,
|
||||||
ValidationInput,
|
ValidationInput,
|
||||||
validate,
|
validate,
|
||||||
)
|
)
|
||||||
from app.validation.engine import covered_slots
|
|
||||||
from app.validation.loader import build_validation_input
|
from app.validation.loader import build_validation_input
|
||||||
|
|
||||||
SEED = 20260709
|
SEED = 20260709
|
||||||
@@ -136,19 +132,8 @@ LAST_NAMES = [
|
|||||||
"Nicolas",
|
"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
|
_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]]] = [
|
POSITIONS: list[tuple[str, PositionCategory, str, list[str]]] = [
|
||||||
("Bar central", _C.BAR, "Chapiteau", []),
|
("Bar central", _C.BAR, "Chapiteau", []),
|
||||||
("Bar scène", _C.BAR, "Grande scène", []),
|
("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(
|
dp_windows = tuple(
|
||||||
DayPartWindow(dp.code, dp.start_time, dp.end_time, dp.order) for dp in day_parts
|
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(
|
coordination = Account(
|
||||||
edition_id=edition.id,
|
edition_id=edition.id,
|
||||||
@@ -337,7 +317,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
|||||||
}
|
}
|
||||||
|
|
||||||
volunteers: list[Volunteer] = []
|
volunteers: list[Volunteer] = []
|
||||||
vol_skill_codes: dict[object, set[str]] = {}
|
|
||||||
used_names: set[tuple[str, str]] = set()
|
used_names: set[tuple[str, str]] = set()
|
||||||
for i in range(120):
|
for i in range(120):
|
||||||
while True:
|
while True:
|
||||||
@@ -366,45 +345,29 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
|||||||
volunteer_id=volunteer.id,
|
volunteer_id=volunteer.id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
count = rng.choices([0, 1, 2, 3], weights=[0.3, 0.34, 0.24, 0.12])[0]
|
# Disponibilités : chaque bénévole déclare, pour ~80 % des jours, une
|
||||||
codes = set(rng.sample(list(skills), k=count))
|
# large fenêtre couvrant la journée et la nuit (06:00 → 04:00 le lendemain).
|
||||||
# Le permis B est courant : on en garantit chez ~40 % des bénévoles
|
_FULL_DAY = (dt.time(6, 0), dt.time(4, 0))
|
||||||
# pour que le parking soit gréable.
|
availabilities: dict[object, list[AvailabilityWindow]] = {}
|
||||||
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]] = {}
|
|
||||||
for volunteer in volunteers:
|
for volunteer in volunteers:
|
||||||
declared: dict[tuple[dt.date, str], AvailabilityStatus] = {}
|
windows: list[AvailabilityWindow] = []
|
||||||
for day in dates:
|
for day in dates:
|
||||||
for dp in day_parts:
|
if rng.random() < 0.8:
|
||||||
if rng.random() < 0.72:
|
start, end = _FULL_DAY
|
||||||
status = rng.choices(
|
session.add(
|
||||||
[
|
Availability(
|
||||||
AvailabilityStatus.AVAILABLE,
|
edition_id=edition.id,
|
||||||
AvailabilityStatus.IF_NEEDED,
|
volunteer_id=volunteer.id,
|
||||||
AvailabilityStatus.UNAVAILABLE,
|
date=day,
|
||||||
],
|
start_time=start,
|
||||||
weights=[0.78, 0.14, 0.08],
|
end_time=end,
|
||||||
)[0]
|
|
||||||
session.add(
|
|
||||||
Availability(
|
|
||||||
edition_id=edition.id,
|
|
||||||
volunteer_id=volunteer.id,
|
|
||||||
date=day,
|
|
||||||
day_part_id=dp.id,
|
|
||||||
status=status,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
declared[(day, dp.code)] = status
|
)
|
||||||
availabilities[volunteer.id] = declared
|
windows.append(AvailabilityWindow(day, start, end))
|
||||||
|
availabilities[volunteer.id] = windows
|
||||||
|
|
||||||
shifts: list[Shift] = []
|
shifts: list[Shift] = []
|
||||||
shift_required: dict[object, set[str]] = {}
|
for position, (_n, category, _l, _r) in zip(positions, POSITIONS, strict=True):
|
||||||
for position, (_n, category, _l, required) in zip(positions, POSITIONS, strict=True):
|
|
||||||
for day in dates:
|
for day in dates:
|
||||||
for start_hour, end_hour in _WINDOWS[category][phase_of[day]]:
|
for start_hour, end_hour in _WINDOWS[category][phase_of[day]]:
|
||||||
min_staff, ideal_staff = _STAFF[category]
|
min_staff, ideal_staff = _STAFF[category]
|
||||||
@@ -420,21 +383,13 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
|||||||
)
|
)
|
||||||
session.add(shift)
|
session.add(shift)
|
||||||
session.flush()
|
session.flush()
|
||||||
for code in required:
|
|
||||||
session.add(ShiftRequiredSkill(shift_id=shift.id, skill_id=skills[code].id))
|
|
||||||
shifts.append(shift)
|
shifts.append(shift)
|
||||||
shift_required[shift.id] = set(required)
|
|
||||||
|
|
||||||
assigned: dict[object, list[Shift]] = {v.id: [] for v in volunteers}
|
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
|
# Trois bénévoles réservés aux scénarios d'avertissement : exclus de
|
||||||
# l'affectation générale pour rester libres.
|
# l'affectation générale pour rester libres.
|
||||||
reserved_unskilled = next(
|
reserved = volunteers[:3]
|
||||||
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_ids = {v.id for v in reserved}
|
reserved_ids = {v.id for v in reserved}
|
||||||
assignable = [v for v in volunteers if v.id not in reserved_ids]
|
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),
|
id=str(shift.id),
|
||||||
starts_at=shift.starts_at,
|
starts_at=shift.starts_at,
|
||||||
ends_at=shift.ends_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(
|
existing_shifts=tuple(
|
||||||
ShiftInfo(id=str(s.id), starts_at=s.starts_at, ends_at=s.ends_at)
|
ShiftInfo(id=str(s.id), starts_at=s.starts_at, ends_at=s.ends_at)
|
||||||
for s in assigned[volunteer.id]
|
for s in assigned[volunteer.id]
|
||||||
),
|
),
|
||||||
availabilities=tuple(
|
availabilities=tuple(availabilities[volunteer.id]),
|
||||||
AvailabilityDeclaration(day, code, status)
|
|
||||||
for (day, code), status in availabilities[volunteer.id].items()
|
|
||||||
),
|
|
||||||
day_parts=dp_windows,
|
day_parts=dp_windows,
|
||||||
)
|
)
|
||||||
return not validate(data)
|
return not validate(data)
|
||||||
@@ -490,15 +440,7 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
|||||||
n_assignments += 1
|
n_assignments += 1
|
||||||
|
|
||||||
n_assignments += _add_demo_warnings(
|
n_assignments += _add_demo_warnings(
|
||||||
session,
|
session, edition, shifts, reserved, assigned, availabilities
|
||||||
edition,
|
|
||||||
shifts,
|
|
||||||
reserved,
|
|
||||||
assigned,
|
|
||||||
availabilities,
|
|
||||||
dp_by_code,
|
|
||||||
day_parts,
|
|
||||||
shift_required,
|
|
||||||
)
|
)
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|
||||||
@@ -526,99 +468,67 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
|||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|
||||||
def _set_availability(session, edition, volunteer, day, day_part, status, availabilities) -> None:
|
def _clear_day(session, volunteer, day, availabilities) -> None:
|
||||||
row = session.exec(
|
session.execute(
|
||||||
select(Availability)
|
delete(Availability)
|
||||||
.where(Availability.volunteer_id == volunteer.id)
|
.where(Availability.volunteer_id == volunteer.id)
|
||||||
.where(Availability.date == day)
|
.where(Availability.date == day)
|
||||||
.where(Availability.day_part_id == day_part.id)
|
)
|
||||||
).first()
|
availabilities[volunteer.id] = [
|
||||||
if row is not None:
|
w for w in availabilities[volunteer.id] if w.date != day
|
||||||
row.status = status
|
]
|
||||||
else:
|
|
||||||
session.add(
|
|
||||||
Availability(
|
def _declare_window(
|
||||||
edition_id=edition.id,
|
session, edition, volunteer, day, start, end, availabilities
|
||||||
volunteer_id=volunteer.id,
|
) -> None:
|
||||||
date=day,
|
session.add(
|
||||||
day_part_id=day_part.id,
|
Availability(
|
||||||
status=status,
|
edition_id=edition.id,
|
||||||
)
|
volunteer_id=volunteer.id,
|
||||||
|
date=day,
|
||||||
|
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(
|
def _add_demo_warnings(
|
||||||
session,
|
session, edition, shifts, reserved, assigned, availabilities
|
||||||
edition,
|
|
||||||
shifts,
|
|
||||||
reserved,
|
|
||||||
assigned,
|
|
||||||
availabilities,
|
|
||||||
dp_by_code,
|
|
||||||
day_parts,
|
|
||||||
shift_required,
|
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Trois affectations qui déclenchent chacune un avertissement, et rien
|
"""Quelques affectations qui déclenchent des avertissements (et rien
|
||||||
d'autre : compétence absente, hors disponibilité, repos insuffisant.
|
d'autre) : hors disponibilité, repos insuffisant. `reserved` = trois
|
||||||
|
bénévoles tenus à l'écart de l'affectation générale.
|
||||||
`reserved` = [bénévole sans compétence, deux autres], tenus à l'écart de
|
|
||||||
l'affectation générale.
|
|
||||||
"""
|
"""
|
||||||
unskilled, reluctant, tired = reserved
|
a, b, tired = reserved
|
||||||
added = 0
|
added = 0
|
||||||
|
|
||||||
# 1. MISSING_REQUIRED_SKILL : sur la régie, un bénévole sans compétence.
|
# OUTSIDE_AVAILABILITY : deux bénévoles affectés un jour où ils n'ont
|
||||||
regie = next(s for s in shifts if "REGIE_SON" in shift_required[s.id])
|
# déclaré aucune disponibilité.
|
||||||
for day, code in covered_slots(regie, day_parts):
|
plain = sorted(shifts, key=lambda s: s.starts_at)
|
||||||
_set_availability(
|
for volunteer, shift in ((a, plain[3]), (b, plain[7])):
|
||||||
session,
|
_clear_day(session, volunteer, shift.starts_at.date(), availabilities)
|
||||||
edition,
|
_add_active(session, edition, volunteer, shift, assigned)
|
||||||
unskilled,
|
added += 1
|
||||||
day,
|
|
||||||
dp_by_code[code],
|
|
||||||
AvailabilityStatus.AVAILABLE,
|
|
||||||
availabilities,
|
|
||||||
)
|
|
||||||
_add_active(session, edition, unskilled, regie, assigned)
|
|
||||||
added += 1
|
|
||||||
|
|
||||||
# 2. OUTSIDE_AVAILABILITY : un créneau de bar sur une demi-journée déclarée
|
# INSUFFICIENT_REST : deux créneaux espacés de moins de 8 h, sur deux
|
||||||
# indisponible.
|
# journées (pour ne pas déclencher le cumul), tous deux couverts par une
|
||||||
bar = next(
|
# disponibilité.
|
||||||
s
|
pair = _find_rest_pair(shifts)
|
||||||
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)
|
|
||||||
if pair is not None:
|
if pair is not None:
|
||||||
first, second = pair
|
first, second = pair
|
||||||
for shift in (first, second):
|
for shift in (first, second):
|
||||||
for day, code in covered_slots(shift, day_parts):
|
_clear_day(session, tired, shift.starts_at.date(), availabilities)
|
||||||
_set_availability(
|
_declare_window(
|
||||||
session,
|
session,
|
||||||
edition,
|
edition,
|
||||||
tired,
|
tired,
|
||||||
day,
|
shift.starts_at.date(),
|
||||||
dp_by_code[code],
|
dt.time(6, 0),
|
||||||
AvailabilityStatus.AVAILABLE,
|
dt.time(4, 0),
|
||||||
availabilities,
|
availabilities,
|
||||||
)
|
)
|
||||||
_add_active(session, edition, tired, first, assigned)
|
_add_active(session, edition, tired, first, assigned)
|
||||||
_add_active(session, edition, tired, second, assigned)
|
_add_active(session, edition, tired, second, assigned)
|
||||||
added += 2
|
added += 2
|
||||||
@@ -626,11 +536,8 @@ def _add_demo_warnings(
|
|||||||
return added
|
return added
|
||||||
|
|
||||||
|
|
||||||
def _find_rest_pair(shifts: list[Shift], shift_required: dict) -> tuple[Shift, Shift] | None:
|
def _find_rest_pair(shifts: list[Shift]) -> tuple[Shift, Shift] | None:
|
||||||
plain = sorted(
|
plain = sorted(shifts, key=lambda s: s.starts_at)
|
||||||
(s for s in shifts if not shift_required[s.id]),
|
|
||||||
key=lambda s: s.starts_at,
|
|
||||||
)
|
|
||||||
for i, first in enumerate(plain):
|
for i, first in enumerate(plain):
|
||||||
for second in plain[i + 1 :]:
|
for second in plain[i + 1 :]:
|
||||||
gap = second.starts_at - first.ends_at
|
gap = second.starts_at - first.ends_at
|
||||||
|
|||||||
@@ -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.engine import has_blocking, validate
|
||||||
from app.validation.models import (
|
from app.validation.models import (
|
||||||
AvailabilityDeclaration,
|
AvailabilityWindow,
|
||||||
DayPartWindow,
|
DayPartWindow,
|
||||||
Severity,
|
Severity,
|
||||||
ShiftInfo,
|
ShiftInfo,
|
||||||
@@ -15,7 +15,7 @@ from app.validation.models import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AvailabilityDeclaration",
|
"AvailabilityWindow",
|
||||||
"DayPartWindow",
|
"DayPartWindow",
|
||||||
"Severity",
|
"Severity",
|
||||||
"ShiftInfo",
|
"ShiftInfo",
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ sortie. Règles et sémantique figées : voir CLAUDE.md § 3.
|
|||||||
|
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
|
|
||||||
from app.models import AvailabilityStatus
|
|
||||||
from app.validation.models import (
|
from app.validation.models import (
|
||||||
|
AvailabilityWindow,
|
||||||
Severity,
|
Severity,
|
||||||
ShiftInfo,
|
ShiftInfo,
|
||||||
ValidationInput,
|
ValidationInput,
|
||||||
@@ -24,7 +24,6 @@ def validate(data: ValidationInput) -> list[Violation]:
|
|||||||
violations.extend(_check_outside_availability(data))
|
violations.extend(_check_outside_availability(data))
|
||||||
violations.extend(_check_daily_hours(data))
|
violations.extend(_check_daily_hours(data))
|
||||||
violations.extend(_check_insufficient_rest(data))
|
violations.extend(_check_insufficient_rest(data))
|
||||||
violations.extend(_check_missing_skills(data))
|
|
||||||
return violations
|
return violations
|
||||||
|
|
||||||
|
|
||||||
@@ -58,59 +57,51 @@ def _check_overlap(data: ValidationInput) -> list[Violation]:
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# OUTSIDE_AVAILABILITY (avertissement) #
|
# OUTSIDE_AVAILABILITY (avertissement) #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
def covered_slots(
|
def _window_interval(w: AvailabilityWindow) -> tuple[dt.datetime, dt.datetime]:
|
||||||
shift: ShiftInfo, day_parts: tuple
|
start = dt.datetime.combine(w.date, w.start_time)
|
||||||
) -> list[tuple[dt.date, str]]:
|
end_day = w.date if w.end_time > w.start_time else w.date + dt.timedelta(days=1)
|
||||||
"""Demi-journées (date, code) dont la fenêtre horaire recoupe le créneau."""
|
return start, dt.datetime.combine(end_day, w.end_time)
|
||||||
if not day_parts:
|
|
||||||
return []
|
|
||||||
slots: list[tuple[dt.date, str]] = []
|
def uncovered_minutes(
|
||||||
day = shift.starts_at.date() - dt.timedelta(days=1)
|
shift: ShiftInfo, windows: tuple[AvailabilityWindow, ...]
|
||||||
last = shift.ends_at.date()
|
) -> int:
|
||||||
while day <= last:
|
"""Minutes du créneau qui ne tombent dans aucune fenêtre de disponibilité.
|
||||||
for part in day_parts:
|
|
||||||
window_start = dt.datetime.combine(day, part.start_time)
|
Le bénévole peut déclarer plusieurs fenêtres ; on regarde leur union.
|
||||||
if part.end_time > part.start_time:
|
"""
|
||||||
window_end = dt.datetime.combine(day, part.end_time)
|
cursor = shift.starts_at
|
||||||
else:
|
intervals = sorted(
|
||||||
window_end = dt.datetime.combine(
|
(iv for iv in map(_window_interval, windows) if iv[1] > iv[0]),
|
||||||
day + dt.timedelta(days=1), part.end_time
|
key=lambda iv: iv[0],
|
||||||
)
|
)
|
||||||
if shift.starts_at < window_end and window_start < shift.ends_at:
|
gap = dt.timedelta()
|
||||||
slots.append((day, part.code))
|
for start, end in intervals:
|
||||||
day += dt.timedelta(days=1)
|
if end <= cursor:
|
||||||
return slots
|
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]:
|
def _check_outside_availability(data: ValidationInput) -> list[Violation]:
|
||||||
slots = covered_slots(data.candidate_shift, data.day_parts)
|
missing = uncovered_minutes(data.candidate_shift, data.availabilities)
|
||||||
if not slots:
|
if missing == 0:
|
||||||
return []
|
return []
|
||||||
declared = {
|
return [
|
||||||
(a.date, a.day_part_code): a.status for a in data.availabilities
|
Violation.of(
|
||||||
}
|
ViolationCode.OUTSIDE_AVAILABILITY,
|
||||||
out: list[Violation] = []
|
date=data.candidate_shift.starts_at.date().isoformat(),
|
||||||
for day, code in slots:
|
uncovered_minutes=missing,
|
||||||
status = declared.get((day, code))
|
)
|
||||||
if status is None:
|
]
|
||||||
out.append(
|
|
||||||
Violation.of(
|
|
||||||
ViolationCode.OUTSIDE_AVAILABILITY,
|
|
||||||
date=day.isoformat(),
|
|
||||||
day_part_code=code,
|
|
||||||
status="UNDECLARED",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
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
|
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
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -12,45 +12,16 @@ from app.models import (
|
|||||||
DayPart,
|
DayPart,
|
||||||
Edition,
|
Edition,
|
||||||
Shift,
|
Shift,
|
||||||
ShiftRequiredSkill,
|
|
||||||
Skill,
|
|
||||||
Volunteer,
|
Volunteer,
|
||||||
VolunteerSkill,
|
|
||||||
)
|
)
|
||||||
from app.validation.models import (
|
from app.validation.models import (
|
||||||
AvailabilityDeclaration,
|
AvailabilityWindow,
|
||||||
DayPartWindow,
|
DayPartWindow,
|
||||||
ShiftInfo,
|
ShiftInfo,
|
||||||
ValidationInput,
|
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(
|
def build_validation_input(
|
||||||
*,
|
*,
|
||||||
session: Session,
|
session: Session,
|
||||||
@@ -62,7 +33,6 @@ def build_validation_input(
|
|||||||
day_parts = list(
|
day_parts = list(
|
||||||
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
|
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
|
# Toute affectation existante du bénévole compte (une affectation = une
|
||||||
# décision, toujours active).
|
# décision, toujours active).
|
||||||
@@ -80,10 +50,8 @@ def build_validation_input(
|
|||||||
)
|
)
|
||||||
|
|
||||||
declarations = tuple(
|
declarations = tuple(
|
||||||
AvailabilityDeclaration(
|
AvailabilityWindow(
|
||||||
date=a.date,
|
date=a.date, start_time=a.start_time, end_time=a.end_time
|
||||||
day_part_code=code_by_day_part_id.get(a.day_part_id, ""),
|
|
||||||
status=a.status,
|
|
||||||
)
|
)
|
||||||
for a in session.exec(
|
for a in session.exec(
|
||||||
select(Availability).where(
|
select(Availability).where(
|
||||||
@@ -97,9 +65,7 @@ def build_validation_input(
|
|||||||
id=str(shift.id),
|
id=str(shift.id),
|
||||||
starts_at=shift.starts_at,
|
starts_at=shift.starts_at,
|
||||||
ends_at=shift.ends_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,
|
existing_shifts=existing_shifts,
|
||||||
availabilities=declarations,
|
availabilities=declarations,
|
||||||
day_parts=tuple(
|
day_parts=tuple(
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ from enum import StrEnum
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.models import AvailabilityStatus
|
|
||||||
|
|
||||||
|
|
||||||
class Severity(StrEnum):
|
class Severity(StrEnum):
|
||||||
BLOCKING = "BLOCKING"
|
BLOCKING = "BLOCKING"
|
||||||
@@ -23,7 +21,6 @@ class ViolationCode(StrEnum):
|
|||||||
OUTSIDE_AVAILABILITY = "OUTSIDE_AVAILABILITY"
|
OUTSIDE_AVAILABILITY = "OUTSIDE_AVAILABILITY"
|
||||||
DAILY_HOURS_EXCEEDED = "DAILY_HOURS_EXCEEDED"
|
DAILY_HOURS_EXCEEDED = "DAILY_HOURS_EXCEEDED"
|
||||||
INSUFFICIENT_REST = "INSUFFICIENT_REST"
|
INSUFFICIENT_REST = "INSUFFICIENT_REST"
|
||||||
MISSING_REQUIRED_SKILL = "MISSING_REQUIRED_SKILL"
|
|
||||||
|
|
||||||
|
|
||||||
SEVERITY_BY_CODE: dict[ViolationCode, Severity] = {
|
SEVERITY_BY_CODE: dict[ViolationCode, Severity] = {
|
||||||
@@ -31,7 +28,6 @@ SEVERITY_BY_CODE: dict[ViolationCode, Severity] = {
|
|||||||
ViolationCode.OUTSIDE_AVAILABILITY: Severity.WARNING,
|
ViolationCode.OUTSIDE_AVAILABILITY: Severity.WARNING,
|
||||||
ViolationCode.DAILY_HOURS_EXCEEDED: Severity.WARNING,
|
ViolationCode.DAILY_HOURS_EXCEEDED: Severity.WARNING,
|
||||||
ViolationCode.INSUFFICIENT_REST: Severity.WARNING,
|
ViolationCode.INSUFFICIENT_REST: Severity.WARNING,
|
||||||
ViolationCode.MISSING_REQUIRED_SKILL: Severity.WARNING,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -53,11 +49,13 @@ class ShiftInfo:
|
|||||||
id: str
|
id: str
|
||||||
starts_at: dt.datetime
|
starts_at: dt.datetime
|
||||||
ends_at: dt.datetime
|
ends_at: dt.datetime
|
||||||
required_skill_codes: frozenset[str] = frozenset()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class DayPartWindow:
|
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
|
code: str
|
||||||
start_time: dt.time
|
start_time: dt.time
|
||||||
# end_time <= start_time ⇒ la demi-journée passe minuit.
|
# end_time <= start_time ⇒ la demi-journée passe minuit.
|
||||||
@@ -66,10 +64,15 @@ class DayPartWindow:
|
|||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@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
|
date: dt.date
|
||||||
day_part_code: str
|
start_time: dt.time
|
||||||
status: AvailabilityStatus
|
end_time: dt.time
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -81,7 +84,6 @@ class ValidationInput:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
candidate_shift: ShiftInfo
|
candidate_shift: ShiftInfo
|
||||||
volunteer_skill_codes: frozenset[str] = frozenset()
|
|
||||||
existing_shifts: tuple[ShiftInfo, ...] = ()
|
existing_shifts: tuple[ShiftInfo, ...] = ()
|
||||||
availabilities: tuple[AvailabilityDeclaration, ...] = ()
|
availabilities: tuple[AvailabilityWindow, ...] = ()
|
||||||
day_parts: tuple[DayPartWindow, ...] = ()
|
day_parts: tuple[DayPartWindow, ...] = ()
|
||||||
|
|||||||
+19
-53
@@ -5,13 +5,10 @@ import datetime as dt
|
|||||||
from app.models import (
|
from app.models import (
|
||||||
Artist,
|
Artist,
|
||||||
Availability,
|
Availability,
|
||||||
AvailabilityStatus,
|
|
||||||
Position,
|
Position,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
Shift,
|
Shift,
|
||||||
Skill,
|
|
||||||
Volunteer,
|
Volunteer,
|
||||||
VolunteerSkill,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -36,7 +33,7 @@ def make_artist(
|
|||||||
|
|
||||||
|
|
||||||
def make_shift(
|
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(
|
shift = Shift(
|
||||||
edition_id=edition.id,
|
edition_id=edition.id,
|
||||||
@@ -47,16 +44,13 @@ def make_shift(
|
|||||||
ideal_staff=ideal_staff,
|
ideal_staff=ideal_staff,
|
||||||
)
|
)
|
||||||
session.add(shift)
|
session.add(shift)
|
||||||
session.flush()
|
|
||||||
for code in skills:
|
|
||||||
_link_skill(session, edition, code=code, shift=shift)
|
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(shift)
|
session.refresh(shift)
|
||||||
return shift
|
return shift
|
||||||
|
|
||||||
|
|
||||||
def make_volunteer(
|
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(
|
volunteer = Volunteer(
|
||||||
edition_id=edition.id,
|
edition_id=edition.id,
|
||||||
@@ -65,66 +59,38 @@ def make_volunteer(
|
|||||||
email=email,
|
email=email,
|
||||||
)
|
)
|
||||||
session.add(volunteer)
|
session.add(volunteer)
|
||||||
session.flush()
|
|
||||||
for code in skills:
|
|
||||||
_link_skill(session, edition, volunteer=volunteer, code=code)
|
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(volunteer)
|
session.refresh(volunteer)
|
||||||
return 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(
|
row = Availability(
|
||||||
edition_id=edition.id,
|
edition_id=edition.id,
|
||||||
volunteer_id=volunteer.id,
|
volunteer_id=volunteer.id,
|
||||||
day_part_id=day_part.id,
|
|
||||||
date=date,
|
date=date,
|
||||||
status=status,
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
)
|
)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
session.commit()
|
session.commit()
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
def declare_available_all_day(session, edition, volunteer, day_parts, date):
|
def declare_available_all_day(session, edition, volunteer, date):
|
||||||
for part in day_parts.values():
|
"""Disponible de 00:00 à 23:59 le jour donné (couvre tout un créneau
|
||||||
make_availability(
|
diurne ; pour la nuit, déclarer une fenêtre dédiée)."""
|
||||||
session,
|
make_availability(
|
||||||
edition,
|
session,
|
||||||
volunteer,
|
edition,
|
||||||
part,
|
volunteer,
|
||||||
date=date,
|
date=date,
|
||||||
status=AvailabilityStatus.AVAILABLE,
|
start_time=dt.time(0, 0),
|
||||||
)
|
end_time=dt.time(23, 59),
|
||||||
|
)
|
||||||
|
|
||||||
def _get_or_create_skill(session, edition, code) -> Skill:
|
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
skill = session.exec(
|
|
||||||
select(Skill)
|
|
||||||
.where(Skill.edition_id == edition.id)
|
|
||||||
.where(Skill.code == code)
|
|
||||||
).first()
|
|
||||||
if skill is None:
|
|
||||||
skill = Skill(edition_id=edition.id, code=code, label=code)
|
|
||||||
session.add(skill)
|
|
||||||
session.flush()
|
|
||||||
return skill
|
|
||||||
|
|
||||||
|
|
||||||
def _link_skill(session, edition, *, code, volunteer=None, shift=None):
|
|
||||||
from app.models import ShiftRequiredSkill
|
|
||||||
|
|
||||||
skill = _get_or_create_skill(session, edition, code)
|
|
||||||
if volunteer is not None:
|
|
||||||
session.add(
|
|
||||||
VolunteerSkill(volunteer_id=volunteer.id, skill_id=skill.id)
|
|
||||||
)
|
|
||||||
if shift is not None:
|
|
||||||
session.add(
|
|
||||||
ShiftRequiredSkill(shift_id=shift.id, skill_id=skill.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def dt_at(day: int, hour: int, minute: int = 0) -> dt.datetime:
|
def dt_at(day: int, hour: int, minute: int = 0) -> dt.datetime:
|
||||||
|
|||||||
@@ -13,21 +13,21 @@ def _url(edition, suffix=""):
|
|||||||
return f"/editions/{edition.id}/assignments{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)
|
position = make_position(session, edition)
|
||||||
shift = make_shift(
|
shift = make_shift(
|
||||||
session, edition, position, start=dt_at(10, 14), end=dt_at(10, 18)
|
session, edition, position, start=dt_at(10, 14), end=dt_at(10, 18)
|
||||||
)
|
)
|
||||||
volunteer = make_volunteer(session, edition, email="v@ex.fr", skills=skills)
|
volunteer = make_volunteer(session, edition, email="v@ex.fr")
|
||||||
if available:
|
if available:
|
||||||
declare_available_all_day(
|
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
|
return volunteer, shift
|
||||||
|
|
||||||
|
|
||||||
def test_create_assignment_clean(client, edition, session, day_parts):
|
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(
|
resp = client.post(
|
||||||
_url(edition),
|
_url(edition),
|
||||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
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):
|
def test_create_assignment_returns_warnings(client, edition, session, day_parts):
|
||||||
# Bénévole non déclaré disponible + compétence requise absente.
|
# Bénévole non déclaré disponible sur le créneau.
|
||||||
volunteer, shift = _setup(session, edition, day_parts, available=False)
|
volunteer, shift = _setup(session, edition, available=False)
|
||||||
position = make_position(session, edition, name="Régie")
|
|
||||||
shift_with_skill = make_shift(
|
|
||||||
session,
|
|
||||||
edition,
|
|
||||||
position,
|
|
||||||
start=dt_at(10, 14),
|
|
||||||
end=dt_at(10, 18),
|
|
||||||
skills=["SOUND"],
|
|
||||||
)
|
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
_url(edition),
|
_url(edition),
|
||||||
json={
|
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
||||||
"volunteer_id": str(volunteer.id),
|
|
||||||
"shift_id": str(shift_with_skill.id),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
codes = {w["code"] for w in resp.json()["warnings"]}
|
codes = {w["code"] for w in resp.json()["warnings"]}
|
||||||
assert "MISSING_REQUIRED_SKILL" in codes
|
|
||||||
assert "OUTSIDE_AVAILABILITY" in codes
|
assert "OUTSIDE_AVAILABILITY" in codes
|
||||||
|
|
||||||
|
|
||||||
def test_create_assignment_blocked_by_overlap(client, edition, session, day_parts):
|
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(
|
assert client.post(
|
||||||
_url(edition),
|
_url(edition),
|
||||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
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):
|
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)}
|
body = {"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)}
|
||||||
assert client.post(_url(edition), json=body).status_code == 201
|
assert client.post(_url(edition), json=body).status_code == 201
|
||||||
resp = client.post(_url(edition), json=body)
|
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.commit()
|
||||||
session.refresh(other)
|
session.refresh(other)
|
||||||
stranger = make_volunteer(session, other, email="s@ex.fr")
|
stranger = make_volunteer(session, other, email="s@ex.fr")
|
||||||
_, shift = _setup(session, edition, day_parts)
|
_, shift = _setup(session, edition)
|
||||||
|
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
_url(edition),
|
_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):
|
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(
|
resp = client.post(
|
||||||
_url(edition, "/validate"),
|
_url(edition, "/validate"),
|
||||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
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(
|
def test_patch_locked_keeps_assignment_and_returns_warnings(
|
||||||
client, edition, session, day_parts
|
client, edition, session, day_parts
|
||||||
):
|
):
|
||||||
volunteer, shift = _setup(session, edition, day_parts, available=False)
|
volunteer, shift = _setup(session, edition, available=False)
|
||||||
aid = client.post(
|
aid = client.post(
|
||||||
_url(edition),
|
_url(edition),
|
||||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
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):
|
def test_delete_assignment(client, edition, session, day_parts):
|
||||||
volunteer, shift = _setup(session, edition, day_parts)
|
volunteer, shift = _setup(session, edition)
|
||||||
aid = client.post(
|
aid = client.post(
|
||||||
_url(edition),
|
_url(edition),
|
||||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
import datetime as dt
|
from app.models import Role, Shift
|
||||||
|
from tests.factories import dt_at, make_position, make_shift
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
from app.models import Availability, AvailabilityStatus, Role, Shift
|
|
||||||
from tests.factories import dt_at, make_position, make_shift, make_volunteer
|
|
||||||
|
|
||||||
|
|
||||||
def _url(edition, suffix=""):
|
def _url(edition, suffix=""):
|
||||||
@@ -46,29 +42,18 @@ def test_update_day_part(client, edition):
|
|||||||
assert resp.json()["end_time"] == "12:00:00"
|
assert resp.json()["end_time"] == "12:00:00"
|
||||||
|
|
||||||
|
|
||||||
def test_delete_unlinks_shifts_and_removes_availabilities(
|
def test_delete_sets_shift_day_part_to_null(client, edition, session, day_parts):
|
||||||
client, edition, session, day_parts
|
|
||||||
):
|
|
||||||
morning = day_parts["MORNING"]
|
morning = day_parts["MORNING"]
|
||||||
position = make_position(session, edition)
|
position = make_position(session, edition)
|
||||||
shift = make_shift(
|
shift = make_shift(
|
||||||
session, edition, position, start=dt_at(10, 6), end=dt_at(10, 13)
|
session, edition, position, start=dt_at(10, 6), end=dt_at(10, 13)
|
||||||
)
|
)
|
||||||
shift.day_part_id = morning.id
|
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()
|
session.commit()
|
||||||
|
|
||||||
assert client.delete(_url(edition, f"/{morning.id}")).status_code == 204
|
assert client.delete(_url(edition, f"/{morning.id}")).status_code == 204
|
||||||
session.expire_all()
|
session.expire_all()
|
||||||
assert session.get(Shift, shift.id).day_part_id is None
|
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):
|
def test_write_is_coordinator_only(edition, make_account, as_account):
|
||||||
|
|||||||
@@ -14,18 +14,16 @@ def _payload(position, **overrides):
|
|||||||
"ends_at": "2026-07-10T22:00:00",
|
"ends_at": "2026-07-10T22:00:00",
|
||||||
"min_staff": 2,
|
"min_staff": 2,
|
||||||
"ideal_staff": 4,
|
"ideal_staff": 4,
|
||||||
"required_skill_codes": ["SST"],
|
|
||||||
}
|
}
|
||||||
base.update(overrides)
|
base.update(overrides)
|
||||||
return base
|
return base
|
||||||
|
|
||||||
|
|
||||||
def test_create_shift_with_required_skills(client, edition, session):
|
def test_create_shift(client, edition, session):
|
||||||
position = make_position(session, edition)
|
position = make_position(session, edition)
|
||||||
resp = client.post(_url(edition), json=_payload(position))
|
resp = client.post(_url(edition), json=_payload(position))
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["required_skill_codes"] == ["SST"]
|
|
||||||
assert body["min_staff"] == 2
|
assert body["min_staff"] == 2
|
||||||
assert body["position_id"] == str(position.id)
|
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
|
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)
|
position = make_position(session, edition)
|
||||||
sid = client.post(_url(edition), json=_payload(position)).json()["id"]
|
sid = client.post(_url(edition), json=_payload(position)).json()["id"]
|
||||||
|
|
||||||
patched = client.patch(
|
patched = client.patch(
|
||||||
_url(edition, f"/{sid}"),
|
_url(edition, f"/{sid}"),
|
||||||
json={
|
json={"ends_at": "2026-07-10T23:30:00"},
|
||||||
"ends_at": "2026-07-10T23:30:00",
|
|
||||||
"required_skill_codes": ["SOUND", "SST"],
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert patched.status_code == 200
|
assert patched.status_code == 200
|
||||||
assert patched.json()["ends_at"] == "2026-07-10T23:30:00"
|
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):
|
def test_patch_shift_into_invalid_range_is_422(client, edition, session):
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import uuid
|
|||||||
|
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
|
||||||
from app.models import Assignment, Availability, AvailabilityStatus, Volunteer
|
from app.models import Assignment, Availability, Volunteer
|
||||||
from tests.factories import (
|
from tests.factories import (
|
||||||
dt_at,
|
dt_at,
|
||||||
make_artist,
|
make_artist,
|
||||||
@@ -18,20 +18,20 @@ def _url(edition, suffix=""):
|
|||||||
return f"/editions/{edition.id}/volunteers{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(
|
resp = client.post(
|
||||||
_url(edition),
|
_url(edition),
|
||||||
json={
|
json={
|
||||||
"first_name": "Alex",
|
"first_name": "Alex",
|
||||||
"last_name": "Martin",
|
"last_name": "Martin",
|
||||||
"email": "alex@ex.fr",
|
"email": "alex@ex.fr",
|
||||||
"skill_codes": ["SST", "PERMIS_B", "SST"],
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["skill_codes"] == ["PERMIS_B", "SST"]
|
|
||||||
assert body["edition_id"] == str(edition.id)
|
assert body["edition_id"] == str(edition.id)
|
||||||
|
assert body["artist_ids"] == []
|
||||||
|
assert body["wished_position_ids"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_create_duplicate_email_conflicts(client, edition):
|
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"] == []
|
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")
|
volunteer = make_volunteer(session, edition, email="p@ex.fr")
|
||||||
r1 = client.patch(
|
r1 = client.patch(
|
||||||
_url(edition, f"/{volunteer.id}"),
|
_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.status_code == 200
|
||||||
assert r1.json()["first_name"] == "Neo"
|
assert r1.json()["first_name"] == "Neo"
|
||||||
assert r1.json()["skill_codes"] == ["SST"]
|
assert r1.json()["comment"] == "dispo tôt"
|
||||||
|
|
||||||
r2 = client.patch(
|
|
||||||
_url(edition, f"/{volunteer.id}"), json={"skill_codes": []}
|
|
||||||
)
|
|
||||||
assert r2.json()["skill_codes"] == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_is_a_full_cascade(client, edition, session, day_parts):
|
def test_delete_is_a_full_cascade(client, edition, session):
|
||||||
volunteer = make_volunteer(session, edition, email="gone@ex.fr")
|
volunteer = make_volunteer(session, edition, email="gone@ex.fr")
|
||||||
position = make_position(session, edition)
|
position = make_position(session, edition)
|
||||||
shift = make_shift(
|
shift = make_shift(
|
||||||
@@ -157,9 +152,9 @@ def test_delete_is_a_full_cascade(client, edition, session, day_parts):
|
|||||||
session,
|
session,
|
||||||
edition,
|
edition,
|
||||||
volunteer,
|
volunteer,
|
||||||
day_parts["MORNING"],
|
|
||||||
date=dt.date(2026, 7, 10),
|
date=dt.date(2026, 7, 10),
|
||||||
status=AvailabilityStatus.AVAILABLE,
|
start_time=dt.time(8, 0),
|
||||||
|
end_time=dt.time(13, 0),
|
||||||
)
|
)
|
||||||
session.add(
|
session.add(
|
||||||
Assignment(
|
Assignment(
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ from sqlmodel import select
|
|||||||
from app.models import Volunteer
|
from app.models import Volunteer
|
||||||
|
|
||||||
_CSV = (
|
_CSV = (
|
||||||
"Prénom;Nom;Email;Compétences;Ven matin\n"
|
"Prénom;Nom;Email;Téléphone\n"
|
||||||
"Alex;Martin;alex@ex.fr;SST, Permis B;x\n"
|
"Alex;Martin;alex@ex.fr;0600000000\n"
|
||||||
"Sam;Nguyen;sam@ex.fr;SST;si besoin\n"
|
"Sam;Nguyen;sam@ex.fr;\n"
|
||||||
).encode()
|
).encode()
|
||||||
|
|
||||||
|
|
||||||
@@ -20,20 +20,13 @@ def _mapping(dry_run: bool) -> str:
|
|||||||
"first_name": "Prénom",
|
"first_name": "Prénom",
|
||||||
"last_name": "Nom",
|
"last_name": "Nom",
|
||||||
"email": "Email",
|
"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(
|
resp = client.post(
|
||||||
f"/editions/{edition.id}/import/analyze",
|
f"/editions/{edition.id}/import/analyze",
|
||||||
files={"file": ("benevoles.csv", _CSV, "text/csv")},
|
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
|
assert resp.status_code == 200
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["format"] == "csv"
|
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["row_count"] == 2
|
||||||
assert body["preview"][0]["values"]["Nom"] == "Martin"
|
assert body["preview"][0]["values"]["Nom"] == "Martin"
|
||||||
assert body["suggested_mapping"]["first_name"] == "Prénom"
|
assert body["suggested_mapping"]["first_name"] == "Prénom"
|
||||||
assert {d["code"] for d in body["day_parts"]} == {
|
assert body["suggested_mapping"]["phone"] == "Téléphone"
|
||||||
"MORNING",
|
|
||||||
"AFTERNOON",
|
|
||||||
"EVENING",
|
|
||||||
}
|
|
||||||
assert body["edition_dates"][0] == "2026-07-09"
|
|
||||||
|
|
||||||
|
|
||||||
def test_execute_dry_run_then_real(client, session, edition):
|
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)},
|
data={"mapping": _mapping(dry_run=False)},
|
||||||
)
|
)
|
||||||
assert real.status_code == 200
|
assert real.status_code == 200
|
||||||
report = real.json()
|
assert real.json()["created"] == 2
|
||||||
assert report["created"] == 2
|
|
||||||
assert sorted(report["skills_created"]) == ["PERMIS_B", "SST"]
|
|
||||||
assert len(session.exec(select(Volunteer)).all()) == 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(
|
bad_mapping = json.dumps(
|
||||||
{
|
{
|
||||||
"dry_run": True,
|
"dry_run": True,
|
||||||
"columns": {"first_name": "Prénom", "last_name": "Nom"},
|
"columns": {"first_name": "Prénom", "last_name": "Absente"},
|
||||||
"availability_columns": [
|
|
||||||
{
|
|
||||||
"header": "Ven matin",
|
|
||||||
"date": "2026-07-10",
|
|
||||||
"day_part_code": "NIGHT",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
@@ -98,7 +77,7 @@ def test_execute_rejects_invalid_mapping(client, edition):
|
|||||||
detail = resp.json()["detail"]
|
detail = resp.json()["detail"]
|
||||||
assert detail["code"] == "import.mapping.invalid"
|
assert detail["code"] == "import.mapping.invalid"
|
||||||
assert any(
|
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"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
import datetime as dt
|
|
||||||
|
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
|
||||||
from app.importing.parser import ParsedTable
|
from app.importing.parser import ParsedTable
|
||||||
from app.importing.schemas import (
|
from app.importing.schemas import ColumnMapping, ImportMapping
|
||||||
AvailabilityColumnMapping,
|
|
||||||
ColumnMapping,
|
|
||||||
ImportMapping,
|
|
||||||
)
|
|
||||||
from app.importing.service import run_import, suggest_mapping, validate_mapping
|
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:
|
def _mapping(dry_run: bool = False) -> ImportMapping:
|
||||||
@@ -21,18 +15,9 @@ def _mapping(dry_run: bool = False) -> ImportMapping:
|
|||||||
first_name="Prénom",
|
first_name="Prénom",
|
||||||
last_name="Nom",
|
last_name="Nom",
|
||||||
email="Email",
|
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"
|
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(
|
report = run_import(
|
||||||
session=session,
|
session=session,
|
||||||
edition=edition,
|
edition=edition,
|
||||||
table=_table(
|
table=_table(
|
||||||
[
|
[
|
||||||
["Alex", "Martin", "alex@ex.fr", "M", "SST, Permis B", "x", "non"],
|
["Alex", "Martin", "alex@ex.fr", "0600000000", "arrive tôt"],
|
||||||
["Sam", "Nguyen", "sam@ex.fr", "L", "SST", "si besoin", ""],
|
["Sam", "Nguyen", "sam@ex.fr", "", ""],
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
mapping=_mapping(),
|
mapping=_mapping(),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert (report.created, report.updated, report.skipped) == (2, 0, 0)
|
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)}
|
volunteers = {v.email: v for v in _volunteers(session, edition)}
|
||||||
assert set(volunteers) == {"alex@ex.fr", "sam@ex.fr"}
|
assert set(volunteers) == {"alex@ex.fr", "sam@ex.fr"}
|
||||||
assert {s.code for s in volunteers["alex@ex.fr"].skills} == {"SST", "PERMIS_B"}
|
assert volunteers["alex@ex.fr"].phone == "0600000000"
|
||||||
|
assert volunteers["alex@ex.fr"].comment == "arrive tôt"
|
||||||
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"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_dry_run_persists_nothing(session, edition):
|
def test_dry_run_persists_nothing(session, edition):
|
||||||
report = run_import(
|
report = run_import(
|
||||||
session=session,
|
session=session,
|
||||||
edition=edition,
|
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),
|
mapping=_mapping(dry_run=True),
|
||||||
)
|
)
|
||||||
assert report.created == 1
|
assert report.created == 1
|
||||||
assert report.dry_run is True
|
assert report.dry_run is True
|
||||||
assert _volunteers(session, edition) == []
|
assert _volunteers(session, edition) == []
|
||||||
assert session.exec(select(Skill)).all() == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_existing_email_is_updated_not_duplicated(session, edition):
|
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(
|
report = run_import(
|
||||||
session=session,
|
session=session,
|
||||||
edition=edition,
|
edition=edition,
|
||||||
table=_table([["Alex", "Martin", "ALEX@ex.fr", "M", "SST", "x", ""]]),
|
table=_table([["Alex", "Martin", "ALEX@ex.fr", "", ""]]),
|
||||||
mapping=_mapping(),
|
mapping=_mapping(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -133,8 +108,8 @@ def test_duplicate_email_within_file_is_skipped(session, edition):
|
|||||||
edition=edition,
|
edition=edition,
|
||||||
table=_table(
|
table=_table(
|
||||||
[
|
[
|
||||||
["Alex", "Martin", "dup@ex.fr", "M", "", "x", ""],
|
["Alex", "Martin", "dup@ex.fr", "", ""],
|
||||||
["Alex", "Doublon", "dup@ex.fr", "L", "", "x", ""],
|
["Alex", "Doublon", "dup@ex.fr", "", ""],
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
mapping=_mapping(),
|
mapping=_mapping(),
|
||||||
@@ -161,7 +136,7 @@ def test_row_without_email_matching_existing_name_warns_but_creates(
|
|||||||
report = run_import(
|
report = run_import(
|
||||||
session=session,
|
session=session,
|
||||||
edition=edition,
|
edition=edition,
|
||||||
table=_table([["jean", "DUPONT", "", "", "", "x", ""]]),
|
table=_table([["jean", "DUPONT", "", "", ""]]),
|
||||||
mapping=_mapping(),
|
mapping=_mapping(),
|
||||||
)
|
)
|
||||||
assert report.created == 1
|
assert report.created == 1
|
||||||
@@ -173,7 +148,7 @@ def test_missing_required_field_is_skipped(session, edition):
|
|||||||
report = run_import(
|
report = run_import(
|
||||||
session=session,
|
session=session,
|
||||||
edition=edition,
|
edition=edition,
|
||||||
table=_table([["", "Martin", "x@ex.fr", "", "", "x", ""]]),
|
table=_table([["", "Martin", "x@ex.fr", "", ""]]),
|
||||||
mapping=_mapping(),
|
mapping=_mapping(),
|
||||||
)
|
)
|
||||||
assert report.skipped == 1
|
assert report.skipped == 1
|
||||||
@@ -185,45 +160,18 @@ def test_invalid_email_is_skipped(session, edition):
|
|||||||
report = run_import(
|
report = run_import(
|
||||||
session=session,
|
session=session,
|
||||||
edition=edition,
|
edition=edition,
|
||||||
table=_table([["Alex", "Martin", "pas-un-email", "", "", "x", ""]]),
|
table=_table([["Alex", "Martin", "pas-un-email", "", ""]]),
|
||||||
mapping=_mapping(),
|
mapping=_mapping(),
|
||||||
)
|
)
|
||||||
assert report.skipped == 1
|
assert report.skipped == 1
|
||||||
assert report.rows[0].issues[0].code == "import.row.invalid_email"
|
assert report.rows[0].issues[0].code == "import.row.invalid_email"
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_availability_value_is_flagged(session, edition):
|
def test_validate_mapping_reports_missing_and_unknown_columns():
|
||||||
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))
|
|
||||||
)
|
|
||||||
mapping = ImportMapping(
|
mapping = ImportMapping(
|
||||||
columns=ColumnMapping(first_name="Prénom", last_name="Nom"),
|
columns=ColumnMapping(first_name="Prénom", last_name="", email="Absente")
|
||||||
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",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
errors = validate_mapping(_table([]), mapping, edition, day_parts)
|
errors = validate_mapping(_table([]), mapping)
|
||||||
codes = {e.code for e in errors}
|
codes = {e.code for e in errors}
|
||||||
assert "import.mapping.unknown_day_part" in codes
|
assert "import.mapping.required_field_missing" in codes
|
||||||
assert "import.mapping.date_outside_edition" in codes
|
assert "import.mapping.column_not_found" in codes
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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("") == []
|
|
||||||
@@ -8,30 +8,24 @@ from app.models import (
|
|||||||
Assignment,
|
Assignment,
|
||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
Availability,
|
Availability,
|
||||||
AvailabilityStatus,
|
|
||||||
DayPart,
|
|
||||||
Edition,
|
Edition,
|
||||||
Position,
|
Position,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
Shift,
|
Shift,
|
||||||
Skill,
|
|
||||||
Volunteer,
|
Volunteer,
|
||||||
)
|
)
|
||||||
|
|
||||||
EXPECTED_TABLES = {
|
EXPECTED_TABLES = {
|
||||||
"edition",
|
"edition",
|
||||||
"day_part",
|
"day_part",
|
||||||
"skill",
|
|
||||||
"artist",
|
"artist",
|
||||||
"volunteer",
|
"volunteer",
|
||||||
"volunteer_skill",
|
|
||||||
"volunteer_artist",
|
"volunteer_artist",
|
||||||
"volunteer_position_wish",
|
"volunteer_position_wish",
|
||||||
"account",
|
"account",
|
||||||
"magic_link_token",
|
"magic_link_token",
|
||||||
"position",
|
"position",
|
||||||
"shift",
|
"shift",
|
||||||
"shift_required_skill",
|
|
||||||
"availability",
|
"availability",
|
||||||
"assignment",
|
"assignment",
|
||||||
}
|
}
|
||||||
@@ -65,8 +59,6 @@ def test_create_core_graph(session):
|
|||||||
first_name="Alex",
|
first_name="Alex",
|
||||||
last_name="Martin",
|
last_name="Martin",
|
||||||
)
|
)
|
||||||
skill = Skill(edition_id=edition.id, code="SST", label="Secourisme")
|
|
||||||
volunteer.skills.append(skill)
|
|
||||||
position = Position(
|
position = Position(
|
||||||
edition_id=edition.id, name="Bar principal", category=PositionCategory.BAR
|
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(volunteer)
|
||||||
session.refresh(position)
|
session.refresh(position)
|
||||||
|
|
||||||
# Lien N-N bénévole <-> compétence.
|
|
||||||
assert [s.code for s in volunteer.skills] == ["SST"]
|
|
||||||
|
|
||||||
shift = Shift(
|
shift = Shift(
|
||||||
edition_id=edition.id,
|
edition_id=edition.id,
|
||||||
position_id=position.id,
|
position_id=position.id,
|
||||||
@@ -107,37 +96,29 @@ def test_create_core_graph(session):
|
|||||||
assert volunteer.assignments[0].id == assignment.id
|
assert volunteer.assignments[0].id == assignment.id
|
||||||
|
|
||||||
|
|
||||||
def test_enum_round_trip(session):
|
def test_availability_window_round_trip(session):
|
||||||
edition = _edition(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(
|
volunteer = Volunteer(
|
||||||
edition_id=edition.id, first_name="Sam", last_name="Nuit"
|
edition_id=edition.id, first_name="Sam", last_name="Nuit"
|
||||||
)
|
)
|
||||||
session.add_all([day_part, volunteer])
|
session.add(volunteer)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(day_part)
|
|
||||||
session.refresh(volunteer)
|
session.refresh(volunteer)
|
||||||
|
|
||||||
avail = Availability(
|
avail = Availability(
|
||||||
edition_id=edition.id,
|
edition_id=edition.id,
|
||||||
volunteer_id=volunteer.id,
|
volunteer_id=volunteer.id,
|
||||||
date=dt.date(2026, 7, 9),
|
date=dt.date(2026, 7, 9),
|
||||||
day_part_id=day_part.id,
|
start_time=dt.time(19, 0),
|
||||||
status=AvailabilityStatus.IF_NEEDED,
|
end_time=dt.time(2, 0), # passe minuit
|
||||||
)
|
)
|
||||||
session.add(avail)
|
session.add(avail)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.expire_all()
|
session.expire_all()
|
||||||
|
|
||||||
reloaded = session.get(Availability, avail.id)
|
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):
|
def test_volunteer_email_unique_per_edition(session):
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ règle, plus les cas limites de la sémantique figée (CLAUDE.md § 3)."""
|
|||||||
|
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
|
|
||||||
from app.models import AvailabilityStatus
|
|
||||||
from app.validation import (
|
from app.validation import (
|
||||||
AvailabilityDeclaration,
|
AvailabilityWindow,
|
||||||
DayPartWindow,
|
DayPartWindow,
|
||||||
Severity,
|
Severity,
|
||||||
ShiftInfo,
|
ShiftInfo,
|
||||||
@@ -26,18 +25,20 @@ def _dt(day: int, hour: int, minute: int = 0) -> dt.datetime:
|
|||||||
return dt.datetime(2026, 7, day, hour, minute)
|
return dt.datetime(2026, 7, day, hour, minute)
|
||||||
|
|
||||||
|
|
||||||
def _shift(sid: str, start: dt.datetime, end: dt.datetime, skills=()) -> ShiftInfo:
|
def _shift(sid: str, start: dt.datetime, end: dt.datetime) -> ShiftInfo:
|
||||||
return ShiftInfo(
|
return ShiftInfo(id=sid, starts_at=start, ends_at=end)
|
||||||
id=sid, starts_at=start, ends_at=end, required_skill_codes=frozenset(skills)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _codes(violations) -> list[ViolationCode]:
|
def _codes(violations) -> list[ViolationCode]:
|
||||||
return [v.code for v in violations]
|
return [v.code for v in violations]
|
||||||
|
|
||||||
|
|
||||||
def _avail(day: int, code: str, status: AvailabilityStatus) -> AvailabilityDeclaration:
|
def _win(day: int, start_h: int, end_h: int) -> AvailabilityWindow:
|
||||||
return AvailabilityDeclaration(dt.date(2026, 7, day), code, status)
|
"""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(
|
data = ValidationInput(
|
||||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||||
existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 22)),),
|
existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 22)),),
|
||||||
day_parts=DAY_PARTS,
|
availabilities=(_win(10, 8, 23),),
|
||||||
availabilities=(
|
|
||||||
_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
assert ViolationCode.SHIFT_OVERLAP not in _codes(validate(data))
|
assert ViolationCode.SHIFT_OVERLAP not in _codes(validate(data))
|
||||||
|
|
||||||
@@ -59,6 +57,7 @@ def test_overlap_triggers_and_is_blocking():
|
|||||||
data = ValidationInput(
|
data = ValidationInput(
|
||||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||||
existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),),
|
existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),),
|
||||||
|
availabilities=(_win(10, 8, 23),),
|
||||||
)
|
)
|
||||||
violations = validate(data)
|
violations = validate(data)
|
||||||
overlap = [v for v in violations if v.code is ViolationCode.SHIFT_OVERLAP]
|
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 #
|
# OUTSIDE_AVAILABILITY #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
def test_availability_pass_when_declared_available():
|
def test_availability_pass_when_shift_within_declared_window():
|
||||||
data = ValidationInput(
|
data = ValidationInput(
|
||||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||||
day_parts=DAY_PARTS,
|
availabilities=(_win(10, 13, 19),),
|
||||||
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),),
|
|
||||||
)
|
)
|
||||||
assert validate(data) == []
|
assert validate(data) == []
|
||||||
|
|
||||||
|
|
||||||
def test_availability_pass_when_declared_if_needed():
|
def test_availability_pass_when_window_exactly_matches_shift():
|
||||||
data = ValidationInput(
|
data = ValidationInput(
|
||||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||||
day_parts=DAY_PARTS,
|
availabilities=(_win(10, 14, 18),),
|
||||||
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.IF_NEEDED),),
|
|
||||||
)
|
)
|
||||||
assert validate(data) == []
|
assert validate(data) == []
|
||||||
|
|
||||||
|
|
||||||
def test_availability_triggers_when_slot_unavailable():
|
def test_availability_triggers_when_nothing_declared():
|
||||||
data = ValidationInput(
|
data = ValidationInput(
|
||||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||||
day_parts=DAY_PARTS,
|
|
||||||
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.UNAVAILABLE),),
|
|
||||||
)
|
)
|
||||||
violations = validate(data)
|
violations = validate(data)
|
||||||
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
||||||
assert violations[0].severity is Severity.WARNING
|
assert violations[0].severity is Severity.WARNING
|
||||||
assert violations[0].params["status"] == "UNAVAILABLE"
|
assert violations[0].params["date"] == "2026-07-10"
|
||||||
assert violations[0].params["day_part_code"] == "AFTERNOON"
|
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(
|
data = ValidationInput(
|
||||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
candidate_shift=_shift("c", _dt(10, 16), _dt(10, 20)),
|
||||||
day_parts=DAY_PARTS,
|
availabilities=(_win(10, 13, 19),),
|
||||||
)
|
)
|
||||||
violations = validate(data)
|
violations = validate(data)
|
||||||
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
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():
|
def test_availability_union_of_several_windows_covers_the_shift():
|
||||||
# 18:00 -> 22:00 recoupe AFTERNOON (jusqu'à 19:00) et EVENING (dès 19:00).
|
|
||||||
data = ValidationInput(
|
data = ValidationInput(
|
||||||
candidate_shift=_shift("c", _dt(10, 18), _dt(10, 22)),
|
candidate_shift=_shift("c", _dt(10, 12), _dt(10, 20)),
|
||||||
day_parts=DAY_PARTS,
|
availabilities=(_win(10, 12, 16), _win(10, 15, 20)),
|
||||||
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),),
|
|
||||||
)
|
)
|
||||||
assert validate(data) == []
|
assert validate(data) == []
|
||||||
|
|
||||||
|
|
||||||
def test_availability_rule_skipped_without_day_parts():
|
def test_availability_midnight_crossing_window_covers_night_shift():
|
||||||
data = ValidationInput(
|
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(
|
data = ValidationInput(
|
||||||
candidate_shift=_shift("c", _dt(10, 9), _dt(10, 17)), # 8 h pile
|
candidate_shift=_shift("c", _dt(10, 9), _dt(10, 17)), # 8 h pile
|
||||||
day_parts=DAY_PARTS,
|
day_parts=DAY_PARTS,
|
||||||
availabilities=(
|
availabilities=(_win(10, 8, 18),),
|
||||||
_avail(10, "MORNING", AvailabilityStatus.AVAILABLE),
|
|
||||||
_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
assert ViolationCode.DAILY_HOURS_EXCEEDED not in _codes(validate(data))
|
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(
|
data = ValidationInput(
|
||||||
candidate_shift=_shift("c", _dt(10, 8), _dt(10, 12)),
|
candidate_shift=_shift("c", _dt(10, 8), _dt(10, 12)),
|
||||||
existing_shifts=(_shift("e", _dt(9, 18), _dt(9, 23)),), # écart 9 h
|
existing_shifts=(_shift("e", _dt(9, 18), _dt(9, 23)),), # écart 9 h
|
||||||
day_parts=DAY_PARTS,
|
availabilities=(_win(10, 8, 12),),
|
||||||
availabilities=(_avail(10, "MORNING", AvailabilityStatus.AVAILABLE),),
|
|
||||||
)
|
)
|
||||||
assert ViolationCode.INSUFFICIENT_REST not in _codes(validate(data))
|
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(
|
data = ValidationInput(
|
||||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||||
existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 22)),),
|
existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 22)),),
|
||||||
|
availabilities=(_win(10, 8, 23),),
|
||||||
)
|
)
|
||||||
codes = _codes(validate(data))
|
codes = _codes(validate(data))
|
||||||
assert ViolationCode.SHIFT_OVERLAP not in codes
|
assert ViolationCode.SHIFT_OVERLAP not in codes
|
||||||
assert ViolationCode.INSUFFICIENT_REST 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 #
|
# Combinaisons #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
def test_clean_assignment_has_no_violation():
|
def test_clean_assignment_has_no_violation():
|
||||||
data = ValidationInput(
|
data = ValidationInput(
|
||||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18), skills=["SST"]),
|
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||||
volunteer_skill_codes=frozenset({"SST"}),
|
|
||||||
existing_shifts=(_shift("e", _dt(9, 8), _dt(9, 12)),),
|
existing_shifts=(_shift("e", _dt(9, 8), _dt(9, 12)),),
|
||||||
day_parts=DAY_PARTS,
|
day_parts=DAY_PARTS,
|
||||||
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),),
|
availabilities=(_win(10, 13, 19),),
|
||||||
)
|
)
|
||||||
assert validate(data) == []
|
assert validate(data) == []
|
||||||
|
|
||||||
|
|
||||||
def test_multiple_rules_fire_together():
|
def test_multiple_rules_fire_together():
|
||||||
data = ValidationInput(
|
data = ValidationInput(
|
||||||
candidate_shift=_shift(
|
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||||
"c", _dt(10, 14), _dt(10, 18), skills=["SOUND"]
|
|
||||||
),
|
|
||||||
volunteer_skill_codes=frozenset(),
|
|
||||||
existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),), # chevauchement
|
existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),), # chevauchement
|
||||||
day_parts=DAY_PARTS,
|
# aucune dispo déclarée ⇒ OUTSIDE_AVAILABILITY
|
||||||
availabilities=(
|
|
||||||
_avail(10, "AFTERNOON", AvailabilityStatus.UNAVAILABLE),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
codes = set(_codes(validate(data)))
|
codes = set(_codes(validate(data)))
|
||||||
assert {
|
assert {
|
||||||
ViolationCode.SHIFT_OVERLAP,
|
ViolationCode.SHIFT_OVERLAP,
|
||||||
ViolationCode.OUTSIDE_AVAILABILITY,
|
ViolationCode.OUTSIDE_AVAILABILITY,
|
||||||
ViolationCode.MISSING_REQUIRED_SKILL,
|
|
||||||
} <= codes
|
} <= codes
|
||||||
assert has_blocking(validate(data)) is True
|
assert has_blocking(validate(data)) is True
|
||||||
|
|
||||||
|
|
||||||
def test_only_warnings_is_not_blocking():
|
def test_only_warnings_is_not_blocking():
|
||||||
data = ValidationInput(
|
data = ValidationInput(
|
||||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18), skills=["SOUND"]),
|
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||||
volunteer_skill_codes=frozenset(),
|
# pas de dispo ⇒ un seul avertissement OUTSIDE_AVAILABILITY
|
||||||
day_parts=DAY_PARTS,
|
|
||||||
availabilities=(_avail(10, "AFTERNOON", AvailabilityStatus.AVAILABLE),),
|
|
||||||
)
|
)
|
||||||
violations = validate(data)
|
violations = validate(data)
|
||||||
assert _codes(violations) == [ViolationCode.MISSING_REQUIRED_SKILL]
|
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
||||||
assert has_blocking(violations) is False
|
assert has_blocking(violations) is False
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import datetime as dt
|
import datetime as dt
|
||||||
|
|
||||||
from app.models import Assignment, AvailabilityStatus
|
from app.models import Assignment
|
||||||
from app.validation import AvailabilityDeclaration
|
from app.validation import AvailabilityWindow
|
||||||
from app.validation.loader import build_validation_input
|
from app.validation.loader import build_validation_input
|
||||||
from tests.factories import (
|
from tests.factories import (
|
||||||
dt_at,
|
dt_at,
|
||||||
@@ -15,18 +15,16 @@ from tests.factories import (
|
|||||||
def test_build_input_gathers_everything_from_db(client, edition, session, day_parts):
|
def test_build_input_gathers_everything_from_db(client, edition, session, day_parts):
|
||||||
position = make_position(session, edition)
|
position = make_position(session, edition)
|
||||||
candidate = make_shift(
|
candidate = make_shift(
|
||||||
session, edition, position, start=dt_at(10, 14), end=dt_at(10, 18),
|
session, edition, position, start=dt_at(10, 14), end=dt_at(10, 18)
|
||||||
skills=["SST"],
|
|
||||||
)
|
)
|
||||||
other_shift = make_shift(
|
other_shift = make_shift(
|
||||||
session, edition, position, start=dt_at(11, 9), end=dt_at(11, 12)
|
session, edition, position, start=dt_at(11, 9), end=dt_at(11, 12)
|
||||||
)
|
)
|
||||||
volunteer = make_volunteer(
|
volunteer = make_volunteer(session, edition, email="v@ex.fr")
|
||||||
session, edition, email="v@ex.fr", skills=["SST", "PERMIS_B"]
|
|
||||||
)
|
|
||||||
make_availability(
|
make_availability(
|
||||||
session, edition, volunteer, day_parts["AFTERNOON"],
|
session, edition, volunteer,
|
||||||
date=dt.date(2026, 7, 10), status=AvailabilityStatus.IF_NEEDED,
|
date=dt.date(2026, 7, 10),
|
||||||
|
start_time=dt.time(13, 0), end_time=dt.time(19, 0),
|
||||||
)
|
)
|
||||||
session.add(
|
session.add(
|
||||||
Assignment(
|
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.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.
|
# 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 [s.id for s in data.existing_shifts] == [str(other_shift.id)]
|
||||||
assert len(data.day_parts) == 3
|
assert len(data.day_parts) == 3
|
||||||
assert data.availabilities == (
|
assert data.availabilities == (
|
||||||
AvailabilityDeclaration(
|
AvailabilityWindow(dt.date(2026, 7, 10), dt.time(13, 0), dt.time(19, 0)),
|
||||||
dt.date(2026, 7, 10), "AFTERNOON", AvailabilityStatus.IF_NEEDED
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user