Étape 62 : les fiches de poste intégraient la photo d'origine de chaque bénévole pour chaque créneau (CPU à 300 %, puis erreur) — photos réduites à la vignette une fois par PDF ; badges : photo recadrée une fois par génération et décodée à taille réduite. Étape 61 : sans photo, le cadre de la photo reste vide sur le badge. Étape 60 : vue par bénévole en tranches de 30 min, affectations étirées sur toute leur durée, postes simultanés empilés, ligne de l'heure actuelle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
233 lines
8.3 KiB
Python
233 lines
8.3 KiB
Python
"""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 PIL import Image as PILImage
|
||
from PIL import ImageOps, UnidentifiedImageError
|
||
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 (
|
||
Image,
|
||
PageBreak,
|
||
Paragraph,
|
||
SimpleDocTemplate,
|
||
Spacer,
|
||
Table,
|
||
TableStyle,
|
||
)
|
||
|
||
_PHOTO_BOX_MM = 14 * mm
|
||
# Côté de la vignette en pixels : 14 mm à 300 dpi.
|
||
_PHOTO_BOX_PX = round(14 / 25.4 * 300)
|
||
|
||
_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 SheetArtist:
|
||
name: str
|
||
start_time: dt.time
|
||
end_time: dt.time
|
||
|
||
|
||
@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
|
||
# Programmation du jour (tous postes confondus, pas seulement les
|
||
# artistes que les bénévoles affectés souhaitent voir) — repère utile sur
|
||
# place (affluence au bar, sécurité...), triée par heure de passage.
|
||
artists: tuple[SheetArtist, ...] = ()
|
||
|
||
|
||
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=6,
|
||
),
|
||
"artists": ParagraphStyle(
|
||
"SheetArtists", parent=base["Normal"],
|
||
fontSize=9.5, textColor=colors.HexColor("#4a4a4a"), 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 _thumbnail(photo: bytes) -> tuple[bytes, int, int] | None:
|
||
"""Photo réduite à la taille de la vignette (orientation EXIF appliquée),
|
||
en JPEG : `(octets, largeur, hauteur)`, `None` si illisible. La photo
|
||
d'origine (souvent plusieurs Mo, prise au téléphone) intégrée telle
|
||
quelle pour chaque bénévole de chaque créneau faisait tourner la
|
||
génération plusieurs minutes et produisait un PDF énorme — jusqu'à
|
||
l'erreur (bug corrigé une fois)."""
|
||
try:
|
||
with PILImage.open(io.BytesIO(photo)) as img:
|
||
# JPEG : décodage directement à taille réduite (bien plus rapide).
|
||
img.draft("RGB", (_PHOTO_BOX_PX, _PHOTO_BOX_PX))
|
||
thumb = ImageOps.exif_transpose(img).convert("RGB")
|
||
thumb.thumbnail((_PHOTO_BOX_PX, _PHOTO_BOX_PX))
|
||
except (UnidentifiedImageError, OSError, ValueError):
|
||
return None
|
||
out = io.BytesIO()
|
||
thumb.save(out, format="JPEG", quality=85)
|
||
return out.getvalue(), thumb.width, thumb.height
|
||
|
||
|
||
def _photo_cell(photo: bytes | None, cache: dict[bytes, tuple[bytes, int, int] | None]):
|
||
"""Vignette carrée (14 mm de côté max), proportions préservées. Chaque
|
||
photo n'est réduite qu'une fois par PDF (`cache`), même si le bénévole
|
||
figure sur plusieurs créneaux."""
|
||
if not photo:
|
||
return ""
|
||
if photo not in cache:
|
||
cache[photo] = _thumbnail(photo)
|
||
thumb = cache[photo]
|
||
if thumb is None:
|
||
return ""
|
||
data, width_px, height_px = thumb
|
||
scale = _PHOTO_BOX_MM / max(width_px, height_px)
|
||
return Image(io.BytesIO(data), width=width_px * scale, height=height_px * scale)
|
||
|
||
|
||
def _shift_table(shift: SheetShift, cache: dict[bytes, tuple[bytes, int, int] | None]) -> Table:
|
||
rows = [["", "Bénévole", "Téléphone"]] + [
|
||
[_photo_cell(v.photo, cache), 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 = []
|
||
thumbnails: dict[bytes, tuple[bytes, int, int] | None] = {}
|
||
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 sheet.artists:
|
||
programme = " · ".join(
|
||
f"{a.start_time:%H:%M}–{a.end_time:%H:%M} {a.name}" for a in sheet.artists
|
||
)
|
||
story.append(Paragraph(f"Programmation : {programme}", styles["artists"]))
|
||
|
||
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, thumbnails))
|
||
else:
|
||
story.append(Paragraph("Personne affecté pour l'instant.", styles["normal"]))
|
||
story.append(Spacer(1, 0))
|
||
|
||
doc.build(story)
|
||
return buffer.getvalue()
|