Feuilles de poste : ajouter le référent en en-tête

Nom + téléphone du référent du poste (Position.lead_account_id), juste
sous le sous-titre (jour/lieu) — « Référent : à désigner » si aucun.
Même source que le référent affiché à l'écran bénévole
(volunteers.py:get_volunteer_planning).

Vérifié : pytest (287 passés, dont les nouveaux tests avec/sans
référent), ruff, alembic check — tous verts (pas de changement
frontend, la feuille reste un simple téléchargement de PDF).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-09-22 18:21:26 +02:00
co-authored by Claude Sonnet 5
parent 2a2a0c23ca
commit f1d88302a4
5 changed files with 82 additions and 6 deletions
+4 -3
View File
@@ -179,8 +179,9 @@ les tables sauf les tables d'association.
aucune nouvelle entité — dérivées de `Position`/`Shift`/`Assignment`/ aucune nouvelle entité — dérivées de `Position`/`Shift`/`Assignment`/
`Volunteer` existants. Complément des badges (un badge identifie un `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 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 poste et par jour, avec son référent (`Position.lead_account_id`, nom +
affectés et leur téléphone, pour affichage sur place. 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, `POST /editions/{id}/position-sheets/generate` (COORDINATION,
`{position_ids?}` — absent = tous) ; un créneau à besoin 0/0 sans aucune `{position_ids?}` — absent = tous) ; un créneau à besoin 0/0 sans aucune
affectation n'apparaît pas (rien à imprimer). 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 | | 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 | | 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 | | 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 **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+21 -2
View File
@@ -10,6 +10,7 @@ import uuid
from collections import defaultdict from collections import defaultdict
from fastapi import APIRouter, Depends, HTTPException, Response from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.orm import aliased
from sqlmodel import Session, select from sqlmodel import Session, select
from app.api.deps import get_edition from app.api.deps import get_edition
@@ -77,12 +78,27 @@ def generate_position_sheets(
continue continue
shifts_by_position_day[(shift.position_id, shift.starts_at.date())].append(shift) 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} 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_name=positions_by_id[position_id].name,
position_location=positions_by_id[position_id].location, position_location=positions_by_id[position_id].location,
date=date, 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( shifts=tuple(
SheetShift( SheetShift(
starts_at=s.starts_at, 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) 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( for (position_id, date), day_shifts in sorted(
shifts_by_position_day.items(), shifts_by_position_day.items(),
key=lambda item: (positions_by_id[item[0][0]].name, item[0][1]), key=lambda item: (positions_by_id[item[0][0]].name, item[0][1]),
+17 -1
View File
@@ -64,6 +64,10 @@ class PositionDaySheet:
position_location: str | None position_location: str | None
date: dt.date date: dt.date
shifts: tuple[SheetShift, ...] 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]: def _styles() -> dict[str, ParagraphStyle]:
@@ -74,7 +78,11 @@ def _styles() -> dict[str, ParagraphStyle]:
), ),
"subtitle": ParagraphStyle( "subtitle": ParagraphStyle(
"SheetSubtitle", parent=base["Normal"], "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": ParagraphStyle(
"ShiftHeader", parent=base["Heading3"], fontSize=13, spaceBefore=12, spaceAfter=4 "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}" subtitle += f" — {sheet.position_location}"
story.append(Paragraph(subtitle, styles["subtitle"])) 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: if not sheet.shifts:
story.append(Paragraph("Aucun créneau ce jour-là.", styles["normal"])) story.append(Paragraph("Aucun créneau ce jour-là.", styles["normal"]))
continue continue
+20
View File
@@ -50,6 +50,26 @@ def test_generates_a_pdf_with_assigned_volunteers(client, edition, session):
assert resp.content.startswith(b"%PDF") 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): def test_position_ids_filter_scopes_the_sheets(client, edition, session):
bar = make_position(session, edition, name="Bar central") bar = make_position(session, edition, name="Bar central")
welcome = make_position(session, edition, name="Accueil") welcome = make_position(session, edition, name="Accueil")
+20
View File
@@ -68,3 +68,23 @@ def test_multiple_sheets_produce_a_larger_pdf():
one = generate_position_sheets_pdf([_sheet()]) one = generate_position_sheets_pdf([_sheet()])
two = generate_position_sheets_pdf([_sheet(), _sheet()]) two = generate_position_sheets_pdf([_sheet(), _sheet()])
assert len(two) > len(one) 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")