Bénévole : vœux d'artistes (entité Artist) et vœux de postes
Nouvelle entité `Artist` (nom + jour de passage), CRUD
`/editions/{id}/artists` (lecture ouverte à tout compte de l'édition,
écriture COORDINATION), unicité (édition, nom, date).
La fiche bénévole porte deux listes de vœux, sur le modèle de
`skill_codes` (None = inchangé, [] = vider) :
- `artist_ids` : artistes que le bénévole veut voir (`volunteer_artist`) ;
- `wished_position_ids` : postes qu'il voudrait tenir
(`volunteer_position_wish`), distinct de l'affectation.
Un id étranger à l'édition renvoie 422 (`artist.unknown` /
`position.unknown`). Suppression d'un artiste : cascade sur les vœux.
Migration 0006. Jeu de démonstration et frontend inchangés (le front
suivra).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
609514369a
commit
24caece7df
@@ -0,0 +1,108 @@
|
||||
"""CRUD de la programmation (`Artist`) d'une édition.
|
||||
|
||||
La coordination constitue la liste ; elle est présentée par jour de passage
|
||||
(`performance_date`). 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)."""
|
||||
|
||||
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,
|
||||
)
|
||||
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)
|
||||
@@ -50,6 +50,9 @@ class VolunteerCreate(ApiModel):
|
||||
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)
|
||||
|
||||
|
||||
class VolunteerUpdate(ApiModel):
|
||||
@@ -58,8 +61,10 @@ class VolunteerUpdate(ApiModel):
|
||||
email: str | None = None
|
||||
phone: str | None = None
|
||||
comment: str | None = None
|
||||
# None = inchangé ; [] = vider les compétences.
|
||||
# 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
|
||||
|
||||
|
||||
class VolunteerRead(ApiModel):
|
||||
@@ -71,6 +76,30 @@ class VolunteerRead(ApiModel):
|
||||
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
|
||||
updated_at: dt.datetime
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Artist (programmation) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class ArtistCreate(ApiModel):
|
||||
name: str
|
||||
performance_date: dt.date
|
||||
|
||||
|
||||
class ArtistUpdate(ApiModel):
|
||||
name: str | None = None
|
||||
performance_date: dt.date | None = None
|
||||
|
||||
|
||||
class ArtistRead(ApiModel):
|
||||
id: uuid.UUID
|
||||
edition_id: uuid.UUID
|
||||
name: str
|
||||
performance_date: dt.date
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
|
||||
@@ -15,7 +15,18 @@ from app.auth.deps import (
|
||||
require_staff,
|
||||
)
|
||||
from app.db import get_session
|
||||
from app.models import Account, Edition, Role, Skill, Volunteer, VolunteerSkill
|
||||
from app.models import (
|
||||
Account,
|
||||
Artist,
|
||||
Edition,
|
||||
Position,
|
||||
Role,
|
||||
Skill,
|
||||
Volunteer,
|
||||
VolunteerArtist,
|
||||
VolunteerPositionWish,
|
||||
VolunteerSkill,
|
||||
)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/editions/{edition_id}/volunteers", tags=["volunteers"]
|
||||
@@ -40,10 +51,39 @@ def _skill_codes(session: Session, volunteer_id: uuid.UUID) -> list[str]:
|
||||
)
|
||||
|
||||
|
||||
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(),
|
||||
skill_codes=_skill_codes(session, volunteer.id),
|
||||
artist_ids=_artist_ids(session, volunteer.id),
|
||||
wished_position_ids=_wished_position_ids(session, volunteer.id),
|
||||
)
|
||||
|
||||
|
||||
@@ -62,6 +102,63 @@ def _set_skills(
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
@@ -71,7 +168,9 @@ def create_volunteer(
|
||||
) -> VolunteerRead:
|
||||
volunteer = Volunteer(
|
||||
edition_id=edition.id,
|
||||
**payload.model_dump(exclude={"skill_codes"}),
|
||||
**payload.model_dump(
|
||||
exclude={"skill_codes", "artist_ids", "wished_position_ids"}
|
||||
),
|
||||
)
|
||||
session.add(volunteer)
|
||||
try:
|
||||
@@ -82,6 +181,8 @@ def create_volunteer(
|
||||
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()
|
||||
session.refresh(volunteer)
|
||||
return _read(session, volunteer)
|
||||
@@ -124,6 +225,8 @@ def update_volunteer(
|
||||
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():
|
||||
setattr(volunteer, field, value)
|
||||
try:
|
||||
@@ -135,6 +238,10 @@ def update_volunteer(
|
||||
) 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:
|
||||
_set_wished_positions(session, volunteer, wished_position_ids)
|
||||
session.commit()
|
||||
session.refresh(volunteer)
|
||||
return _read(session, volunteer)
|
||||
|
||||
Reference in New Issue
Block a user