É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:
co-authored by
Claude Sonnet 5
parent
111d1e5c4b
commit
40127a2252
@@ -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()
|
||||
Reference in New Issue
Block a user