diff --git a/CLAUDE.md b/CLAUDE.md index 4c6df5c..577af41 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -179,8 +179,9 @@ les tables sauf les tables d'association. aucune nouvelle entité — dérivées de `Position`/`Shift`/`Assignment`/ `Volunteer` existants. Complément des badges (un badge identifie un bénévole ; une feuille identifie un poste pour la journée) : une page par - poste et par jour, listant les créneaux du jour avec les bénévoles - affectés et leur téléphone, pour affichage sur place. + poste et par jour, avec son référent (`Position.lead_account_id`, nom + + téléphone — « à désigner » si aucun) en en-tête, puis les créneaux du jour + avec les bénévoles affectés et leur téléphone, pour affichage sur place. `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). @@ -616,7 +617,7 @@ Aucune dépendance locale requise hors Docker. | 19 | Revue de sécurité avant ouverture au public : refus de démarrer en production avec un `JWT_SECRET` par défaut/vide/court, avertissement si `EMAIL_ENABLED=false` en production, en-têtes de sécurité (nginx + Caddy), pas de jeton dans les logs d'accès du frontend, limiteur de tentatives sur `/auth/login` et `/auth/magic-link` | à valider | | 20 | Planches de badges bénévoles (PDF) : recto personnalisable (fond + champs nom/poste(s)/catégorie/rôle, éditeur glisser-déposer), verso fixe (même image pour tous), écran « Badges » dédié (`/badges`, COORDINATION) | à valider | | 21 | Formulaire public d'inscription (`/inscription`, sans authentification) : champs activables par la coordination (email, téléphone, vœux de poste — max 2, vœux d'artiste — un par soir, vœu de compagnon en texte libre, disponibilité/cotisation déclaratives, photo, commentaire), captcha hCaptcha, ouverture/fermeture, lien à partager, écran de configuration dédié (`/inscriptions`, COORDINATION) — les bénévoles créés rejoignent directement la liste | à valider | -| 22 | Feuilles de poste (PDF, complément des badges) : une page par poste et par jour avec les créneaux, les bénévoles affectés et leur téléphone — bouton depuis la grille des besoins (COORDINATION) ; photo du bénévole affichée sur son écran et sur l'écran référent (en face du nom de chaque membre de l'équipe) | à valider | +| 22 | Feuilles de poste (PDF, complément des badges) : une page par poste et par jour avec le référent, les créneaux, les bénévoles affectés et leur téléphone — bouton depuis la grille des besoins (COORDINATION) ; photo du bénévole affichée sur son écran et sur l'écran référent (en face du nom de chaque membre de l'équipe) | à 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 0641fdc..c519eb4 100644 --- a/backend/app/api/position_sheets.py +++ b/backend/app/api/position_sheets.py @@ -10,6 +10,7 @@ import uuid from collections import defaultdict from fastapi import APIRouter, Depends, HTTPException, Response +from sqlalchemy.orm import aliased from sqlmodel import Session, select from app.api.deps import get_edition @@ -77,12 +78,27 @@ def generate_position_sheets( continue shifts_by_position_day[(shift.position_id, shift.starts_at.date())].append(shift) + Referent = aliased(Volunteer) + referent_rows = session.exec( + select(Position.id, Referent) + .join(Account, Account.id == Position.lead_account_id, isouter=True) + .join(Referent, Referent.id == Account.volunteer_id, isouter=True) + .where(Position.id.in_(position_ids)) + ).all() + referent_by_position: dict[uuid.UUID, Volunteer] = { + pid: referent for pid, referent in referent_rows if referent is not None + } + positions_by_id = {p.id: p for p in positions} - sheets = [ - PositionDaySheet( + + def _sheet(position_id: uuid.UUID, date: dt.date, day_shifts: list[Shift]) -> PositionDaySheet: + referent = referent_by_position.get(position_id) + return PositionDaySheet( position_name=positions_by_id[position_id].name, position_location=positions_by_id[position_id].location, date=date, + referent_name=f"{referent.first_name} {referent.last_name}" if referent else None, + referent_phone=referent.phone if referent else None, shifts=tuple( SheetShift( starts_at=s.starts_at, @@ -97,6 +113,9 @@ def generate_position_sheets( for s in sorted(day_shifts, key=lambda s: s.starts_at) ), ) + + sheets = [ + _sheet(position_id, date, day_shifts) for (position_id, date), day_shifts in sorted( shifts_by_position_day.items(), key=lambda item: (positions_by_id[item[0][0]].name, item[0][1]), diff --git a/backend/app/sheets/pdf.py b/backend/app/sheets/pdf.py index 49c497c..5380f10 100644 --- a/backend/app/sheets/pdf.py +++ b/backend/app/sheets/pdf.py @@ -64,6 +64,10 @@ class PositionDaySheet: position_location: str | None date: dt.date shifts: tuple[SheetShift, ...] + # None si le poste n'a pas (encore) de référent désigné + # (`Position.lead_account_id`). + referent_name: str | None = None + referent_phone: str | None = None def _styles() -> dict[str, ParagraphStyle]: @@ -74,7 +78,11 @@ def _styles() -> dict[str, ParagraphStyle]: ), "subtitle": ParagraphStyle( "SheetSubtitle", parent=base["Normal"], - fontSize=12, textColor=colors.grey, spaceAfter=16, + fontSize=12, textColor=colors.grey, spaceAfter=4, + ), + "referent": ParagraphStyle( + "SheetReferent", parent=base["Normal"], + fontSize=11.5, fontName="Helvetica-Bold", spaceAfter=16, ), "shiftHeader": ParagraphStyle( "ShiftHeader", parent=base["Heading3"], fontSize=13, spaceBefore=12, spaceAfter=4 @@ -125,6 +133,14 @@ def generate_position_sheets_pdf(sheets: list[PositionDaySheet]) -> bytes: subtitle += f" — {sheet.position_location}" story.append(Paragraph(subtitle, styles["subtitle"])) + if sheet.referent_name: + referent_line = f"Référent : {sheet.referent_name}" + if sheet.referent_phone: + referent_line += f" — {sheet.referent_phone}" + story.append(Paragraph(referent_line, styles["referent"])) + else: + story.append(Paragraph("Référent : à désigner", styles["referent"])) + 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 5f2722a..2ae43ff 100644 --- a/backend/tests/test_position_sheets_api.py +++ b/backend/tests/test_position_sheets_api.py @@ -50,6 +50,26 @@ def test_generates_a_pdf_with_assigned_volunteers(client, edition, session): assert resp.content.startswith(b"%PDF") +def test_generates_a_pdf_with_a_referent(client, edition, session, make_account): + position = make_position(session, edition, name="Bar central") + make_shift( + session, edition, position, start=dt_at(9, 19), end=dt_at(9, 22), + min_staff=1, ideal_staff=2, + ) + referent_volunteer = make_volunteer(session, edition, first_name="Sam", last_name="Nuit") + referent_account = make_account( + Role.LEAD, volunteer_id=referent_volunteer.id, email="referent@e.fr" + ) + client.patch( + f"/editions/{edition.id}/positions/{position.id}", + json={"lead_account_id": str(referent_account.id)}, + ) + + 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 6f6bad7..39b8c68 100644 --- a/backend/tests/test_position_sheets_pdf.py +++ b/backend/tests/test_position_sheets_pdf.py @@ -68,3 +68,23 @@ def test_multiple_sheets_produce_a_larger_pdf(): one = generate_position_sheets_pdf([_sheet()]) two = generate_position_sheets_pdf([_sheet(), _sheet()]) assert len(two) > len(one) + + +def test_generates_a_valid_pdf_with_a_referent(): + sheet = PositionDaySheet( + position_name="Bar central", position_location="Chapiteau", + date=dt.date(2026, 7, 10), shifts=(), + referent_name="Sam Nuit", referent_phone="0600000000", + ) + pdf = generate_position_sheets_pdf([sheet]) + assert pdf.startswith(b"%PDF") + + +def test_generates_a_valid_pdf_without_a_referent(): + sheet = PositionDaySheet( + position_name="Bar central", position_location="Chapiteau", + date=dt.date(2026, 7, 10), shifts=(), + ) + assert sheet.referent_name is None + pdf = generate_position_sheets_pdf([sheet]) + assert pdf.startswith(b"%PDF")