Bénévoles deux par ligne (vignette 10 mm, nom et téléphone superposés), titres, en-têtes de créneau et marges resserrés — 6 feuilles réalistes : 11 pages ⇒ 6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
325 lines
12 KiB
Python
325 lines
12 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 xml.sax.saxutils import escape
|
||
|
||
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,
|
||
)
|
||
|
||
# Mise en page compacte (une feuille = une page autant que possible) :
|
||
# vignette de 10 mm, deux bénévoles par ligne.
|
||
_PHOTO_BOX_MM = 10 * mm
|
||
# Côté de la vignette en pixels : 10 mm à 300 dpi.
|
||
_PHOTO_BOX_PX = round(10 / 25.4 * 300)
|
||
_PER_ROW = 2
|
||
|
||
_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=18, leading=21, spaceBefore=0, spaceAfter=1,
|
||
),
|
||
"subtitle": ParagraphStyle(
|
||
"SheetSubtitle", parent=base["Normal"],
|
||
fontSize=10.5, leading=13, textColor=colors.grey, spaceAfter=2,
|
||
),
|
||
"referent": ParagraphStyle(
|
||
"SheetReferent", parent=base["Normal"],
|
||
fontSize=10.5, leading=13, fontName="Helvetica-Bold", spaceAfter=3,
|
||
),
|
||
"artists": ParagraphStyle(
|
||
"SheetArtists", parent=base["Normal"],
|
||
fontSize=8.5, leading=10.5, textColor=colors.HexColor("#4a4a4a"), spaceAfter=4,
|
||
),
|
||
"shiftHeader": ParagraphStyle(
|
||
"ShiftHeader", parent=base["Heading3"],
|
||
fontSize=11, leading=13, spaceBefore=7, spaceAfter=3,
|
||
),
|
||
"shiftHeaderWarning": ParagraphStyle(
|
||
"ShiftHeaderWarning", parent=base["Heading3"],
|
||
fontSize=11, leading=13, spaceBefore=7, spaceAfter=3,
|
||
textColor=colors.HexColor("#b23a1f"),
|
||
),
|
||
"volunteerName": ParagraphStyle(
|
||
"SheetVolunteerName", parent=base["Normal"],
|
||
fontName="Helvetica-Bold", fontSize=9.5, leading=11.5,
|
||
),
|
||
"volunteerPhone": ParagraphStyle(
|
||
"SheetVolunteerPhone", parent=base["Normal"], fontSize=9, leading=11,
|
||
),
|
||
"footerTitle": ParagraphStyle(
|
||
"SheetFooterTitle", parent=base["Normal"],
|
||
fontName="Helvetica-Bold", fontSize=8.5, leading=11,
|
||
),
|
||
"footer": ParagraphStyle(
|
||
"SheetFooter", parent=base["Normal"], fontSize=8.5, leading=11,
|
||
),
|
||
"normal": base["Normal"],
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class SheetFooter:
|
||
"""Message imprimé en bas de chaque page (message de l'organisation,
|
||
`Edition.volunteer_message`) : `title` en gras, puis `message` —
|
||
retours à la ligne conservés."""
|
||
|
||
title: str
|
||
message: str
|
||
|
||
|
||
_MARGIN_X = 15 * mm
|
||
_MARGIN_TOP = 12 * mm
|
||
_MARGIN_BOTTOM = 12 * mm
|
||
# Écart entre le bas du contenu et le filet du pied de page.
|
||
_FOOTER_GAP = 5 * mm
|
||
|
||
|
||
def _footer_paragraphs(footer: SheetFooter, styles: dict) -> list[Paragraph]:
|
||
message = escape(footer.message).replace("\n", "<br/>")
|
||
return [
|
||
Paragraph(escape(footer.title), styles["footerTitle"]),
|
||
Paragraph(message, styles["footer"]),
|
||
]
|
||
|
||
|
||
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],
|
||
styles: dict[str, ParagraphStyle],
|
||
width: float,
|
||
) -> Table:
|
||
"""Bénévoles du créneau, deux par ligne : vignette, puis nom (gras) et
|
||
téléphone l'un sous l'autre. Deux fois moins de lignes qu'une liste à
|
||
une personne par ligne (avant l'étape 65, souvent deux pages par
|
||
feuille)."""
|
||
photo_w = _PHOTO_BOX_MM + 3 * mm
|
||
text_w = width / _PER_ROW - photo_w
|
||
cells = [
|
||
[
|
||
_photo_cell(v.photo, cache),
|
||
[
|
||
Paragraph(escape(v.name), styles["volunteerName"]),
|
||
Paragraph(escape(v.phone or "—"), styles["volunteerPhone"]),
|
||
],
|
||
]
|
||
for v in shift.volunteers
|
||
]
|
||
rows = []
|
||
for i in range(0, len(cells), _PER_ROW):
|
||
chunk = cells[i : i + _PER_ROW]
|
||
chunk += [["", ""]] * (_PER_ROW - len(chunk))
|
||
rows.append([cell for pair in chunk for cell in pair])
|
||
table = Table(rows, colWidths=[photo_w, text_w] * _PER_ROW)
|
||
style = [
|
||
("TOPPADDING", (0, 0), (-1, -1), 2),
|
||
("BOTTOMPADDING", (0, 0), (-1, -1), 2),
|
||
("LEFTPADDING", (0, 0), (-1, -1), 3),
|
||
("RIGHTPADDING", (0, 0), (-1, -1), 3),
|
||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||
("LINEBELOW", (0, 0), (-1, -1), 0.25, colors.lightgrey),
|
||
("LINEABOVE", (0, 0), (-1, 0), 0.25, colors.lightgrey),
|
||
]
|
||
# Filet vertical entre les deux bénévoles d'une même ligne.
|
||
for col in range(2, 2 * _PER_ROW, 2):
|
||
style.append(("LINEBEFORE", (col, 0), (col, -1), 0.25, colors.lightgrey))
|
||
table.setStyle(TableStyle(style))
|
||
return table
|
||
|
||
|
||
def generate_position_sheets_pdf(
|
||
sheets: list[PositionDaySheet], footer: SheetFooter | None = None
|
||
) -> bytes:
|
||
"""`footer` : imprimé en bas de **chaque** page (y compris la suite
|
||
d'une feuille sur une seconde page) ; la marge du bas s'agrandit de sa
|
||
hauteur pour que le contenu ne passe jamais dessous."""
|
||
styles = _styles()
|
||
buffer = io.BytesIO()
|
||
width = A4[0] - 2 * _MARGIN_X
|
||
paragraphs = _footer_paragraphs(footer, styles) if footer else []
|
||
footer_height = sum(p.wrap(width, A4[1])[1] for p in paragraphs)
|
||
doc = SimpleDocTemplate(
|
||
buffer, pagesize=A4,
|
||
leftMargin=_MARGIN_X, rightMargin=_MARGIN_X, topMargin=_MARGIN_TOP,
|
||
bottomMargin=_MARGIN_BOTTOM + (footer_height + _FOOTER_GAP if paragraphs else 0),
|
||
)
|
||
|
||
def draw_footer(canvas, _doc) -> None:
|
||
if not paragraphs:
|
||
return
|
||
canvas.saveState()
|
||
top = _MARGIN_BOTTOM + footer_height
|
||
canvas.setStrokeColor(colors.black)
|
||
canvas.setLineWidth(0.75)
|
||
canvas.line(_MARGIN_X, top + 2 * mm, _MARGIN_X + width, top + 2 * mm)
|
||
y = top
|
||
for p in paragraphs:
|
||
_, h = p.wrap(width, A4[1])
|
||
y -= h
|
||
p.drawOn(canvas, _MARGIN_X, y)
|
||
canvas.restoreState()
|
||
|
||
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, styles, width))
|
||
else:
|
||
story.append(Paragraph("Personne affecté pour l'instant.", styles["normal"]))
|
||
story.append(Spacer(1, 0))
|
||
|
||
doc.build(story, onFirstPage=draw_footer, onLaterPages=draw_footer)
|
||
return buffer.getvalue()
|