Files
AdamThomasandClaude Sonnet 5 fea9d37019 Artiste : heure de passage ; vœux d'artistes pris en compte par le solveur
Artist gagne start_time/end_time (end <= start ⇒ finit le lendemain,
même convention que Shift/DayPart/Availability). Migration 0008.

Le solveur (app/solver/) résout maintenant les fenêtres de passage des
artistes que chaque bénévole veut voir (VolunteerArtist) et pénalise
toute suggestion qui chevaucherait un passage souhaité — le bénévole
raterait l'artiste. Comme le reste du scoring, c'est un terme de
l'objectif, pas un blocage : pour un créneau urgent sans autre
candidat, le solveur propose quand même, quitte à ce que ce soit un
pis-aller.

- SolverVolunteer.wished_artist_windows (fenêtres ShiftInfo précalculées
  par le loader depuis Artist + VolunteerArtist).
- Nouveau malus _ARTIST_CONFLICT_PENALTY dans engine.py.
- Tests : moteur (conflit détecté/ignoré hors chevauchement/toujours
  suggéré en dernier recours) + intégration API bout en bout.

176 tests, ruff et alembic check au vert. CLAUDE.md mis à jour (§2 et §4).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-11 18:13:54 +02:00

113 lines
3.7 KiB
Python

"""CRUD de la programmation (`Artist`) d'une édition.
La coordination constitue la liste ; elle est présentée par jour et heure de
passage (`performance_date`, `start_time`, `end_time`). Les bénévoles y
choisissent les artistes qu'ils veulent voir (vœux stockés dans
`volunteer_artist`, exposés par la fiche bénévole) — le solveur en tient
compte pour éviter de les affecter pendant le passage."""
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
from app.api.deps import get_edition
from app.api.schemas import ArtistCreate, ArtistRead, ArtistUpdate
from app.auth.deps import current_account_in_edition, require_coordinator
from app.db import get_session
from app.models import Account, Artist, Edition
router = APIRouter(prefix="/editions/{edition_id}/artists", tags=["artists"])
@router.get("", response_model=list[ArtistRead])
def list_artists(
edition: Edition = Depends(get_edition),
_account: Account = Depends(current_account_in_edition),
session: Session = Depends(get_session),
) -> list[Artist]:
return list(
session.exec(
select(Artist)
.where(Artist.edition_id == edition.id)
.order_by(Artist.performance_date, Artist.name)
)
)
@router.post("", response_model=ArtistRead, status_code=status.HTTP_201_CREATED)
def create_artist(
payload: ArtistCreate,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Artist:
name = payload.name.strip()
if not name:
raise HTTPException(
status_code=422, detail={"code": "artist.name_required"}
)
artist = Artist(
edition_id=edition.id,
name=name,
performance_date=payload.performance_date,
start_time=payload.start_time,
end_time=payload.end_time,
)
session.add(artist)
try:
session.commit()
except IntegrityError as exc:
session.rollback()
raise HTTPException(
status_code=409, detail={"code": "artist.duplicate"}
) from exc
session.refresh(artist)
return artist
@router.patch("/{artist_id}", response_model=ArtistRead)
def update_artist(
artist_id: uuid.UUID,
payload: ArtistUpdate,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Artist:
artist = owned_or_404(session.get(Artist, artist_id), edition.id)
changes = payload.model_dump(exclude_unset=True)
if "name" in changes:
name = (changes["name"] or "").strip()
if not name:
raise HTTPException(
status_code=422, detail={"code": "artist.name_required"}
)
changes["name"] = name
for field, value in changes.items():
setattr(artist, field, value)
try:
session.commit()
except IntegrityError as exc:
session.rollback()
raise HTTPException(
status_code=409, detail={"code": "artist.duplicate"}
) from exc
session.refresh(artist)
return artist
@router.delete("/{artist_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_artist(
artist_id: uuid.UUID,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Response:
artist = owned_or_404(session.get(Artist, artist_id), edition.id)
# Cascade base : les vœux `volunteer_artist` sont supprimés.
session.delete(artist)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)