diff --git a/CLAUDE.md b/CLAUDE.md index 0e828a5..1242c4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -194,7 +194,10 @@ les tables sauf les tables d'association. téléphone — « à désigner » si aucun) en en-tête, puis les créneaux du jour avec les bénévoles affectés, leur téléphone et leur photo (`Volunteer.photo` — vignette carrée 14 mm, proportions préservées, case vide si le bénévole - n'en a pas), pour affichage sur place. + n'en a pas), pour affichage sur place. Sous le référent, la programmation + du jour (`Artist`, § 2) : tous les passages de cette date, quel que soit le + poste — repère utile même hors de la régie (affluence au bar, sécurité...) + — triés par heure, omise si aucun artiste ne joue ce jour-là. `POST /editions/{id}/position-sheets/generate` (COORDINATION, `{position_ids?}` — absent = tous) ; un créneau à besoin 0/0 sans aucune affectation n'apparaît pas (rien à imprimer). @@ -651,6 +654,7 @@ Aucune dépendance locale requise hors Docker. | 23 | Créneaux : plus de génération automatique à la création d'un poste — création manuelle à horaire libre (`ConfigGrid.tsx`), génération depuis les créneaux types conservée en option (« Générer les créneaux ») ; grille des besoins redessinée en vrai axe des temps (une colonne = 30 min, bornée à la plage utilisée par jour, cases fusionnées pour chaque créneau — `buildTimeGrid`/`placeInTimeGrid`) | à valider | | 24 | Feuilles de poste : photo des bénévoles affectés à côté de leur nom (vignette 14 mm, case vide si absente) | à valider | | 25 | Formulaire public : photo recadrée en carré côté navigateur avant l'envoi, aperçu agrandi ; personnalisation de la page (logo, texte d'introduction) depuis l'écran de configuration (`/inscriptions`) | à valider | +| 26 | Feuilles de poste : programmation du jour (horaires des artistes, tous postes confondus) sous le référent | à valider | **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + diff --git a/backend/app/api/position_sheets.py b/backend/app/api/position_sheets.py index e75fb86..b55b031 100644 --- a/backend/app/api/position_sheets.py +++ b/backend/app/api/position_sheets.py @@ -17,9 +17,10 @@ from app.api.deps import get_edition from app.api.schemas import PositionSheetsRequest from app.auth.deps import require_coordinator from app.db import get_session -from app.models import Account, Assignment, Edition, Position, Shift, Volunteer +from app.models import Account, Artist, Assignment, Edition, Position, Shift, Volunteer from app.sheets.pdf import ( PositionDaySheet, + SheetArtist, SheetShift, SheetVolunteer, generate_position_sheets_pdf, @@ -89,6 +90,15 @@ def generate_position_sheets( pid: referent for pid, referent in referent_rows if referent is not None } + # Programmation du jour, tous postes confondus — voir `SheetArtist`. + artists_by_date: dict[dt.date, list[Artist]] = defaultdict(list) + for artist in session.exec( + select(Artist) + .where(Artist.edition_id == edition.id) + .order_by(Artist.start_time) + ): + artists_by_date[artist.performance_date].append(artist) + positions_by_id = {p.id: p for p in positions} def _sheet(position_id: uuid.UUID, date: dt.date, day_shifts: list[Shift]) -> PositionDaySheet: @@ -99,6 +109,10 @@ def generate_position_sheets( date=date, referent_name=f"{referent.first_name} {referent.last_name}" if referent else None, referent_phone=referent.phone if referent else None, + artists=tuple( + SheetArtist(name=a.name, start_time=a.start_time, end_time=a.end_time) + for a in artists_by_date.get(date, []) + ), shifts=tuple( SheetShift( starts_at=s.starts_at, diff --git a/backend/app/sheets/pdf.py b/backend/app/sheets/pdf.py index 1bdad49..eb1f828 100644 --- a/backend/app/sheets/pdf.py +++ b/backend/app/sheets/pdf.py @@ -48,6 +48,13 @@ class SheetVolunteer: photo: bytes | None = None +@dataclass(frozen=True) +class SheetArtist: + name: str + start_time: dt.time + end_time: dt.time + + @dataclass(frozen=True) class SheetShift: starts_at: dt.datetime @@ -73,6 +80,10 @@ class PositionDaySheet: # (`Position.lead_account_id`). referent_name: str | None = None referent_phone: str | None = None + # Programmation du jour (tous postes confondus, pas seulement les + # artistes que les bénévoles affectés souhaitent voir) — repère utile sur + # place (affluence au bar, sécurité...), triée par heure de passage. + artists: tuple[SheetArtist, ...] = () def _styles() -> dict[str, ParagraphStyle]: @@ -87,7 +98,11 @@ def _styles() -> dict[str, ParagraphStyle]: ), "referent": ParagraphStyle( "SheetReferent", parent=base["Normal"], - fontSize=11.5, fontName="Helvetica-Bold", spaceAfter=16, + fontSize=11.5, fontName="Helvetica-Bold", spaceAfter=6, + ), + "artists": ParagraphStyle( + "SheetArtists", parent=base["Normal"], + fontSize=9.5, textColor=colors.HexColor("#4a4a4a"), spaceAfter=16, ), "shiftHeader": ParagraphStyle( "ShiftHeader", parent=base["Heading3"], fontSize=13, spaceBefore=12, spaceAfter=4 @@ -155,6 +170,12 @@ def generate_position_sheets_pdf(sheets: list[PositionDaySheet]) -> bytes: else: story.append(Paragraph("Référent : à désigner", styles["referent"])) + if sheet.artists: + programme = " · ".join( + f"{a.start_time:%H:%M}–{a.end_time:%H:%M} {a.name}" for a in sheet.artists + ) + story.append(Paragraph(f"Programmation : {programme}", styles["artists"])) + if not sheet.shifts: story.append(Paragraph("Aucun créneau ce jour-là.", styles["normal"])) continue diff --git a/backend/tests/test_position_sheets_api.py b/backend/tests/test_position_sheets_api.py index ded3224..28c6b42 100644 --- a/backend/tests/test_position_sheets_api.py +++ b/backend/tests/test_position_sheets_api.py @@ -1,9 +1,10 @@ """Feuilles de poste (PDF) — API.""" import base64 +import datetime as dt from app.models import Assignment, Role -from tests.factories import dt_at, make_position, make_shift, make_volunteer +from tests.factories import dt_at, make_artist, make_position, make_shift, make_volunteer # PNG 1×1 valide (transparent), le plus petit possible. _PNG = base64.b64decode( @@ -101,6 +102,36 @@ def test_includes_a_volunteer_photo_when_present(client, edition, session): assert resp.content.startswith(b"%PDF") +def test_includes_the_day_lineup_across_all_positions(client, edition, session): + bar = make_position(session, edition, name="Bar central") + welcome = make_position(session, edition, name="Accueil") + make_shift( + session, edition, bar, start=dt_at(9, 19), end=dt_at(9, 22), + min_staff=1, ideal_staff=2, + ) + make_shift( + session, edition, welcome, start=dt_at(9, 9), end=dt_at(9, 13), + min_staff=1, ideal_staff=2, + ) + # Passage le 9 (jour des deux créneaux) : doit apparaître sur les deux + # feuilles, pas seulement celle du poste « Régie ». + make_artist( + session, edition, name="Chilla", + performance_date=dt.date(2026, 7, 9), + start_time=dt.time(20, 0), end_time=dt.time(21, 0), + ) + # Passage un autre jour : ne doit apparaître sur aucune des deux. + make_artist( + session, edition, name="Hors sujet", + performance_date=dt.date(2026, 7, 11), + start_time=dt.time(20, 0), end_time=dt.time(21, 0), + ) + + resp = client.post(_url(edition), json={}) + assert resp.status_code == 200 + assert resp.content.startswith(b"%PDF") + + def test_position_ids_filter_scopes_the_sheets(client, edition, session): bar = make_position(session, edition, name="Bar central") welcome = make_position(session, edition, name="Accueil") diff --git a/backend/tests/test_position_sheets_pdf.py b/backend/tests/test_position_sheets_pdf.py index e1299ba..4de824a 100644 --- a/backend/tests/test_position_sheets_pdf.py +++ b/backend/tests/test_position_sheets_pdf.py @@ -5,6 +5,7 @@ import datetime as dt from app.sheets.pdf import ( PositionDaySheet, + SheetArtist, SheetShift, SheetVolunteer, generate_position_sheets_pdf, @@ -118,3 +119,26 @@ def test_a_volunteer_photo_makes_the_pdf_larger(): [_sheet(volunteers=(SheetVolunteer(name="Alex Martin", phone=None, photo=_PNG),))] ) assert len(with_photo) > len(without_photo) + + +def test_generates_a_valid_pdf_with_the_day_lineup(): + sheet = PositionDaySheet( + position_name="Bar central", position_location="Chapiteau", + date=dt.date(2026, 7, 10), shifts=(), + artists=( + SheetArtist(name="Chilla", start_time=dt.time(20, 0), end_time=dt.time(21, 0)), + SheetArtist(name="XYZ", start_time=dt.time(21, 30), end_time=dt.time(23, 0)), + ), + ) + pdf = generate_position_sheets_pdf([sheet]) + assert pdf.startswith(b"%PDF") + + +def test_generates_a_valid_pdf_without_a_lineup(): + sheet = PositionDaySheet( + position_name="Bar central", position_location="Chapiteau", + date=dt.date(2026, 7, 10), shifts=(), + ) + assert sheet.artists == () + pdf = generate_position_sheets_pdf([sheet]) + assert pdf.startswith(b"%PDF")