"""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()