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)