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

Compétences supprimées de bout en bout :
- tables `skill`, `volunteer_skill`, `shift_required_skill` ;
- `Volunteer.skill_codes`, `Shift.required_skill_codes`, colonne `skills`
  de l'import, règle de validation `MISSING_REQUIRED_SKILL`, jeu de démo.

Disponibilités repensées : `Availability` passe de (`day_part_id`,
`status` tri-état) à une **fenêtre horaire libre** (`date`, `start_time`,
`end_time` ; `end <= start` ⇒ passe minuit), plusieurs par jour.
- Nouveaux endpoints `GET`/`PUT /editions/{id}/volunteers/{vid}/availability`
  (PUT = remplacement complet, COORDINATION) et
  `GET /editions/{id}/availability` (toute l'édition, pour la grille).
- `OUTSIDE_AVAILABILITY` (toujours WARNING) : compare le créneau à l'union
  des fenêtres du bénévole ce jour-là ; renvoie `date` + `uncovered_minutes`.
  `DayPart` ne sert plus qu'à la borne de « journée » du cumul 8 h.
- L'import ne gère plus les disponibilités (fenêtres saisies dans l'app) :
  `values.py` supprimé, mapping réduit à nom/email/téléphone/commentaire.

Migration 0007 (recrée `availability`). `AvailabilityStatus` supprimé.
158 tests, `alembic check` et `ruff` au vert. Front à suivre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
AdamThomas
2026-09-10 22:46:45 +02:00
co-authored by Claude Sonnet 5
parent 81609eecc5
commit be51770b51
36 changed files with 800 additions and 1199 deletions
+1 -31
View File
@@ -1,13 +1,10 @@
"""Helpers partagés par les routers CRUD."""
import uuid
from collections.abc import Iterable
from fastapi import HTTPException
from sqlalchemy import delete
from sqlmodel import Session, select
from app.models import Skill
from sqlmodel import Session
def owned_or_404(instance, edition_id: uuid.UUID):
@@ -17,33 +14,6 @@ def owned_or_404(instance, edition_id: uuid.UUID):
return instance
def resolve_skills(
session: Session, edition_id: uuid.UUID, codes: Iterable[str]
) -> list[Skill]:
"""Récupère ou crée les compétences de l'édition pour les codes donnés."""
existing = {
s.code: s
for s in session.exec(
select(Skill).where(Skill.edition_id == edition_id)
)
}
resolved: list[Skill] = []
seen: set[str] = set()
for raw in codes:
code = raw.strip()
if not code or code in seen:
continue
seen.add(code)
skill = existing.get(code)
if skill is None:
skill = Skill(edition_id=edition_id, code=code, label=code)
session.add(skill)
session.flush()
existing[code] = skill
resolved.append(skill)
return resolved
def replace_links(
session: Session, link_model, *, filter_column, filter_value, pairs
) -> None:
+132
View File
@@ -0,0 +1,132 @@
"""Disponibilités déclarées par les bénévoles.
Une disponibilité = « le <date>, je peux de <start_time> à <end_time> ».
Fenêtre horaire libre (pas liée aux créneaux types) ; plusieurs par jour
possibles. `end_time <= start_time` ⇒ la fenêtre se termine le lendemain.
Ces fenêtres sont prises en compte par le moteur de validation (règle
`OUTSIDE_AVAILABILITY`) au moment d'affecter un bénévole à un créneau.
"""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import delete
from sqlmodel import Session, select
from app.api._common import owned_or_404
from app.api.deps import get_edition
from app.api.schemas import AvailabilityRead, AvailabilitySet
from app.auth.deps import (
current_account_in_edition,
require_coordinator,
require_staff,
)
from app.db import get_session
from app.models import Account, Availability, Edition, Role, Volunteer
router = APIRouter(prefix="/editions/{edition_id}", tags=["availability"])
def _ensure_can_read(account: Account, volunteer_id: uuid.UUID) -> None:
if account.role in (Role.COORDINATOR, Role.LEAD):
return
if account.volunteer_id == volunteer_id:
return
raise HTTPException(status_code=403, detail={"code": "error.forbidden"})
def _windows(session: Session, volunteer_id: uuid.UUID) -> list[Availability]:
return list(
session.exec(
select(Availability)
.where(Availability.volunteer_id == volunteer_id)
.order_by(Availability.date, Availability.start_time)
)
)
@router.get(
"/volunteers/{volunteer_id}/availability",
response_model=list[AvailabilityRead],
)
def list_availability(
volunteer_id: uuid.UUID,
edition: Edition = Depends(get_edition),
account: Account = Depends(current_account_in_edition),
session: Session = Depends(get_session),
) -> list[Availability]:
_ensure_can_read(account, volunteer_id)
owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
return _windows(session, volunteer_id)
@router.put(
"/volunteers/{volunteer_id}/availability",
response_model=list[AvailabilityRead],
)
def replace_availability(
volunteer_id: uuid.UUID,
payload: AvailabilitySet,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> list[Availability]:
owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
for w in payload.windows:
if not edition.start_date <= w.date <= edition.end_date:
raise HTTPException(
status_code=422,
detail={
"code": "availability.date_outside_edition",
"params": {"date": w.date.isoformat()},
},
)
if w.start_time == w.end_time:
raise HTTPException(
status_code=422,
detail={"code": "availability.empty_window"},
)
session.execute(
delete(Availability).where(Availability.volunteer_id == volunteer_id)
)
# Dé-doublonne (date, start, end) : la contrainte d'unicité l'imposerait.
seen: set[tuple] = set()
for w in payload.windows:
key = (w.date, w.start_time, w.end_time)
if key in seen:
continue
seen.add(key)
session.add(
Availability(
edition_id=edition.id,
volunteer_id=volunteer_id,
date=w.date,
start_time=w.start_time,
end_time=w.end_time,
)
)
session.commit()
return _windows(session, volunteer_id)
@router.get("/availability", response_model=list[AvailabilityRead])
def list_edition_availability(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_staff),
session: Session = Depends(get_session),
) -> list[Availability]:
"""Toutes les disponibilités de l'édition (pour la grille des besoins)."""
return list(
session.exec(
select(Availability)
.where(Availability.edition_id == edition.id)
.order_by(
Availability.volunteer_id,
Availability.date,
Availability.start_time,
)
)
)
+1 -1
View File
@@ -94,7 +94,7 @@ def delete_day_part(
session: Session = Depends(get_session),
) -> Response:
day_part = owned_or_404(session.get(DayPart, day_part_id), edition.id)
# Cascade base : disponibilités supprimées, day_part_id des créneaux -> NULL.
# Cascade base : day_part_id des créneaux issus de ce type repasse à NULL.
session.delete(day_part)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
+4 -36
View File
@@ -1,10 +1,8 @@
"""Endpoints d'import : analyse puis exécution."""
import datetime as dt
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from pydantic import ValidationError
from sqlmodel import Session, select
from sqlmodel import Session
from app.api.deps import get_edition
from app.auth.deps import require_coordinator
@@ -12,13 +10,12 @@ from app.db import get_session
from app.importing.parser import ImportParseError, parse_upload
from app.importing.schemas import (
AnalyzeResponse,
DayPartInfo,
ImportMapping,
ImportReport,
PreviewRow,
)
from app.importing.service import run_import, suggest_mapping, validate_mapping
from app.models import Account, DayPart, Edition
from app.models import Account, Edition
router = APIRouter(prefix="/editions/{edition_id}/import", tags=["import"])
@@ -45,27 +42,11 @@ def _is_xlsx(filename: str, content: bytes) -> bool:
)
def _edition_dates(edition: Edition) -> list[dt.date]:
span = (edition.end_date - edition.start_date).days
return [edition.start_date + dt.timedelta(days=i) for i in range(span + 1)]
def _day_parts(session: Session, edition: Edition) -> list[DayPart]:
return list(
session.exec(
select(DayPart)
.where(DayPart.edition_id == edition.id)
.order_by(DayPart.order)
)
)
@router.post("/analyze", response_model=AnalyzeResponse)
async def analyze(
file: UploadFile = File(...),
edition: Edition = Depends(get_edition),
_edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> AnalyzeResponse:
content = await _read_upload(file)
try:
@@ -87,17 +68,6 @@ async def analyze(
row_count=len(table.rows),
preview=preview,
suggested_mapping=suggest_mapping(table.headers),
edition_dates=_edition_dates(edition),
day_parts=[
DayPartInfo(
code=dp.code,
label=dp.label,
start_time=dp.start_time,
end_time=dp.end_time,
order=dp.order,
)
for dp in _day_parts(session, edition)
],
)
@@ -127,9 +97,7 @@ async def execute(
detail={"code": exc.code, "params": exc.params},
) from exc
mapping_errors = validate_mapping(
table, parsed_mapping, edition, _day_parts(session, edition)
)
mapping_errors = validate_mapping(table, parsed_mapping)
if mapping_errors:
raise HTTPException(
status_code=422,
+27 -6
View File
@@ -49,7 +49,6 @@ class VolunteerCreate(ApiModel):
email: str | None = None
phone: str | None = None
comment: str | None = None
skill_codes: list[str] = Field(default_factory=list)
# Vœux du bénévole : artistes à voir, postes souhaités.
artist_ids: list[uuid.UUID] = Field(default_factory=list)
wished_position_ids: list[uuid.UUID] = Field(default_factory=list)
@@ -62,7 +61,6 @@ class VolunteerUpdate(ApiModel):
phone: str | None = None
comment: str | None = None
# None = inchangé ; [] = vider. Idem pour artist_ids / wished_position_ids.
skill_codes: list[str] | None = None
artist_ids: list[uuid.UUID] | None = None
wished_position_ids: list[uuid.UUID] | None = None
@@ -75,7 +73,6 @@ class VolunteerRead(ApiModel):
email: str | None
phone: str | None
comment: str | None
skill_codes: list[str]
artist_ids: list[uuid.UUID]
wished_position_ids: list[uuid.UUID]
created_at: dt.datetime
@@ -141,7 +138,6 @@ class ShiftCreate(ApiModel):
ends_at: dt.datetime
min_staff: int = Field(default=0, ge=0)
ideal_staff: int = Field(default=0, ge=0)
required_skill_codes: list[str] = Field(default_factory=list)
class ShiftUpdate(ApiModel):
@@ -150,7 +146,6 @@ class ShiftUpdate(ApiModel):
ends_at: dt.datetime | None = None
min_staff: int | None = Field(default=None, ge=0)
ideal_staff: int | None = Field(default=None, ge=0)
required_skill_codes: list[str] | None = None
class ShiftRead(ApiModel):
@@ -162,7 +157,6 @@ class ShiftRead(ApiModel):
ends_at: dt.datetime
min_staff: int
ideal_staff: int
required_skill_codes: list[str]
created_at: dt.datetime
updated_at: dt.datetime
@@ -202,6 +196,33 @@ class DayPartUpdate(ApiModel):
order: int | None = None
# --------------------------------------------------------------------------- #
# Availability (disponibilités déclarées par le bénévole) #
# --------------------------------------------------------------------------- #
class AvailabilityWindowIn(ApiModel):
date: dt.date
start_time: dt.time
# end_time <= start_time ⇒ la fenêtre se termine le lendemain.
end_time: dt.time
class AvailabilitySet(ApiModel):
"""Remplace l'intégralité des disponibilités d'un bénévole."""
windows: list[AvailabilityWindowIn] = Field(default_factory=list)
class AvailabilityRead(ApiModel):
id: uuid.UUID
edition_id: uuid.UUID
volunteer_id: uuid.UUID
date: dt.date
start_time: dt.time
end_time: dt.time
created_at: dt.datetime
updated_at: dt.datetime
# --------------------------------------------------------------------------- #
# Assignment #
# --------------------------------------------------------------------------- #
+9 -48
View File
@@ -5,7 +5,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlmodel import Session, select
from app.api._common import owned_or_404, replace_links, resolve_skills
from app.api._common import owned_or_404
from app.api.deps import get_edition
from app.api.schemas import ShiftCreate, ShiftRead, ShiftUpdate
from app.auth.deps import (
@@ -15,44 +15,13 @@ from app.auth.deps import (
require_staff,
)
from app.db import get_session
from app.models import (
Account,
Edition,
Position,
Shift,
ShiftRequiredSkill,
Skill,
)
from app.models import Account, Edition, Position, Shift
router = APIRouter(prefix="/editions/{edition_id}/shifts", tags=["shifts"])
def _skill_codes(session: Session, shift_id: uuid.UUID) -> list[str]:
return sorted(
session.exec(
select(Skill.code)
.join(ShiftRequiredSkill, ShiftRequiredSkill.skill_id == Skill.id)
.where(ShiftRequiredSkill.shift_id == shift_id)
)
)
def _read(session: Session, shift: Shift) -> ShiftRead:
return ShiftRead(
**shift.model_dump(),
required_skill_codes=_skill_codes(session, shift.id),
)
def _set_skills(session: Session, shift: Shift, codes: list[str]) -> None:
skills = resolve_skills(session, shift.edition_id, codes)
replace_links(
session,
ShiftRequiredSkill,
filter_column=ShiftRequiredSkill.shift_id,
filter_value=shift.id,
pairs=[{"shift_id": shift.id, "skill_id": s.id} for s in skills],
)
def _read(shift: Shift) -> ShiftRead:
return ShiftRead(**shift.model_dump())
def _check_range(starts_at, ends_at) -> None:
@@ -84,16 +53,11 @@ def create_shift(
_check_range(payload.starts_at, payload.ends_at)
position = _require_position(session, payload.position_id, edition.id)
ensure_can_write_position(account, position)
shift = Shift(
edition_id=edition.id,
**payload.model_dump(exclude={"required_skill_codes"}),
)
shift = Shift(edition_id=edition.id, **payload.model_dump())
session.add(shift)
session.flush()
_set_skills(session, shift, payload.required_skill_codes)
session.commit()
session.refresh(shift)
return _read(session, shift)
return _read(shift)
@router.get("", response_model=list[ShiftRead])
@@ -110,7 +74,7 @@ def list_shifts(
)
if position_id is not None:
stmt = stmt.where(Shift.position_id == position_id)
return [_read(session, s) for s in session.exec(stmt)]
return [_read(s) for s in session.exec(stmt)]
@router.get("/{shift_id}", response_model=ShiftRead)
@@ -121,7 +85,7 @@ def get_shift(
session: Session = Depends(get_session),
) -> ShiftRead:
shift = owned_or_404(session.get(Shift, shift_id), edition.id)
return _read(session, shift)
return _read(shift)
@router.patch("/{shift_id}", response_model=ShiftRead)
@@ -135,18 +99,15 @@ def update_shift(
shift = owned_or_404(session.get(Shift, shift_id), edition.id)
ensure_can_write_shift(account, session, shift)
changes = payload.model_dump(exclude_unset=True)
skill_codes = changes.pop("required_skill_codes", None)
if "position_id" in changes:
target = _require_position(session, changes["position_id"], edition.id)
ensure_can_write_position(account, target)
for field, value in changes.items():
setattr(shift, field, value)
_check_range(shift.starts_at, shift.ends_at)
if skill_codes is not None:
_set_skills(session, shift, skill_codes)
session.commit()
session.refresh(shift)
return _read(session, shift)
return _read(shift)
@router.delete("/{shift_id}", status_code=status.HTTP_204_NO_CONTENT)
+3 -37
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select
from app.api._common import owned_or_404, replace_links, resolve_skills
from app.api._common import owned_or_404, replace_links
from app.api.deps import get_edition
from app.api.schemas import VolunteerCreate, VolunteerRead, VolunteerUpdate
from app.auth.deps import (
@@ -21,11 +21,9 @@ from app.models import (
Edition,
Position,
Role,
Skill,
Volunteer,
VolunteerArtist,
VolunteerPositionWish,
VolunteerSkill,
)
router = APIRouter(
@@ -41,16 +39,6 @@ def _ensure_can_read(account: Account, volunteer_id: uuid.UUID) -> None:
raise HTTPException(status_code=403, detail={"code": "error.forbidden"})
def _skill_codes(session: Session, volunteer_id: uuid.UUID) -> list[str]:
return sorted(
session.exec(
select(Skill.code)
.join(VolunteerSkill, VolunteerSkill.skill_id == Skill.id)
.where(VolunteerSkill.volunteer_id == volunteer_id)
)
)
def _artist_ids(session: Session, volunteer_id: uuid.UUID) -> list[uuid.UUID]:
return list(
session.exec(
@@ -81,27 +69,11 @@ def _wished_position_ids(
def _read(session: Session, volunteer: Volunteer) -> VolunteerRead:
return VolunteerRead(
**volunteer.model_dump(),
skill_codes=_skill_codes(session, volunteer.id),
artist_ids=_artist_ids(session, volunteer.id),
wished_position_ids=_wished_position_ids(session, volunteer.id),
)
def _set_skills(
session: Session, volunteer: Volunteer, codes: list[str]
) -> None:
skills = resolve_skills(session, volunteer.edition_id, codes)
replace_links(
session,
VolunteerSkill,
filter_column=VolunteerSkill.volunteer_id,
filter_value=volunteer.id,
pairs=[
{"volunteer_id": volunteer.id, "skill_id": s.id} for s in skills
],
)
def _resolve_owned_ids(
session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID], code: str
) -> list[uuid.UUID]:
@@ -168,9 +140,7 @@ def create_volunteer(
) -> VolunteerRead:
volunteer = Volunteer(
edition_id=edition.id,
**payload.model_dump(
exclude={"skill_codes", "artist_ids", "wished_position_ids"}
),
**payload.model_dump(exclude={"artist_ids", "wished_position_ids"}),
)
session.add(volunteer)
try:
@@ -180,7 +150,6 @@ def create_volunteer(
raise HTTPException(
status_code=409, detail={"code": "volunteer.duplicate_email"}
) from exc
_set_skills(session, volunteer, payload.skill_codes)
_set_artists(session, volunteer, payload.artist_ids)
_set_wished_positions(session, volunteer, payload.wished_position_ids)
session.commit()
@@ -224,7 +193,6 @@ def update_volunteer(
) -> VolunteerRead:
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
changes = payload.model_dump(exclude_unset=True)
skill_codes = changes.pop("skill_codes", None)
artist_ids = changes.pop("artist_ids", None)
wished_position_ids = changes.pop("wished_position_ids", None)
for field, value in changes.items():
@@ -236,8 +204,6 @@ def update_volunteer(
raise HTTPException(
status_code=409, detail={"code": "volunteer.duplicate_email"}
) from exc
if skill_codes is not None:
_set_skills(session, volunteer, skill_codes)
if artist_ids is not None:
_set_artists(session, volunteer, artist_ids)
if wished_position_ids is not None:
@@ -256,7 +222,7 @@ def delete_volunteer(
) -> Response:
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
# Effacement complet : la cascade base supprime disponibilités,
# affectations, liens de compétence et compte éventuel.
# affectations, vœux et compte éventuel.
session.delete(volunteer)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
+4 -14
View File
@@ -24,40 +24,30 @@
"account.volunteer_wrong_edition": "Le bénévole n'appartient pas à cette édition.",
"account.password_required": "Un compte coordination requiert un mot de passe d'au moins {min} caractères.",
"account.password_not_allowed": "Un compte référent ou bénévole se connecte par lien magique, sans mot de passe.",
"import.error.empty_file": "Le fichier est vide.",
"import.error.encoding": "L'encodage du fichier n'est pas reconnu.",
"import.error.unreadable_file": "Fichier illisible ou format non pris en charge.",
"import.error.file_too_large": "Le fichier est trop volumineux.",
"import.mapping.invalid": "Le paramétrage des colonnes est invalide.",
"import.mapping.required_field_missing": "Le champ « {field} » doit être associé à une colonne.",
"import.mapping.column_not_found": "La colonne « {column} » est absente du fichier.",
"import.mapping.unknown_day_part": "Demi-journée « {day_part_code} » inconnue pour cette édition (colonne « {column} »).",
"import.mapping.date_outside_edition": "La date {date} de la colonne « {column} » est hors des dates de l'édition.",
"import.mapping.duplicate_availability_column": "La colonne « {column} » est associée plusieurs fois.",
"import.row.missing_required_field": "Champ obligatoire manquant : {field}.",
"import.row.invalid_email": "Adresse email invalide : {value}.",
"import.row.duplicate_email_in_file": "Email déjà présent à la ligne {first_line} du fichier.",
"import.row.duplicate_name_in_file": "Nom et prénom déjà présents à la ligne {first_line} du fichier.",
"import.row.updated_existing": "Bénévole existant mis à jour.",
"import.row.possible_duplicate_name": "Un bénévole portant le même nom existe déjà dans l'édition.",
"import.row.skill_created": "Nouvelle compétence créée : {label}.",
"import.row.unknown_availability_value": "Valeur de disponibilité non comprise « {value} » (colonne « {column} »).",
"validation.SHIFT_OVERLAP": "Chevauchement avec un autre créneau du bénévole.",
"validation.OUTSIDE_AVAILABILITY": "Hors des disponibilités déclarées ({date}, {day_part_code}).",
"validation.OUTSIDE_AVAILABILITY": "Hors des disponibilités déclarées le {date} ({uncovered_minutes} min non couvertes).",
"validation.DAILY_HOURS_EXCEEDED": "Plus de 8 h d'affectation cumulées le {date}.",
"validation.INSUFFICIENT_REST": "Moins de 8 h de repos entre deux créneaux.",
"validation.MISSING_REQUIRED_SKILL": "Compétence requise absente : {skill_code}.",
"notification.no_recipients": "Aucun bénévole ciblé.",
"email.magic_link.subject": "Ton lien de connexion",
"email.magic_link.body": "Bonjour,\n\nVoici ton lien de connexion, valable {minutes} minutes :\n\n{link}\n\nSi tu n'es pas à l'origine de cette demande, ignore simplement cet email.",
"email.planning.default_subject": "Ton planning bénévole",
"email.planning.default_body": "Bonjour {prenom},\n\nTon planning a été mis à jour : {nb_creneaux} créneau(x), soit {nb_heures} h au total.\n\n{creneaux}\n\nÀ très vite sur le festival !",
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
"email.planning.shift_line": "- {position} : {start} → {end}"
"email.planning.shift_line": "- {position} : {start} → {end}",
"availability.date_outside_edition": "La date {date} est hors des dates de l'édition.",
"availability.empty_window": "Une disponibilité doit avoir une heure de début et de fin différentes."
}
-23
View File
@@ -4,7 +4,6 @@ Le rapport ne contient que des **codes** de traduction + des paramètres
structurés, jamais de phrase (voir `locales/fr.json`).
"""
import datetime as dt
import uuid
from typing import Literal
@@ -20,21 +19,11 @@ class ColumnMapping(BaseModel):
email: str | None = None
phone: str | None = None
comment: str | None = None
skills: str | None = None
class AvailabilityColumnMapping(BaseModel):
header: str
date: dt.date
day_part_code: str
class ImportMapping(BaseModel):
dry_run: bool = True
columns: ColumnMapping
availability_columns: list[AvailabilityColumnMapping] = Field(
default_factory=list
)
class Issue(BaseModel):
@@ -57,8 +46,6 @@ class ImportReport(BaseModel):
created: int = 0
updated: int = 0
skipped: int = 0
availabilities_written: int = 0
skills_created: list[str] = Field(default_factory=list)
rows: list[RowReport] = Field(default_factory=list)
@@ -67,14 +54,6 @@ class PreviewRow(BaseModel):
values: dict[str, str]
class DayPartInfo(BaseModel):
code: str
label: str
start_time: dt.time
end_time: dt.time
order: int
class AnalyzeResponse(BaseModel):
filename: str
format: Literal["csv", "xlsx"]
@@ -82,5 +61,3 @@ class AnalyzeResponse(BaseModel):
row_count: int
preview: list[PreviewRow]
suggested_mapping: dict[str, str]
edition_dates: list[dt.date]
day_parts: list[DayPartInfo]
+9 -153
View File
@@ -1,4 +1,8 @@
"""Cœur de l'import : suggestion de mapping, validation, exécution."""
"""Cœur de l'import : suggestion de mapping, validation, exécution.
L'import ne porte que l'identité des bénévoles (nom, email, téléphone,
commentaire). Les disponibilités se déclarent dans l'application.
"""
import re
@@ -11,16 +15,8 @@ from app.importing.schemas import (
Issue,
RowReport,
)
from app.importing.text import normalize, slug_code, split_skills
from app.importing.values import parse_availability_cell
from app.models import (
Availability,
DayPart,
Edition,
Skill,
Volunteer,
VolunteerSkill,
)
from app.importing.text import normalize
from app.models import Edition, Volunteer
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
@@ -30,7 +26,6 @@ _FIELD_HINTS: dict[str, tuple[str, ...]] = {
"last_name": ("nom", "last name", "lastname", "surname", "famille"),
"phone": ("tel", "phone", "portable", "mobile", "gsm"),
"comment": ("commentaire", "remarque", "note", "precision"),
"skills": ("competence", "skill", "habilitation", "brevet", "diplome"),
}
@@ -50,10 +45,7 @@ def suggest_mapping(headers: list[str]) -> dict[str, str]:
def validate_mapping(
table: ParsedTable,
mapping: ImportMapping,
edition: Edition,
day_parts: list[DayPart],
table: ParsedTable, mapping: ImportMapping
) -> list[Issue]:
"""Contrôles préalables. Retourne une liste vide si le mapping est correct."""
errors: list[Issue] = []
@@ -80,7 +72,7 @@ def validate_mapping(
)
)
for field in ("email", "phone", "comment", "skills"):
for field in ("email", "phone", "comment"):
header = getattr(columns, field)
if header and header not in headers:
errors.append(
@@ -91,50 +83,6 @@ def validate_mapping(
params={"column": header},
)
)
valid_codes = {dp.code for dp in day_parts}
seen: set[str] = set()
for column in mapping.availability_columns:
if column.header not in headers:
errors.append(
Issue(
code="import.mapping.column_not_found",
severity="ERROR",
field="availability",
params={"column": column.header},
)
)
if column.header in seen:
errors.append(
Issue(
code="import.mapping.duplicate_availability_column",
severity="ERROR",
params={"column": column.header},
)
)
seen.add(column.header)
if column.day_part_code not in valid_codes:
errors.append(
Issue(
code="import.mapping.unknown_day_part",
severity="ERROR",
params={
"column": column.header,
"day_part_code": column.day_part_code,
},
)
)
if not edition.start_date <= column.date <= edition.end_date:
errors.append(
Issue(
code="import.mapping.date_outside_edition",
severity="ERROR",
params={
"column": column.header,
"date": column.date.isoformat(),
},
)
)
return errors
@@ -150,12 +98,6 @@ def run_import(
Le mapping est supposé déjà validé par `validate_mapping`.
"""
columns = mapping.columns
day_part_by_code = {
dp.code: dp
for dp in session.exec(
select(DayPart).where(DayPart.edition_id == edition.id)
)
}
existing = list(
session.exec(
@@ -169,14 +111,6 @@ def run_import(
key = (normalize(volunteer.last_name), normalize(volunteer.first_name))
by_name.setdefault(key, volunteer)
skills_by_code = {
s.code: s
for s in session.exec(
select(Skill).where(Skill.edition_id == edition.id)
)
}
created_skills: set[str] = set()
report = ImportReport(dry_run=mapping.dry_run, total_rows=len(table.rows))
email_seen: dict[str, int] = {}
name_seen: dict[tuple[str, str], int] = {}
@@ -284,82 +218,6 @@ def run_import(
session.flush()
if columns.skills:
linked = set(
session.exec(
select(VolunteerSkill.skill_id).where(
VolunteerSkill.volunteer_id == volunteer.id
)
)
)
for token in split_skills(row.get(columns.skills) or ""):
code = slug_code(token)
if not code:
continue
skill = skills_by_code.get(code)
if skill is None:
skill = Skill(
edition_id=edition.id, code=code, label=token.strip()
)
session.add(skill)
session.flush()
skills_by_code[code] = skill
created_skills.add(code)
entry.issues.append(
Issue(
code="import.row.skill_created",
severity="WARNING",
params={"code": code, "label": skill.label},
)
)
if skill.id not in linked:
session.add(
VolunteerSkill(
volunteer_id=volunteer.id, skill_id=skill.id
)
)
linked.add(skill.id)
for column in mapping.availability_columns:
status, recognized = parse_availability_cell(
row.get(column.header, "") or ""
)
if not recognized:
entry.issues.append(
Issue(
code="import.row.unknown_availability_value",
severity="WARNING",
field=column.header,
params={
"column": column.header,
"value": (row.get(column.header, "") or "").strip(),
},
)
)
continue
if status is None:
continue
day_part = day_part_by_code[column.day_part_code]
current = session.exec(
select(Availability)
.where(Availability.volunteer_id == volunteer.id)
.where(Availability.date == column.date)
.where(Availability.day_part_id == day_part.id)
).first()
if current is not None:
current.status = status
else:
session.add(
Availability(
edition_id=edition.id,
volunteer_id=volunteer.id,
date=column.date,
day_part_id=day_part.id,
status=status,
)
)
report.availabilities_written += 1
entry.volunteer_id = volunteer.id
report.rows.append(entry)
if entry.action == "CREATED":
@@ -367,8 +225,6 @@ def run_import(
else:
report.updated += 1
report.skills_created = sorted(created_skills)
if mapping.dry_run:
session.rollback()
else:
+3 -8
View File
@@ -1,9 +1,8 @@
"""Normalisation de texte partagée par l'import (accents, casse, découpage)."""
"""Normalisation de texte partagée par l'import (accents, casse)."""
import re
import unicodedata
_SKILL_SEPARATORS = re.compile(r"[;,/\n]+")
_MULTI_UNDERSCORE = re.compile(r"_+")
@@ -15,12 +14,8 @@ def normalize(value: str) -> str:
def slug_code(value: str) -> str:
"""Code stable pour une compétence : MAJUSCULES, séparateurs en `_`."""
"""Code stable (MAJUSCULES, séparateurs en `_`) — utilisé pour les
créneaux types (`DayPart.code`)."""
base = normalize(value).upper()
chars = [c if c.isalnum() else "_" for c in base]
return _MULTI_UNDERSCORE.sub("_", "".join(chars)).strip("_")
def split_skills(value: str) -> list[str]:
parts = (p.strip() for p in _SKILL_SEPARATORS.split(value or ""))
return [p for p in parts if p]
-38
View File
@@ -1,38 +0,0 @@
"""Interprétation des valeurs de cellule : disponibilités."""
from app.importing.text import normalize
from app.models import AvailabilityStatus
_AVAILABLE = {
"x", "o", "oui", "y", "yes", "ok", "1", "vrai", "true",
"dispo", "disponible", "present", "presente",
"matin", "aprem", "apres-midi", "apres midi", "midi", "soir", "soiree",
"journee", "jour",
}
_IF_NEEDED = {
"si besoin", "sib", "peut-etre", "peut etre", "?", "2",
"backup", "reserve", "au besoin",
}
_UNAVAILABLE = {
"non", "n", "no", "0", "faux", "false", "indispo", "indisponible", "absent",
"-", "x-",
}
_EMPTY = {"", "na", "n/a", "nc"}
def parse_availability_cell(value: str) -> tuple[AvailabilityStatus | None, bool]:
"""(statut, reconnu).
- statut `None` + reconnu `True` → cellule vide, pas de déclaration.
- statut `None` + reconnu `False` → valeur non comprise, à signaler.
"""
norm = normalize(value)
if norm in _EMPTY:
return None, True
if norm in _AVAILABLE:
return AvailabilityStatus.AVAILABLE, True
if norm in _IF_NEEDED:
return AvailabilityStatus.IF_NEEDED, True
if norm in _UNAVAILABLE:
return AvailabilityStatus.UNAVAILABLE, True
return None, False
+2
View File
@@ -5,6 +5,7 @@ from app.api.accounts import router as account_router
from app.api.artists import router as artist_router
from app.api.assignments import router as assignment_router
from app.api.auth import router as auth_router
from app.api.availability import router as availability_router
from app.api.day_parts import router as day_part_router
from app.api.editions import router as edition_router
from app.api.health import router as health_router
@@ -39,6 +40,7 @@ app.include_router(account_router)
app.include_router(import_router)
app.include_router(artist_router)
app.include_router(volunteer_router)
app.include_router(availability_router)
app.include_router(position_router)
app.include_router(shift_router)
app.include_router(assignment_router)
@@ -0,0 +1,155 @@
"""Retrait des compétences ; disponibilités en fenêtres horaires libres
Revision ID: 0007_drop_skills_avail_windows
Revises: 0006_artist_and_volunteer_wishes
Create Date: 2026-09-10
- Suppression des tables `skill`, `volunteer_skill`, `shift_required_skill`.
- `availability` : on abandonne (`day_part_id`, `status`) au profit de
(`start_time`, `end_time`) — fenêtre horaire libre, plusieurs par jour.
La table est recréée (les anciennes lignes ne sont pas convertibles).
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0007_drop_skills_avail_windows"
down_revision: str | None = "0006_artist_and_volunteer_wishes"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_STATUS = sa.Enum(
"AVAILABLE", "IF_NEEDED", "UNAVAILABLE",
name="availabilitystatus", native_enum=False,
)
def upgrade() -> None:
op.drop_table("shift_required_skill")
op.drop_table("volunteer_skill")
op.drop_index("ix_skill_edition_id", table_name="skill")
op.drop_table("skill")
op.drop_table("availability")
op.create_table(
"availability",
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("edition_id", sa.Uuid(), nullable=False),
sa.Column("volunteer_id", sa.Uuid(), nullable=False),
sa.Column("date", sa.Date(), nullable=False),
sa.Column("start_time", sa.Time(), nullable=False),
sa.Column("end_time", sa.Time(), nullable=False),
sa.ForeignKeyConstraint(
["edition_id"], ["edition.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["volunteer_id"], ["volunteer.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"volunteer_id", "date", "start_time", "end_time",
name="uq_availability_window",
),
)
op.create_index(
op.f("ix_availability_edition_id"),
"availability",
["edition_id"],
unique=False,
)
op.create_index(
op.f("ix_availability_volunteer_id"),
"availability",
["volunteer_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index(op.f("ix_availability_volunteer_id"), table_name="availability")
op.drop_index(op.f("ix_availability_edition_id"), table_name="availability")
op.drop_table("availability")
op.create_table(
"availability",
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("edition_id", sa.Uuid(), nullable=False),
sa.Column("volunteer_id", sa.Uuid(), nullable=False),
sa.Column("date", sa.Date(), nullable=False),
sa.Column("day_part_id", sa.Uuid(), nullable=False),
sa.Column("status", _STATUS, nullable=False),
sa.ForeignKeyConstraint(
["edition_id"], ["edition.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["volunteer_id"], ["volunteer.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["day_part_id"], ["day_part.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"volunteer_id", "date", "day_part_id", name="uq_availability_slot"
),
)
op.create_index(
op.f("ix_availability_edition_id"),
"availability", ["edition_id"], unique=False,
)
op.create_index(
op.f("ix_availability_volunteer_id"),
"availability", ["volunteer_id"], unique=False,
)
op.create_index(
op.f("ix_availability_day_part_id"),
"availability", ["day_part_id"], unique=False,
)
op.create_table(
"skill",
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("edition_id", sa.Uuid(), nullable=False),
sa.Column("code", sa.String(), nullable=False),
sa.Column("label", sa.String(), nullable=False),
sa.ForeignKeyConstraint(
["edition_id"], ["edition.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"edition_id", "code", name="uq_skill_edition_code"
),
)
op.create_index(
op.f("ix_skill_edition_id"), "skill", ["edition_id"], unique=False
)
op.create_table(
"volunteer_skill",
sa.Column("volunteer_id", sa.Uuid(), nullable=False),
sa.Column("skill_id", sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(
["skill_id"], ["skill.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["volunteer_id"], ["volunteer.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("volunteer_id", "skill_id"),
)
op.create_table(
"shift_required_skill",
sa.Column("shift_id", sa.Uuid(), nullable=False),
sa.Column("skill_id", sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(
["shift_id"], ["shift.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["skill_id"], ["skill.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("shift_id", "skill_id"),
)
-7
View File
@@ -15,16 +15,12 @@ from app.models.domain import (
MagicLinkToken,
Position,
Shift,
ShiftRequiredSkill,
Skill,
Volunteer,
VolunteerArtist,
VolunteerPositionWish,
VolunteerSkill,
)
from app.models.enums import (
AssignmentSource,
AvailabilityStatus,
PositionCategory,
Role,
)
@@ -35,7 +31,6 @@ __all__ = [
"Assignment",
"AssignmentSource",
"Availability",
"AvailabilityStatus",
"DayPart",
"Edition",
"MagicLinkToken",
@@ -43,8 +38,6 @@ __all__ = [
"PositionCategory",
"Role",
"Shift",
"ShiftRequiredSkill",
"Skill",
"TimestampMixin",
"Volunteer",
"VolunteerArtist",
+11 -73
View File
@@ -16,7 +16,6 @@ from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
from app.models.base import TimestampMixin
from app.models.enums import (
AssignmentSource,
AvailabilityStatus,
PositionCategory,
Role,
)
@@ -27,28 +26,6 @@ _PASSIVE = {"passive_deletes": True}
# --------------------------------------------------------------------------- #
# Tables d'association (sans champs d'audit) #
# --------------------------------------------------------------------------- #
class VolunteerSkill(SQLModel, table=True):
__tablename__ = "volunteer_skill"
volunteer_id: uuid.UUID = Field(
foreign_key="volunteer.id", primary_key=True, ondelete="CASCADE"
)
skill_id: uuid.UUID = Field(
foreign_key="skill.id", primary_key=True, ondelete="CASCADE"
)
class ShiftRequiredSkill(SQLModel, table=True):
__tablename__ = "shift_required_skill"
shift_id: uuid.UUID = Field(
foreign_key="shift.id", primary_key=True, ondelete="CASCADE"
)
skill_id: uuid.UUID = Field(
foreign_key="skill.id", primary_key=True, ondelete="CASCADE"
)
class VolunteerArtist(SQLModel, table=True):
"""Vœu d'un bénévole : « je veux voir cet artiste »."""
@@ -114,38 +91,11 @@ class DayPart(TimestampMixin, table=True):
)
edition: Edition = Relationship(back_populates="day_parts")
availabilities: list["Availability"] = Relationship(
back_populates="day_part", sa_relationship_kwargs=_PASSIVE
)
# --------------------------------------------------------------------------- #
# Personnes : bénévoles, compétences, comptes #
# Personnes : bénévoles, comptes #
# --------------------------------------------------------------------------- #
class Skill(TimestampMixin, table=True):
__table_args__ = (
UniqueConstraint("edition_id", "code", name="uq_skill_edition_code"),
)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
edition_id: uuid.UUID = Field(
foreign_key="edition.id", index=True, ondelete="CASCADE"
)
code: str
label: str
volunteers: list["Volunteer"] = Relationship(
back_populates="skills",
link_model=VolunteerSkill,
sa_relationship_kwargs=_PASSIVE,
)
shifts_requiring: list["Shift"] = Relationship(
back_populates="required_skills",
link_model=ShiftRequiredSkill,
sa_relationship_kwargs=_PASSIVE,
)
class Artist(TimestampMixin, table=True):
"""Artiste programmé sur l'édition.
@@ -194,11 +144,6 @@ class Volunteer(TimestampMixin, table=True):
phone: str | None = None
comment: str | None = None
skills: list[Skill] = Relationship(
back_populates="volunteers",
link_model=VolunteerSkill,
sa_relationship_kwargs=_PASSIVE,
)
wished_artists: list[Artist] = Relationship(
back_populates="fans",
link_model=VolunteerArtist,
@@ -319,25 +264,25 @@ class Shift(TimestampMixin, table=True):
)
position: Position = Relationship(back_populates="shifts")
required_skills: list[Skill] = Relationship(
back_populates="shifts_requiring",
link_model=ShiftRequiredSkill,
sa_relationship_kwargs=_PASSIVE,
)
assignments: list["Assignment"] = Relationship(
back_populates="shift", sa_relationship_kwargs=_PASSIVE
)
class Availability(TimestampMixin, table=True):
"""Déclaration du bénévole : « je peux venir tel jour, telle demi-journée ».
"""Déclaration du bénévole : « tel jour, je peux venir de telle à telle
heure ». Fenêtre horaire libre (pas liée aux créneaux types). Plusieurs
fenêtres par jour possibles.
À ne pas confondre avec Assignment (décision de l'organisation).
Heures naïves, locales Europe/Paris. `end_time <= start_time` ⇒ la fenêtre
passe minuit (se termine le lendemain). À ne pas confondre avec Assignment
(décision de l'organisation).
"""
__table_args__ = (
UniqueConstraint(
"volunteer_id", "date", "day_part_id", name="uq_availability_slot"
"volunteer_id", "date", "start_time", "end_time",
name="uq_availability_window",
),
)
@@ -349,17 +294,10 @@ class Availability(TimestampMixin, table=True):
foreign_key="volunteer.id", index=True, ondelete="CASCADE"
)
date: dt.date
day_part_id: uuid.UUID = Field(
foreign_key="day_part.id", index=True, ondelete="CASCADE"
)
status: AvailabilityStatus = Field(
sa_column=Column(
SAEnum(AvailabilityStatus, native_enum=False), nullable=False
)
)
start_time: dt.time
end_time: dt.time
volunteer: Volunteer = Relationship(back_populates="availabilities")
day_part: DayPart = Relationship(back_populates="availabilities")
class Assignment(TimestampMixin, table=True):
-6
View File
@@ -13,12 +13,6 @@ class Role(enum.StrEnum):
VOLUNTEER = "VOLUNTEER"
class AvailabilityStatus(enum.StrEnum):
AVAILABLE = "AVAILABLE"
IF_NEEDED = "IF_NEEDED"
UNAVAILABLE = "UNAVAILABLE"
class AssignmentSource(enum.StrEnum):
SOLVER = "SOLVER"
MANUAL = "MANUAL"
+73 -166
View File
@@ -11,6 +11,7 @@ import random
from collections import Counter
from dataclasses import dataclass, field
from sqlalchemy import delete
from sqlmodel import Session, select
from app.auth.passwords import hash_password
@@ -21,27 +22,22 @@ from app.models import (
Assignment,
AssignmentSource,
Availability,
AvailabilityStatus,
DayPart,
Edition,
Position,
PositionCategory,
Role,
Shift,
ShiftRequiredSkill,
Skill,
Volunteer,
VolunteerSkill,
)
from app.validation import (
AvailabilityDeclaration,
AvailabilityWindow,
DayPartWindow,
Severity,
ShiftInfo,
ValidationInput,
validate,
)
from app.validation.engine import covered_slots
from app.validation.loader import build_validation_input
SEED = 20260709
@@ -136,19 +132,8 @@ LAST_NAMES = [
"Nicolas",
]
SKILLS: list[tuple[str, str]] = [
("SST", "Sauveteur secouriste du travail"),
("PERMIS_B", "Permis B"),
("REGIE_SON", "Régie son"),
("REGIE_LUM", "Régie lumière"),
("CACES", "CACES nacelle"),
("ELEC", "Habilitation électrique"),
("EN", "Anglais courant"),
("ES", "Espagnol courant"),
]
_C = PositionCategory
# (nom, catégorie, lieu, compétences requises sur ses créneaux)
# (nom, catégorie, lieu, _ inutilisé)
POSITIONS: list[tuple[str, PositionCategory, str, list[str]]] = [
("Bar central", _C.BAR, "Chapiteau", []),
("Bar scène", _C.BAR, "Grande scène", []),
@@ -291,11 +276,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
dp_windows = tuple(
DayPartWindow(dp.code, dp.start_time, dp.end_time, dp.order) for dp in day_parts
)
dp_by_code = {dp.code: dp for dp in day_parts}
skills = {code: Skill(edition_id=edition.id, code=code, label=label) for code, label in SKILLS}
session.add_all(skills.values())
session.flush()
coordination = Account(
edition_id=edition.id,
@@ -337,7 +317,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
}
volunteers: list[Volunteer] = []
vol_skill_codes: dict[object, set[str]] = {}
used_names: set[tuple[str, str]] = set()
for i in range(120):
while True:
@@ -366,45 +345,29 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
volunteer_id=volunteer.id,
)
)
count = rng.choices([0, 1, 2, 3], weights=[0.3, 0.34, 0.24, 0.12])[0]
codes = set(rng.sample(list(skills), k=count))
# Le permis B est courant : on en garantit chez ~40 % des bénévoles
# pour que le parking soit gréable.
if i % 5 in (0, 1):
codes.add("PERMIS_B")
for code in codes:
session.add(VolunteerSkill(volunteer_id=volunteer.id, skill_id=skills[code].id))
vol_skill_codes[volunteer.id] = codes
availabilities: dict[object, dict[tuple[dt.date, str], AvailabilityStatus]] = {}
# Disponibilités : chaque bénévole déclare, pour ~80 % des jours, une
# large fenêtre couvrant la journée et la nuit (06:00 → 04:00 le lendemain).
_FULL_DAY = (dt.time(6, 0), dt.time(4, 0))
availabilities: dict[object, list[AvailabilityWindow]] = {}
for volunteer in volunteers:
declared: dict[tuple[dt.date, str], AvailabilityStatus] = {}
windows: list[AvailabilityWindow] = []
for day in dates:
for dp in day_parts:
if rng.random() < 0.72:
status = rng.choices(
[
AvailabilityStatus.AVAILABLE,
AvailabilityStatus.IF_NEEDED,
AvailabilityStatus.UNAVAILABLE,
],
weights=[0.78, 0.14, 0.08],
)[0]
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
+2 -2
View File
@@ -5,7 +5,7 @@ Voir CLAUDE.md § 3 pour les règles et la sémantique.
from app.validation.engine import has_blocking, validate
from app.validation.models import (
AvailabilityDeclaration,
AvailabilityWindow,
DayPartWindow,
Severity,
ShiftInfo,
@@ -15,7 +15,7 @@ from app.validation.models import (
)
__all__ = [
"AvailabilityDeclaration",
"AvailabilityWindow",
"DayPartWindow",
"Severity",
"ShiftInfo",
+42 -65
View File
@@ -6,8 +6,8 @@ sortie. Règles et sémantique figées : voir CLAUDE.md § 3.
import datetime as dt
from app.models import AvailabilityStatus
from app.validation.models import (
AvailabilityWindow,
Severity,
ShiftInfo,
ValidationInput,
@@ -24,7 +24,6 @@ def validate(data: ValidationInput) -> list[Violation]:
violations.extend(_check_outside_availability(data))
violations.extend(_check_daily_hours(data))
violations.extend(_check_insufficient_rest(data))
violations.extend(_check_missing_skills(data))
return violations
@@ -58,59 +57,51 @@ def _check_overlap(data: ValidationInput) -> list[Violation]:
# --------------------------------------------------------------------------- #
# OUTSIDE_AVAILABILITY (avertissement) #
# --------------------------------------------------------------------------- #
def covered_slots(
shift: ShiftInfo, day_parts: tuple
) -> list[tuple[dt.date, str]]:
"""Demi-journées (date, code) dont la fenêtre horaire recoupe le créneau."""
if not day_parts:
return []
slots: list[tuple[dt.date, str]] = []
day = shift.starts_at.date() - dt.timedelta(days=1)
last = shift.ends_at.date()
while day <= last:
for part in day_parts:
window_start = dt.datetime.combine(day, part.start_time)
if part.end_time > part.start_time:
window_end = dt.datetime.combine(day, part.end_time)
else:
window_end = dt.datetime.combine(
day + dt.timedelta(days=1), part.end_time
)
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
]
+3 -37
View File
@@ -12,45 +12,16 @@ from app.models import (
DayPart,
Edition,
Shift,
ShiftRequiredSkill,
Skill,
Volunteer,
VolunteerSkill,
)
from app.validation.models import (
AvailabilityDeclaration,
AvailabilityWindow,
DayPartWindow,
ShiftInfo,
ValidationInput,
)
def _volunteer_skill_codes(
session: Session, volunteer_id: uuid.UUID
) -> frozenset[str]:
return frozenset(
session.exec(
select(Skill.code)
.join(VolunteerSkill, VolunteerSkill.skill_id == Skill.id)
.where(VolunteerSkill.volunteer_id == volunteer_id)
)
)
def _shift_required_skill_codes(
session: Session, shift_id: uuid.UUID
) -> frozenset[str]:
return frozenset(
session.exec(
select(Skill.code)
.join(
ShiftRequiredSkill, ShiftRequiredSkill.skill_id == Skill.id
)
.where(ShiftRequiredSkill.shift_id == shift_id)
)
)
def build_validation_input(
*,
session: Session,
@@ -62,7 +33,6 @@ def build_validation_input(
day_parts = list(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
)
code_by_day_part_id = {dp.id: dp.code for dp in day_parts}
# Toute affectation existante du bénévole compte (une affectation = une
# décision, toujours active).
@@ -80,10 +50,8 @@ def build_validation_input(
)
declarations = tuple(
AvailabilityDeclaration(
date=a.date,
day_part_code=code_by_day_part_id.get(a.day_part_id, ""),
status=a.status,
AvailabilityWindow(
date=a.date, start_time=a.start_time, end_time=a.end_time
)
for a in session.exec(
select(Availability).where(
@@ -97,9 +65,7 @@ def build_validation_input(
id=str(shift.id),
starts_at=shift.starts_at,
ends_at=shift.ends_at,
required_skill_codes=_shift_required_skill_codes(session, shift.id),
),
volunteer_skill_codes=_volunteer_skill_codes(session, volunteer.id),
existing_shifts=existing_shifts,
availabilities=declarations,
day_parts=tuple(
+12 -10
View File
@@ -10,8 +10,6 @@ from enum import StrEnum
from pydantic import BaseModel, Field
from app.models import AvailabilityStatus
class Severity(StrEnum):
BLOCKING = "BLOCKING"
@@ -23,7 +21,6 @@ class ViolationCode(StrEnum):
OUTSIDE_AVAILABILITY = "OUTSIDE_AVAILABILITY"
DAILY_HOURS_EXCEEDED = "DAILY_HOURS_EXCEEDED"
INSUFFICIENT_REST = "INSUFFICIENT_REST"
MISSING_REQUIRED_SKILL = "MISSING_REQUIRED_SKILL"
SEVERITY_BY_CODE: dict[ViolationCode, Severity] = {
@@ -31,7 +28,6 @@ SEVERITY_BY_CODE: dict[ViolationCode, Severity] = {
ViolationCode.OUTSIDE_AVAILABILITY: Severity.WARNING,
ViolationCode.DAILY_HOURS_EXCEEDED: Severity.WARNING,
ViolationCode.INSUFFICIENT_REST: Severity.WARNING,
ViolationCode.MISSING_REQUIRED_SKILL: Severity.WARNING,
}
@@ -53,11 +49,13 @@ class ShiftInfo:
id: str
starts_at: dt.datetime
ends_at: dt.datetime
required_skill_codes: frozenset[str] = frozenset()
@dataclass(frozen=True)
class DayPartWindow:
"""Créneau type. Ne sert plus qu'à fixer la borne de « journée » pour le
cumul des 8 h (début du premier créneau type)."""
code: str
start_time: dt.time
# end_time <= start_time ⇒ la demi-journée passe minuit.
@@ -66,10 +64,15 @@ class DayPartWindow:
@dataclass(frozen=True)
class AvailabilityDeclaration:
class AvailabilityWindow:
"""« Le <date>, je peux de <start_time> à <end_time>. »
`end_time <= start_time` ⇒ la fenêtre se termine le lendemain.
"""
date: dt.date
day_part_code: str
status: AvailabilityStatus
start_time: dt.time
end_time: dt.time
@dataclass(frozen=True)
@@ -81,7 +84,6 @@ class ValidationInput:
"""
candidate_shift: ShiftInfo
volunteer_skill_codes: frozenset[str] = frozenset()
existing_shifts: tuple[ShiftInfo, ...] = ()
availabilities: tuple[AvailabilityDeclaration, ...] = ()
availabilities: tuple[AvailabilityWindow, ...] = ()
day_parts: tuple[DayPartWindow, ...] = ()