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
@@ -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 :
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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")
|
||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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=()
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user