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
229 lines
7.1 KiB
Python
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)
|