Files
benevoles/backend/app/api/volunteers.py
T
AdamThomasandClaude Sonnet 5 be51770b51 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
2026-09-10 22:46:45 +02:00

229 lines
7.1 KiB
Python

"""CRUD des bénévoles. La suppression est un effacement complet (RGPD)."""
import uuid
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
from app.api.deps import get_edition
from app.api.schemas import VolunteerCreate, VolunteerRead, VolunteerUpdate
from app.auth.deps import (
current_account_in_edition,
require_coordinator,
require_staff,
)
from app.db import get_session
from app.models import (
Account,
Artist,
Edition,
Position,
Role,
Volunteer,
VolunteerArtist,
VolunteerPositionWish,
)
router = APIRouter(
prefix="/editions/{edition_id}/volunteers", tags=["volunteers"]
)
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 _artist_ids(session: Session, volunteer_id: uuid.UUID) -> list[uuid.UUID]:
return list(
session.exec(
select(Artist.id)
.join(VolunteerArtist, VolunteerArtist.artist_id == Artist.id)
.where(VolunteerArtist.volunteer_id == volunteer_id)
.order_by(Artist.performance_date, Artist.name)
)
)
def _wished_position_ids(
session: Session, volunteer_id: uuid.UUID
) -> list[uuid.UUID]:
return list(
session.exec(
select(Position.id)
.join(
VolunteerPositionWish,
VolunteerPositionWish.position_id == Position.id,
)
.where(VolunteerPositionWish.volunteer_id == volunteer_id)
.order_by(Position.name)
)
)
def _read(session: Session, volunteer: Volunteer) -> VolunteerRead:
return VolunteerRead(
**volunteer.model_dump(),
artist_ids=_artist_ids(session, volunteer.id),
wished_position_ids=_wished_position_ids(session, volunteer.id),
)
def _resolve_owned_ids(
session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID], code: str
) -> list[uuid.UUID]:
"""Ordre d'entrée conservé, doublons retirés ; 422 si un id est étranger."""
wanted = list(dict.fromkeys(ids))
if wanted:
found = set(
session.exec(
select(model.id).where(
model.edition_id == edition_id,
model.id.in_(wanted),
)
)
)
if len(found) != len(wanted):
raise HTTPException(status_code=422, detail={"code": code})
return wanted
def _set_artists(
session: Session, volunteer: Volunteer, artist_ids: list[uuid.UUID]
) -> None:
ids = _resolve_owned_ids(
session, Artist, volunteer.edition_id, artist_ids, "artist.unknown"
)
replace_links(
session,
VolunteerArtist,
filter_column=VolunteerArtist.volunteer_id,
filter_value=volunteer.id,
pairs=[
{"volunteer_id": volunteer.id, "artist_id": aid} for aid in ids
],
)
def _set_wished_positions(
session: Session, volunteer: Volunteer, position_ids: list[uuid.UUID]
) -> None:
ids = _resolve_owned_ids(
session,
Position,
volunteer.edition_id,
position_ids,
"position.unknown",
)
replace_links(
session,
VolunteerPositionWish,
filter_column=VolunteerPositionWish.volunteer_id,
filter_value=volunteer.id,
pairs=[
{"volunteer_id": volunteer.id, "position_id": pid} for pid in ids
],
)
@router.post("", response_model=VolunteerRead, status_code=status.HTTP_201_CREATED)
def create_volunteer(
payload: VolunteerCreate,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> VolunteerRead:
volunteer = Volunteer(
edition_id=edition.id,
**payload.model_dump(exclude={"artist_ids", "wished_position_ids"}),
)
session.add(volunteer)
try:
session.flush()
except IntegrityError as exc:
session.rollback()
raise HTTPException(
status_code=409, detail={"code": "volunteer.duplicate_email"}
) from exc
_set_artists(session, volunteer, payload.artist_ids)
_set_wished_positions(session, volunteer, payload.wished_position_ids)
session.commit()
session.refresh(volunteer)
return _read(session, volunteer)
@router.get("", response_model=list[VolunteerRead])
def list_volunteers(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_staff),
session: Session = Depends(get_session),
) -> list[VolunteerRead]:
volunteers = session.exec(
select(Volunteer)
.where(Volunteer.edition_id == edition.id)
.order_by(Volunteer.last_name, Volunteer.first_name)
)
return [_read(session, v) for v in volunteers]
@router.get("/{volunteer_id}", response_model=VolunteerRead)
def get_volunteer(
volunteer_id: uuid.UUID,
edition: Edition = Depends(get_edition),
account: Account = Depends(current_account_in_edition),
session: Session = Depends(get_session),
) -> VolunteerRead:
_ensure_can_read(account, volunteer_id)
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
return _read(session, volunteer)
@router.patch("/{volunteer_id}", response_model=VolunteerRead)
def update_volunteer(
volunteer_id: uuid.UUID,
payload: VolunteerUpdate,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> VolunteerRead:
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
changes = payload.model_dump(exclude_unset=True)
artist_ids = changes.pop("artist_ids", None)
wished_position_ids = changes.pop("wished_position_ids", None)
for field, value in changes.items():
setattr(volunteer, field, value)
try:
session.flush()
except IntegrityError as exc:
session.rollback()
raise HTTPException(
status_code=409, detail={"code": "volunteer.duplicate_email"}
) from exc
if artist_ids is not None:
_set_artists(session, volunteer, artist_ids)
if wished_position_ids is not None:
_set_wished_positions(session, volunteer, wished_position_ids)
session.commit()
session.refresh(volunteer)
return _read(session, volunteer)
@router.delete("/{volunteer_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_volunteer(
volunteer_id: uuid.UUID,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Response:
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
# Effacement complet : la cascade base supprime disponibilités,
# affectations, vœux et compte éventuel.
session.delete(volunteer)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)