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:
AdamThomas
2026-09-10 22:12:55 +02:00
co-authored by Claude Sonnet 5
parent 609514369a
commit 24caece7df
13 changed files with 618 additions and 11 deletions
+108
View File
@@ -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)
+30 -1
View File
@@ -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
+109 -2
View File
@@ -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)
+4
View File
@@ -11,6 +11,10 @@
"shift.ends_not_after_starts": "La fin du créneau doit être postérieure à son début.",
"shift.position_wrong_edition": "Le poste n'appartient pas à cette édition.",
"volunteer.duplicate_email": "Un bénévole avec cet email existe déjà dans l'édition.",
"artist.name_required": "Le nom de l'artiste ne peut pas être vide.",
"artist.duplicate": "Un artiste porte déjà ce nom à cette date dans l'édition.",
"artist.unknown": "Un des artistes choisis n'appartient pas à cette édition.",
"position.unknown": "Un des postes choisis n'appartient pas à cette édition.",
"assignment.wrong_edition": "Le bénévole ou le créneau n'appartient pas à cette édition.",
"assignment.duplicate": "Ce bénévole est déjà affecté à ce créneau.",
"auth.invalid_token": "Lien de connexion invalide ou expiré.",
+2
View File
@@ -2,6 +2,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.accounts import router as account_router
from app.api.artists import router as artist_router
from app.api.assignments import router as assignment_router
from app.api.auth import router as auth_router
from app.api.day_parts import router as day_part_router
@@ -36,6 +37,7 @@ app.include_router(edition_router)
app.include_router(day_part_router)
app.include_router(account_router)
app.include_router(import_router)
app.include_router(artist_router)
app.include_router(volunteer_router)
app.include_router(position_router)
app.include_router(shift_router)
@@ -0,0 +1,80 @@
"""Artiste (programmation) + vœux des bénévoles (artistes, postes)
Revision ID: 0006_artist_and_volunteer_wishes
Revises: 0005_drop_volunteer_tshirt_diet
Create Date: 2026-09-10
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0006_artist_and_volunteer_wishes"
down_revision: str | None = "0005_drop_volunteer_tshirt_diet"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"artist",
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("edition_id", sa.Uuid(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("performance_date", sa.Date(), nullable=False),
sa.ForeignKeyConstraint(
["edition_id"], ["edition.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"edition_id", "name", "performance_date",
name="uq_artist_edition_name_date",
),
)
op.create_index(
op.f("ix_artist_edition_id"), "artist", ["edition_id"], unique=False
)
op.create_index(
op.f("ix_artist_performance_date"),
"artist",
["performance_date"],
unique=False,
)
op.create_table(
"volunteer_artist",
sa.Column("volunteer_id", sa.Uuid(), nullable=False),
sa.Column("artist_id", sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(
["artist_id"], ["artist.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["volunteer_id"], ["volunteer.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("volunteer_id", "artist_id"),
)
op.create_table(
"volunteer_position_wish",
sa.Column("volunteer_id", sa.Uuid(), nullable=False),
sa.Column("position_id", sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(
["position_id"], ["position.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["volunteer_id"], ["volunteer.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("volunteer_id", "position_id"),
)
def downgrade() -> None:
op.drop_table("volunteer_position_wish")
op.drop_table("volunteer_artist")
op.drop_index(op.f("ix_artist_performance_date"), table_name="artist")
op.drop_index(op.f("ix_artist_edition_id"), table_name="artist")
op.drop_table("artist")
+6
View File
@@ -7,6 +7,7 @@ les métadonnées sur `SQLModel.metadata`.
from app.models.base import TimestampMixin, local_now
from app.models.domain import (
Account,
Artist,
Assignment,
Availability,
DayPart,
@@ -17,6 +18,8 @@ from app.models.domain import (
ShiftRequiredSkill,
Skill,
Volunteer,
VolunteerArtist,
VolunteerPositionWish,
VolunteerSkill,
)
from app.models.enums import (
@@ -28,6 +31,7 @@ from app.models.enums import (
__all__ = [
"Account",
"Artist",
"Assignment",
"AssignmentSource",
"Availability",
@@ -43,6 +47,8 @@ __all__ = [
"Skill",
"TimestampMixin",
"Volunteer",
"VolunteerArtist",
"VolunteerPositionWish",
"VolunteerSkill",
"local_now",
]
+70
View File
@@ -49,6 +49,32 @@ class ShiftRequiredSkill(SQLModel, table=True):
)
class VolunteerArtist(SQLModel, table=True):
"""Vœu d'un bénévole : « je veux voir cet artiste »."""
__tablename__ = "volunteer_artist"
volunteer_id: uuid.UUID = Field(
foreign_key="volunteer.id", primary_key=True, ondelete="CASCADE"
)
artist_id: uuid.UUID = Field(
foreign_key="artist.id", primary_key=True, ondelete="CASCADE"
)
class VolunteerPositionWish(SQLModel, table=True):
"""Vœu d'un bénévole : « je voudrais tenir ce poste »."""
__tablename__ = "volunteer_position_wish"
volunteer_id: uuid.UUID = Field(
foreign_key="volunteer.id", primary_key=True, ondelete="CASCADE"
)
position_id: uuid.UUID = Field(
foreign_key="position.id", primary_key=True, ondelete="CASCADE"
)
# --------------------------------------------------------------------------- #
# Édition et découpage des journées #
# --------------------------------------------------------------------------- #
@@ -120,6 +146,35 @@ class Skill(TimestampMixin, table=True):
)
class Artist(TimestampMixin, table=True):
"""Artiste programmé sur l'édition.
La coordination constitue la liste (présentée par jour de passage). Les
bénévoles y choisissent les artistes qu'ils veulent voir (`VolunteerArtist`).
"""
__table_args__ = (
UniqueConstraint(
"edition_id", "name", "performance_date",
name="uq_artist_edition_name_date",
),
)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
edition_id: uuid.UUID = Field(
foreign_key="edition.id", index=True, ondelete="CASCADE"
)
name: str
# Jour de passage, dans les dates de l'édition.
performance_date: dt.date = Field(index=True)
fans: list["Volunteer"] = Relationship(
back_populates="wished_artists",
link_model=VolunteerArtist,
sa_relationship_kwargs=_PASSIVE,
)
class Volunteer(TimestampMixin, table=True):
__table_args__ = (
UniqueConstraint(
@@ -144,6 +199,16 @@ class Volunteer(TimestampMixin, table=True):
link_model=VolunteerSkill,
sa_relationship_kwargs=_PASSIVE,
)
wished_artists: list[Artist] = Relationship(
back_populates="fans",
link_model=VolunteerArtist,
sa_relationship_kwargs=_PASSIVE,
)
wished_positions: list["Position"] = Relationship(
back_populates="wishing_volunteers",
link_model=VolunteerPositionWish,
sa_relationship_kwargs=_PASSIVE,
)
availabilities: list["Availability"] = Relationship(
back_populates="volunteer", sa_relationship_kwargs=_PASSIVE
)
@@ -219,6 +284,11 @@ class Position(TimestampMixin, table=True):
shifts: list["Shift"] = Relationship(
back_populates="position", sa_relationship_kwargs=_PASSIVE
)
wishing_volunteers: list["Volunteer"] = Relationship(
back_populates="wished_positions",
link_model=VolunteerPositionWish,
sa_relationship_kwargs=_PASSIVE,
)
class Shift(TimestampMixin, table=True):