diff --git a/CLAUDE.md b/CLAUDE.md index 78ebf0b..ffa8c2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,9 @@ colonne, route et variable utilise la colonne « Code / base ». | Créneau | `Shift` | | Disponibilité | `Availability` | | 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` | | Découpage de journée (paramétrable)| `DayPart` (`code`, `label`, `start_time`, `end_time`, `order`) | | Lien magique | `MagicLinkToken` | @@ -66,9 +69,20 @@ les tables sauf les tables d'association. - **`Volunteer`** : `email` (unique **par édition**, nullable pour les 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 - 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` (`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`), `lead_account_id` (→ `Account`, `SET NULL` à la suppression du compte). - **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont @@ -96,8 +110,9 @@ Conventions techniques : type ENUM natif PostgreSQL. - Suppression en cascade au niveau base (`ON DELETE CASCADE`) sur tous les `edition_id` et sur les liens vers `volunteer`, `shift`, `position`, - `skill`, `account`. La suppression complète d'un bénévole (RGPD) efface donc - ses `Availability`, `Assignment`, liens de compétence et son `Account`. + `skill`, `artist`, `account`. La suppression complète d'un bénévole (RGPD) + 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 + `tzdata`). `app.models.local_now()` pour les horodatages. @@ -119,6 +134,7 @@ Matrice appliquée par les endpoints : | `positions`/`shifts` écriture | ✔ | ses postes | — | | `assignments` écriture | ✔ | ses postes | — | | `assignments` lecture | ✔ | ✔ | les siennes | +| `artists` lecture / écriture | ✔ / ✔ | ✔ / — | ✔ / — | | `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — | **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 │ │ ├── api/ # routers + schemas Pydantic + deps │ │ │ # health, auth, accounts, editions, -│ │ │ # day_parts, imports, volunteers, -│ │ │ # positions, shifts, assignments, -│ │ │ # matrix, notifications +│ │ │ # day_parts, imports, artists, +│ │ │ # volunteers, positions, shifts, +│ │ │ # assignments, matrix, notifications │ │ ├── auth/ # mot de passe + lien magique + JWT + rôles │ │ ├── email/ # SMTP (sender) + rendu des emails (templates) │ │ ├── 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 | | 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 | +| 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, SMS, constructeur de formulaire, pointage sur site, IA. Frontend : diff --git a/backend/app/api/artists.py b/backend/app/api/artists.py new file mode 100644 index 0000000..851cdc0 --- /dev/null +++ b/backend/app/api/artists.py @@ -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) diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index ce4cc11..0eafbbc 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -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 diff --git a/backend/app/api/volunteers.py b/backend/app/api/volunteers.py index 48c8fb3..26baf71 100644 --- a/backend/app/api/volunteers.py +++ b/backend/app/api/volunteers.py @@ -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) diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json index a237708..57bb431 100644 --- a/backend/app/i18n/locales/fr.json +++ b/backend/app/i18n/locales/fr.json @@ -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é.", diff --git a/backend/app/main.py b/backend/app/main.py index 27fedc2..51d00b3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/migrations/versions/0006_artist_and_volunteer_wishes.py b/backend/app/migrations/versions/0006_artist_and_volunteer_wishes.py new file mode 100644 index 0000000..9ec1548 --- /dev/null +++ b/backend/app/migrations/versions/0006_artist_and_volunteer_wishes.py @@ -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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 74e11ea..80511a8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py index 99b717e..e94cd4f 100644 --- a/backend/app/models/domain.py +++ b/backend/app/models/domain.py @@ -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): diff --git a/backend/tests/factories.py b/backend/tests/factories.py index 22178b6..4601840 100644 --- a/backend/tests/factories.py +++ b/backend/tests/factories.py @@ -3,6 +3,7 @@ import datetime as dt from app.models import ( + Artist, Availability, AvailabilityStatus, Position, @@ -22,6 +23,18 @@ def make_position(session, edition, *, name="Bar", category=PositionCategory.BAR 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( session, edition, position, *, start, end, min_staff=1, ideal_staff=2, skills=() ): diff --git a/backend/tests/test_api_artists.py b/backend/tests/test_api_artists.py new file mode 100644 index 0000000..7149c3a --- /dev/null +++ b/backend/tests/test_api_artists.py @@ -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 diff --git a/backend/tests/test_api_volunteers.py b/backend/tests/test_api_volunteers.py index 70179c1..e94eebc 100644 --- a/backend/tests/test_api_volunteers.py +++ b/backend/tests/test_api_volunteers.py @@ -1,10 +1,12 @@ import datetime as dt +import uuid from sqlmodel import select from app.models import Assignment, Availability, AvailabilityStatus, Volunteer from tests.factories import ( dt_at, + make_artist, make_availability, make_position, 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): - import uuid - 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 +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): volunteer = make_volunteer(session, edition, email="p@ex.fr") r1 = client.patch( diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 0d0fd9f..12d4bf9 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -22,8 +22,11 @@ EXPECTED_TABLES = { "edition", "day_part", "skill", + "artist", "volunteer", "volunteer_skill", + "volunteer_artist", + "volunteer_position_wish", "account", "magic_link_token", "position",