"""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.platypus import ( PageBreak, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle, ) _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 @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 _shift_table(shift: SheetShift) -> Table: rows = [["Bénévole", "Téléphone"]] + [ [v.name, v.phone or "—"] for v in shift.volunteers ] table = Table(rows, colWidths=[110 * 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), ("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()