"""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, ) _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"), ), "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 = 18 * mm _MARGIN_BOTTOM = 16 * 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", "
") 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]) -> 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], 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=16 * mm, 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)) 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()