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
+23 -6
View File
@@ -23,6 +23,9 @@ colonne, route et variable utilise la colonne « Code / base ».
| Créneau | `Shift` | | Créneau | `Shift` |
| Disponibilité | `Availability` | | Disponibilité | `Availability` |
| Affectation | `Assignment` | | Affectation | `Assignment` |
| Artiste programmé | `Artist` (`name`, `performance_date`) |
| Vœu « je veux voir cet artiste » | `VolunteerArtist` (N-N `Volunteer` ↔ `Artist`) |
| Vœu « je voudrais ce poste » | `VolunteerPositionWish` (N-N `Volunteer` ↔ `Position`) |
| Compte (utilisateur connecté) | `Account` | | Compte (utilisateur connecté) | `Account` |
| Découpage de journée (paramétrable)| `DayPart` (`code`, `label`, `start_time`, `end_time`, `order`) | | Découpage de journée (paramétrable)| `DayPart` (`code`, `label`, `start_time`, `end_time`, `order`) |
| Lien magique | `MagicLinkToken` | | Lien magique | `MagicLinkToken` |
@@ -66,9 +69,20 @@ les tables sauf les tables d'association.
- **`Volunteer`** : `email` (unique **par édition**, nullable pour les - **`Volunteer`** : `email` (unique **par édition**, nullable pour les
bénévoles créés à la main — plusieurs `NULL` autorisés), `first_name`, bénévoles créés à la main — plusieurs `NULL` autorisés), `first_name`,
`last_name`, `phone`, `comment`. Pas de date de naissance, ni de taille de `last_name`, `phone`, `comment`. Pas de date de naissance, ni de taille de
t-shirt / régime alimentaire. t-shirt / régime alimentaire. Porte trois listes de vœux, exposées par la
fiche (`skill_codes`, `artist_ids`, `wished_position_ids` ; `None`/absent =
inchangé, `[]` = vider).
- **`Skill`** : référentiel par édition (`code`, `label`). N-N `Volunteer` - **`Skill`** : référentiel par édition (`code`, `label`). N-N `Volunteer`
(`volunteer_skill`). N-N `Shift` (`shift_required_skill`). (`volunteer_skill`). N-N `Shift` (`shift_required_skill`).
- **`Artist`** : programmation de l'édition (`name`, `performance_date`).
Unique (`edition_id`, `name`, `performance_date`). La coordination gère la
liste ; elle est présentée par jour. Les bénévoles y choisissent les
artistes qu'ils veulent voir (`volunteer_artist`). CRUD
`GET/POST/PATCH/DELETE /editions/{id}/artists` (lecture : tout compte de
l'édition ; écriture : COORDINATION).
- Vœux de postes : `volunteer_position_wish` (« postes que le bénévole
voudrait tenir »), distinct de `Assignment` (décision) et de `Position.
lead_account_id` (référent).
- **`Position`** : `name`, `location`, `category` (`PositionCategory`), - **`Position`** : `name`, `location`, `category` (`PositionCategory`),
`lead_account_id` (→ `Account`, `SET NULL` à la suppression du compte). `lead_account_id` (→ `Account`, `SET NULL` à la suppression du compte).
- **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont - **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont
@@ -96,8 +110,9 @@ Conventions techniques :
type ENUM natif PostgreSQL. type ENUM natif PostgreSQL.
- Suppression en cascade au niveau base (`ON DELETE CASCADE`) sur tous les - Suppression en cascade au niveau base (`ON DELETE CASCADE`) sur tous les
`edition_id` et sur les liens vers `volunteer`, `shift`, `position`, `edition_id` et sur les liens vers `volunteer`, `shift`, `position`,
`skill`, `account`. La suppression complète d'un bénévole (RGPD) efface donc `skill`, `artist`, `account`. La suppression complète d'un bénévole (RGPD)
ses `Availability`, `Assignment`, liens de compétence et son `Account`. efface donc ses `Availability`, `Assignment`, liens de compétence, vœux
(artistes, postes) et son `Account`.
- Dates/heures **naïves**, heure locale Europe/Paris (`TZ` du conteneur + - Dates/heures **naïves**, heure locale Europe/Paris (`TZ` du conteneur +
`tzdata`). `app.models.local_now()` pour les horodatages. `tzdata`). `app.models.local_now()` pour les horodatages.
@@ -119,6 +134,7 @@ Matrice appliquée par les endpoints :
| `positions`/`shifts` écriture | ✔ | ses postes | — | | `positions`/`shifts` écriture | ✔ | ses postes | — |
| `assignments` écriture | ✔ | ses postes | — | | `assignments` écriture | ✔ | ses postes | — |
| `assignments` lecture | ✔ | ✔ | les siennes | | `assignments` lecture | ✔ | ✔ | les siennes |
| `artists` lecture / écriture | ✔ / ✔ | ✔ / — | ✔ / — |
| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — | | `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — |
**Authentification — deux systèmes selon le compte**, une même session JWT **Authentification — deux systèmes selon le compte**, une même session JWT
@@ -261,9 +277,9 @@ fonctions d'IA. *(L'envoi d'emails par SMTP est en place — voir § 2.)*
│ │ ├── logging_config.py # RGPD : pas de PII dans les logs │ │ ├── logging_config.py # RGPD : pas de PII dans les logs
│ │ ├── api/ # routers + schemas Pydantic + deps │ │ ├── api/ # routers + schemas Pydantic + deps
│ │ │ # health, auth, accounts, editions, │ │ │ # health, auth, accounts, editions,
│ │ │ # day_parts, imports, volunteers, │ │ │ # day_parts, imports, artists,
│ │ │ # positions, shifts, assignments, │ │ │ # volunteers, positions, shifts,
│ │ │ # matrix, notifications │ │ │ # assignments, matrix, notifications
│ │ ├── auth/ # mot de passe + lien magique + JWT + rôles │ │ ├── auth/ # mot de passe + lien magique + JWT + rôles
│ │ ├── email/ # SMTP (sender) + rendu des emails (templates) │ │ ├── email/ # SMTP (sender) + rendu des emails (templates)
│ │ ├── importing/ # import CSV/Excel (parser, values, service) │ │ ├── importing/ # import CSV/Excel (parser, values, service)
@@ -354,6 +370,7 @@ Aucune dépendance locale requise hors Docker.
| 10 | Envoi d'emails par SMTP : lien magique + récapitulatif de planning | validée | | 10 | Envoi d'emails par SMTP : lien magique + récapitulatif de planning | validée |
| 11 | `GET`/`PATCH /editions/{id}` (nom, dates) — ajouter/retirer des jours ; UX config front | validée | | 11 | `GET`/`PATCH /editions/{id}` (nom, dates) — ajouter/retirer des jours ; UX config front | validée |
| 12 | Créneaux types (`DayPart` CRUD) + génération auto des créneaux 0/0 à la création d'un poste ; case « à définir » / « sureffectif » sur la grille | à valider | | 12 | Créneaux types (`DayPart` CRUD) + génération auto des créneaux 0/0 à la création d'un poste ; case « à définir » / « sureffectif » sur la grille | à valider |
| 13 | Bénévole : retrait taille de t-shirt / régime ; entité `Artist` (CRUD, par jour) + vœux des bénévoles (artistes, postes) | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : solveur OR-Tools, **Reste hors périmètre** (modèle prêt à les accueillir) : solveur OR-Tools,
SMS, constructeur de formulaire, pointage sur site, IA. Frontend : SMS, constructeur de formulaire, pointage sur site, IA. Frontend :
+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 phone: str | None = None
comment: str | None = None comment: str | None = None
skill_codes: list[str] = Field(default_factory=list) 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): class VolunteerUpdate(ApiModel):
@@ -58,8 +61,10 @@ class VolunteerUpdate(ApiModel):
email: str | None = None email: str | None = None
phone: str | None = None phone: str | None = None
comment: 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 skill_codes: list[str] | None = None
artist_ids: list[uuid.UUID] | None = None
wished_position_ids: list[uuid.UUID] | None = None
class VolunteerRead(ApiModel): class VolunteerRead(ApiModel):
@@ -71,6 +76,30 @@ class VolunteerRead(ApiModel):
phone: str | None phone: str | None
comment: str | None comment: str | None
skill_codes: list[str] 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 created_at: dt.datetime
updated_at: dt.datetime updated_at: dt.datetime
+109 -2
View File
@@ -15,7 +15,18 @@ from app.auth.deps import (
require_staff, require_staff,
) )
from app.db import get_session 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( router = APIRouter(
prefix="/editions/{edition_id}/volunteers", tags=["volunteers"] 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: def _read(session: Session, volunteer: Volunteer) -> VolunteerRead:
return VolunteerRead( return VolunteerRead(
**volunteer.model_dump(), **volunteer.model_dump(),
skill_codes=_skill_codes(session, volunteer.id), 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) @router.post("", response_model=VolunteerRead, status_code=status.HTTP_201_CREATED)
def create_volunteer( def create_volunteer(
payload: VolunteerCreate, payload: VolunteerCreate,
@@ -71,7 +168,9 @@ def create_volunteer(
) -> VolunteerRead: ) -> VolunteerRead:
volunteer = Volunteer( volunteer = Volunteer(
edition_id=edition.id, edition_id=edition.id,
**payload.model_dump(exclude={"skill_codes"}), **payload.model_dump(
exclude={"skill_codes", "artist_ids", "wished_position_ids"}
),
) )
session.add(volunteer) session.add(volunteer)
try: try:
@@ -82,6 +181,8 @@ def create_volunteer(
status_code=409, detail={"code": "volunteer.duplicate_email"} status_code=409, detail={"code": "volunteer.duplicate_email"}
) from exc ) from exc
_set_skills(session, volunteer, payload.skill_codes) _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.commit()
session.refresh(volunteer) session.refresh(volunteer)
return _read(session, volunteer) return _read(session, volunteer)
@@ -124,6 +225,8 @@ def update_volunteer(
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id) volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
changes = payload.model_dump(exclude_unset=True) changes = payload.model_dump(exclude_unset=True)
skill_codes = changes.pop("skill_codes", None) 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(): for field, value in changes.items():
setattr(volunteer, field, value) setattr(volunteer, field, value)
try: try:
@@ -135,6 +238,10 @@ def update_volunteer(
) from exc ) from exc
if skill_codes is not None: if skill_codes is not None:
_set_skills(session, volunteer, skill_codes) _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.commit()
session.refresh(volunteer) session.refresh(volunteer)
return _read(session, 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.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.", "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.", "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.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.", "assignment.duplicate": "Ce bénévole est déjà affecté à ce créneau.",
"auth.invalid_token": "Lien de connexion invalide ou expiré.", "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 fastapi.middleware.cors import CORSMiddleware
from app.api.accounts import router as account_router 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.assignments import router as assignment_router
from app.api.auth import router as auth_router from app.api.auth import router as auth_router
from app.api.day_parts import router as day_part_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(day_part_router)
app.include_router(account_router) app.include_router(account_router)
app.include_router(import_router) app.include_router(import_router)
app.include_router(artist_router)
app.include_router(volunteer_router) app.include_router(volunteer_router)
app.include_router(position_router) app.include_router(position_router)
app.include_router(shift_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.base import TimestampMixin, local_now
from app.models.domain import ( from app.models.domain import (
Account, Account,
Artist,
Assignment, Assignment,
Availability, Availability,
DayPart, DayPart,
@@ -17,6 +18,8 @@ from app.models.domain import (
ShiftRequiredSkill, ShiftRequiredSkill,
Skill, Skill,
Volunteer, Volunteer,
VolunteerArtist,
VolunteerPositionWish,
VolunteerSkill, VolunteerSkill,
) )
from app.models.enums import ( from app.models.enums import (
@@ -28,6 +31,7 @@ from app.models.enums import (
__all__ = [ __all__ = [
"Account", "Account",
"Artist",
"Assignment", "Assignment",
"AssignmentSource", "AssignmentSource",
"Availability", "Availability",
@@ -43,6 +47,8 @@ __all__ = [
"Skill", "Skill",
"TimestampMixin", "TimestampMixin",
"Volunteer", "Volunteer",
"VolunteerArtist",
"VolunteerPositionWish",
"VolunteerSkill", "VolunteerSkill",
"local_now", "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 # # É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): class Volunteer(TimestampMixin, table=True):
__table_args__ = ( __table_args__ = (
UniqueConstraint( UniqueConstraint(
@@ -144,6 +199,16 @@ class Volunteer(TimestampMixin, table=True):
link_model=VolunteerSkill, link_model=VolunteerSkill,
sa_relationship_kwargs=_PASSIVE, 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( availabilities: list["Availability"] = Relationship(
back_populates="volunteer", sa_relationship_kwargs=_PASSIVE back_populates="volunteer", sa_relationship_kwargs=_PASSIVE
) )
@@ -219,6 +284,11 @@ class Position(TimestampMixin, table=True):
shifts: list["Shift"] = Relationship( shifts: list["Shift"] = Relationship(
back_populates="position", sa_relationship_kwargs=_PASSIVE 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): class Shift(TimestampMixin, table=True):
+13
View File
@@ -3,6 +3,7 @@
import datetime as dt import datetime as dt
from app.models import ( from app.models import (
Artist,
Availability, Availability,
AvailabilityStatus, AvailabilityStatus,
Position, Position,
@@ -22,6 +23,18 @@ def make_position(session, edition, *, name="Bar", category=PositionCategory.BAR
return position return position
def make_artist(
session, edition, *, name="Chilla", performance_date=dt.date(2026, 7, 10)
):
artist = Artist(
edition_id=edition.id, name=name, performance_date=performance_date
)
session.add(artist)
session.commit()
session.refresh(artist)
return artist
def make_shift( def make_shift(
session, edition, position, *, start, end, min_staff=1, ideal_staff=2, skills=() session, edition, position, *, start, end, min_staff=1, ideal_staff=2, skills=()
): ):
+114
View File
@@ -0,0 +1,114 @@
import datetime as dt
import uuid
from sqlmodel import select
from app.models import Artist, Role, VolunteerArtist
from tests.factories import make_artist, make_volunteer
def _url(edition, suffix=""):
return f"/editions/{edition.id}/artists{suffix}"
def test_create_and_list_ordered_by_date_then_name(client, edition):
client.post(
_url(edition), json={"name": "Ziak", "performance_date": "2026-07-11"}
)
client.post(
_url(edition), json={"name": "Aloïse Sauvage", "performance_date": "2026-07-11"}
)
r = client.post(
_url(edition), json={"name": "Chilla", "performance_date": "2026-07-10"}
)
assert r.status_code == 201
body = client.get(_url(edition)).json()
assert [(a["name"], a["performance_date"]) for a in body] == [
("Chilla", "2026-07-10"),
("Aloïse Sauvage", "2026-07-11"),
("Ziak", "2026-07-11"),
]
def test_blank_name_is_422(client, edition):
r = client.post(
_url(edition), json={"name": " ", "performance_date": "2026-07-10"}
)
assert r.status_code == 422
assert r.json()["detail"]["code"] == "artist.name_required"
def test_duplicate_name_and_date_conflicts(client, edition):
payload = {"name": "Chilla", "performance_date": "2026-07-10"}
assert client.post(_url(edition), json=payload).status_code == 201
r = client.post(_url(edition), json=payload)
assert r.status_code == 409
assert r.json()["detail"]["code"] == "artist.duplicate"
# Même nom, autre jour : autorisé.
r2 = client.post(
_url(edition), json={"name": "Chilla", "performance_date": "2026-07-11"}
)
assert r2.status_code == 201
def test_update_artist(client, edition, session):
artist = make_artist(session, edition, name="Chila")
r = client.patch(
_url(edition, f"/{artist.id}"),
json={"name": "Chilla", "performance_date": "2026-07-12"},
)
assert r.status_code == 200
assert r.json()["name"] == "Chilla"
assert r.json()["performance_date"] == "2026-07-12"
def test_delete_artist_removes_volunteer_wishes(client, edition, session):
artist = make_artist(session, edition)
volunteer = make_volunteer(session, edition, email="fan@e.fr")
assert client.patch(
f"/editions/{edition.id}/volunteers/{volunteer.id}",
json={"artist_ids": [str(artist.id)]},
).json()["artist_ids"] == [str(artist.id)]
assert client.delete(_url(edition, f"/{artist.id}")).status_code == 204
assert session.exec(select(Artist)).all() == []
assert session.exec(select(VolunteerArtist)).all() == []
fiche = client.get(
f"/editions/{edition.id}/volunteers/{volunteer.id}"
).json()
assert fiche["artist_ids"] == []
def test_unknown_edition_is_404(client):
assert client.get(f"/editions/{uuid.uuid4()}/artists").status_code == 404
def test_write_is_coordinator_only(edition, make_account, as_account):
lead = make_account(Role.LEAD, email="lead@e.fr")
api = as_account(lead)
resp = api.post(
_url(edition), json={"name": "X", "performance_date": "2026-07-10"}
)
assert resp.status_code == 403
# La lecture reste ouverte à tout compte de l'édition.
assert api.get(_url(edition)).status_code == 200
def test_other_edition_artist_is_404(client, edition, session):
from app.models import Edition
other = Edition(
year=2027,
start_date=dt.date(2027, 7, 1),
end_date=dt.date(2027, 7, 4),
)
session.add(other)
session.commit()
session.refresh(other)
stranger = make_artist(session, other, name="Ailleurs")
assert client.patch(
_url(edition, f"/{stranger.id}"), json={"name": "Y"}
).status_code == 404
+56 -2
View File
@@ -1,10 +1,12 @@
import datetime as dt import datetime as dt
import uuid
from sqlmodel import select from sqlmodel import select
from app.models import Assignment, Availability, AvailabilityStatus, Volunteer from app.models import Assignment, Availability, AvailabilityStatus, Volunteer
from tests.factories import ( from tests.factories import (
dt_at, dt_at,
make_artist,
make_availability, make_availability,
make_position, make_position,
make_shift, make_shift,
@@ -56,8 +58,6 @@ def test_list_is_ordered_by_name(client, edition, session):
def test_get_unknown_id_is_404(client, edition): def test_get_unknown_id_is_404(client, edition):
import uuid
assert client.get(_url(edition, f"/{uuid.uuid4()}")).status_code == 404 assert client.get(_url(edition, f"/{uuid.uuid4()}")).status_code == 404
@@ -77,6 +77,60 @@ def test_get_volunteer_of_another_edition_is_404(client, edition, session):
assert client.get(_url(edition, f"/{stranger.id}")).status_code == 404 assert client.get(_url(edition, f"/{stranger.id}")).status_code == 404
def test_create_with_artist_and_position_wishes(client, edition, session):
artist = make_artist(session, edition)
position = make_position(session, edition)
resp = client.post(
_url(edition),
json={
"first_name": "Fan",
"last_name": "Zele",
"artist_ids": [str(artist.id)],
"wished_position_ids": [str(position.id)],
},
)
assert resp.status_code == 201
body = resp.json()
assert body["artist_ids"] == [str(artist.id)]
assert body["wished_position_ids"] == [str(position.id)]
def test_unknown_artist_wish_is_422(client, edition):
resp = client.post(
_url(edition),
json={
"first_name": "A",
"last_name": "B",
"artist_ids": [str(uuid.uuid4())],
},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "artist.unknown"
def test_patch_replaces_and_clears_wishes(client, edition, session):
a1 = make_artist(session, edition, name="A1")
a2 = make_artist(session, edition, name="A2")
volunteer = make_volunteer(session, edition, email="w@ex.fr")
r1 = client.patch(
_url(edition, f"/{volunteer.id}"),
json={"artist_ids": [str(a2.id), str(a1.id), str(a2.id)]},
)
# Doublons retirés, tri par (date, nom).
assert r1.json()["artist_ids"] == [str(a1.id), str(a2.id)]
r2 = client.patch(
_url(edition, f"/{volunteer.id}"), json={"artist_ids": []}
)
assert r2.json()["artist_ids"] == []
# Champ absent = inchangé.
r3 = client.patch(
_url(edition, f"/{volunteer.id}"), json={"first_name": "Zoe"}
)
assert r3.json()["artist_ids"] == []
def test_patch_replaces_skills_and_can_clear(client, edition, session): def test_patch_replaces_skills_and_can_clear(client, edition, session):
volunteer = make_volunteer(session, edition, email="p@ex.fr") volunteer = make_volunteer(session, edition, email="p@ex.fr")
r1 = client.patch( r1 = client.patch(
+3
View File
@@ -22,8 +22,11 @@ EXPECTED_TABLES = {
"edition", "edition",
"day_part", "day_part",
"skill", "skill",
"artist",
"volunteer", "volunteer",
"volunteer_skill", "volunteer_skill",
"volunteer_artist",
"volunteer_position_wish",
"account", "account",
"magic_link_token", "magic_link_token",
"position", "position",