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