Files
benevoles/backend/app/sheets/pdf.py
T
AdamThomasandClaude Sonnet 5 f6fbfaf82a Feuilles de poste : ajouter la photo des bénévoles affectés
Vignette carrée (14 mm, proportions préservées) à côté du nom de chaque
bénévole affecté, à côté de son téléphone — case vide si le bénévole n'a
pas de photo. Complète l'affichage déjà en place sur l'écran référent et
l'écran bénévole (étape 22).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:06:51 +02:00

179 lines
6.0 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.lib.utils import ImageReader
from reportlab.platypus import (
Image,
PageBreak,
Paragraph,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
_PHOTO_BOX_MM = 14 * mm
_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
photo: bytes | None = 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, ...]
# 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]:
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=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
),
"shiftHeaderWarning": ParagraphStyle(
"ShiftHeaderWarning", parent=base["Heading3"],
fontSize=13, spaceBefore=12, spaceAfter=4, textColor=colors.HexColor("#b23a1f"),
),
"normal": base["Normal"],
}
def _photo_thumbnail(photo: bytes) -> Image:
"""Vignette carrée (14 mm de côté max), proportions préservées."""
width_px, height_px = ImageReader(io.BytesIO(photo)).getSize()
scale = _PHOTO_BOX_MM / max(width_px, height_px)
return Image(io.BytesIO(photo), width=width_px * scale, height=height_px * scale)
def _shift_table(shift: SheetShift) -> Table:
rows = [["", "Bénévole", "Téléphone"]] + [
[_photo_thumbnail(v.photo) if v.photo else "", v.name, v.phone or "—"]
for v in shift.volunteers
]
table = Table(rows, colWidths=[18 * mm, 92 * 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),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("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 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
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()