Étape 22 : feuilles de poste (PDF), complément des badges

Une page par poste et par jour, listant les créneaux avec les bénévoles
affectés et leur téléphone — à imprimer et afficher sur place. Aucune
nouvelle entité : dérivé de Position/Shift/Assignment/Volunteer
existants (contrairement aux badges, pas de personnalisation à
configurer). Un créneau à besoin 0/0 sans affectation n'apparaît pas.

Backend : app/sheets/pdf.py (ReportLab, Platypus — SimpleDocTemplate/
Table plutôt que le canvas bas niveau des badges, plus adapté à un
rapport texte multi-pages), POST /editions/{id}/position-sheets/generate
(COORDINATION, {position_ids?} — absent = tous les postes).

Frontend : bouton « Feuilles de poste » dans la grille des besoins,
respecte le filtre de catégorie déjà actif à l'écran.

Vérifié : pytest (284 passés), ruff, alembic check, tsc --noEmit,
eslint, vite build, smoke (flux de bout en bout en mode démonstration)
— tous verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-09-21 21:27:01 +02:00
co-authored by Claude Sonnet 5
parent 111d1e5c4b
commit 40127a2252
17 changed files with 561 additions and 4 deletions
+13
View File
@@ -0,0 +1,13 @@
from app.sheets.pdf import (
PositionDaySheet,
SheetShift,
SheetVolunteer,
generate_position_sheets_pdf,
)
__all__ = [
"PositionDaySheet",
"SheetShift",
"SheetVolunteer",
"generate_position_sheets_pdf",
]
+148
View File
@@ -0,0 +1,148 @@
"""Génération des feuilles de poste (PDF).
Module isolé, fonction pure (`generate_position_sheets_pdf`) : aucun accès
base ici — voir `app/api/position_sheets.py` pour l'assemblage des données.
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 — à imprimer et afficher sur place.
Complément des badges (`app/badges/pdf.py`) : un badge identifie un
bénévole, une feuille identifie un poste pour la journée.
"""
import datetime as dt
import io
from dataclasses import dataclass
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.platypus import (
PageBreak,
Paragraph,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
_WEEKDAYS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"]
_MONTHS_FR = [
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
]
def _format_day_fr(date: dt.date) -> str:
return f"{_WEEKDAYS_FR[date.weekday()]} {date.day} {_MONTHS_FR[date.month - 1]} {date.year}"
@dataclass(frozen=True)
class SheetVolunteer:
name: str
phone: str | None
@dataclass(frozen=True)
class SheetShift:
starts_at: dt.datetime
ends_at: dt.datetime
min_staff: int
ideal_staff: int
volunteers: tuple[SheetVolunteer, ...]
@property
def understaffed(self) -> bool:
return len(self.volunteers) < self.min_staff
@dataclass(frozen=True)
class PositionDaySheet:
"""Une page : un poste, un jour, ses créneaux triés chronologiquement."""
position_name: str
position_location: str | None
date: dt.date
shifts: tuple[SheetShift, ...]
def _styles() -> dict[str, ParagraphStyle]:
base = getSampleStyleSheet()
return {
"title": ParagraphStyle(
"SheetTitle", parent=base["Title"], alignment=0, fontSize=22, spaceAfter=2
),
"subtitle": ParagraphStyle(
"SheetSubtitle", parent=base["Normal"],
fontSize=12, textColor=colors.grey, spaceAfter=16,
),
"shiftHeader": ParagraphStyle(
"ShiftHeader", parent=base["Heading3"], fontSize=13, spaceBefore=12, spaceAfter=4
),
"shiftHeaderWarning": ParagraphStyle(
"ShiftHeaderWarning", parent=base["Heading3"],
fontSize=13, spaceBefore=12, spaceAfter=4, textColor=colors.HexColor("#b23a1f"),
),
"normal": base["Normal"],
}
def _shift_table(shift: SheetShift) -> Table:
rows = [["Bénévole", "Téléphone"]] + [
[v.name, v.phone or "—"] for v in shift.volunteers
]
table = Table(rows, colWidths=[110 * mm, 55 * mm])
table.setStyle(
TableStyle(
[
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 10.5),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
("LINEBELOW", (0, 0), (-1, 0), 0.75, colors.black),
("GRID", (0, 1), (-1, -1), 0.25, colors.lightgrey),
]
)
)
return table
def generate_position_sheets_pdf(sheets: list[PositionDaySheet]) -> bytes:
styles = _styles()
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer, pagesize=A4,
leftMargin=18 * mm, rightMargin=18 * mm, topMargin=16 * mm, bottomMargin=16 * mm,
)
story = []
for i, sheet in enumerate(sheets):
if i > 0:
story.append(PageBreak())
story.append(Paragraph(sheet.position_name, styles["title"]))
subtitle = _format_day_fr(sheet.date)
if sheet.position_location:
subtitle += f" — {sheet.position_location}"
story.append(Paragraph(subtitle, styles["subtitle"]))
if not sheet.shifts:
story.append(Paragraph("Aucun créneau ce jour-là.", styles["normal"]))
continue
for shift in sheet.shifts:
header = (
f"{shift.starts_at:%H:%M} – {shift.ends_at:%H:%M}"
f" ({len(shift.volunteers)}/{shift.ideal_staff})"
)
if shift.understaffed:
header += " ⚠ sous l'effectif minimal"
style = styles["shiftHeaderWarning"] if shift.understaffed else styles["shiftHeader"]
story.append(Paragraph(header, style))
if shift.volunteers:
story.append(_shift_table(shift))
else:
story.append(Paragraph("Personne affecté pour l'instant.", styles["normal"]))
story.append(Spacer(1, 0))
doc.build(story)
return buffer.getvalue()