Files
benevoles/backend/app/sheets/pdf.py
T
AdamThomasandClaude Sonnet 5 40127a2252 É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>
2026-09-21 21:27:01 +02:00

149 lines
4.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()