Feuilles de poste compactées : une page par poste et par jour
ci-cd / build-backend (push) Failing after 1m2s
ci-cd / build-frontend (push) Skipped
ci-cd / deploy (push) Skipped

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>
This commit is contained in:
AdamThomas
2026-10-28 19:22:46 +01:00
co-authored by Claude Opus 5.5
parent 749c7e0917
commit 5c2cc0a6c3
3 changed files with 97 additions and 33 deletions
+6 -2
View File
@@ -280,13 +280,16 @@ les tables sauf les tables d'association.
téléphone — « à désigner » si aucun) en en-tête, puis les créneaux du jour
avec les bénévoles affectés et leur téléphone, pour affichage sur place.
`POST /editions/{id}/position-sheets/generate` (COORDINATION,
`{position_ids?}` — absent = tous) ; un créneau à besoin 0/0 sans aucune
`{position_ids?}` — absent = tous). Mise en page compacte, pour tenir
sur une page par poste et par jour autant que possible : bénévoles **deux
par ligne** (vignette de 10 mm, nom en gras et téléphone dessous),
en-têtes et marges resserrés ; un créneau à besoin 0/0 sans aucune
affectation n'apparaît pas (rien à imprimer). Le **message de
l'organisation** (`Edition.volunteer_message`, s'il y en a un) est
imprimé en pied de **chaque** page, suite d'une feuille comprise
(`SheetFooter`, marge du bas agrandie de sa hauteur pour que le contenu
ne passe jamais dessous). Photo de chaque bénévole en
vignette de 14 mm : réduite à 300 dpi (`app/sheets/pdf.py:_thumbnail`,
vignette (10 mm) : réduite à 300 dpi (`app/sheets/pdf.py:_thumbnail`,
décodage JPEG à taille réduite via `draft`) une seule fois par PDF, une
photo illisible laissant la case vide. Avant l'étape 62, la photo
d'origine (plusieurs Mo, prise au téléphone) était intégrée telle quelle
@@ -1062,6 +1065,7 @@ Aucune dépendance locale requise hors Docker.
| 62 | Feuilles de poste : génération interminable (CPU à 300 %, puis « erreur inattendue ») — photos d'origine intégrées en pleine résolution pour chaque bénévole de chaque créneau ; désormais réduites à la vignette une fois par PDF (40 photos : 15 s / 24 Mo ⇒ 0,2 s / 109 Ko), photo illisible ignorée. Badges : photo recadrée une seule fois par génération et décodée à taille réduite (120 badges : 10 s ⇒ 0,3 s) | à valider |
| 63 | Feuilles de poste : message de l'organisation en bas de chaque page (titre + texte, retours à la ligne conservés), rien si aucun message | à valider |
| 64 | Vue par bénévole : barre de recherche sur le nom du bénévole ou d'un de ses postes (sans accents ni casse), postes trouvés cernés d'orange, cumulable avec les filtres et le tri | à valider |
| 65 | Feuilles de poste compactées : 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 — sur un jeu réaliste de 6 feuilles, 11 pages ⇒ 6 (une par poste et par jour) | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, IA. Frontend : squelette + 6 écrans +
+64 -28
View File
@@ -30,9 +30,12 @@ from reportlab.platypus import (
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)
# 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 = [
@@ -94,26 +97,36 @@ def _styles() -> dict[str, ParagraphStyle]:
base = getSampleStyleSheet()
return {
"title": ParagraphStyle(
"SheetTitle", parent=base["Title"], alignment=0, fontSize=22, spaceAfter=2
"SheetTitle", parent=base["Title"], alignment=0,
fontSize=18, leading=21, spaceBefore=0, spaceAfter=1,
),
"subtitle": ParagraphStyle(
"SheetSubtitle", parent=base["Normal"],
fontSize=12, textColor=colors.grey, spaceAfter=4,
fontSize=10.5, leading=13, textColor=colors.grey, spaceAfter=2,
),
"referent": ParagraphStyle(
"SheetReferent", parent=base["Normal"],
fontSize=11.5, fontName="Helvetica-Bold", spaceAfter=6,
fontSize=10.5, leading=13, fontName="Helvetica-Bold", spaceAfter=3,
),
"artists": ParagraphStyle(
"SheetArtists", parent=base["Normal"],
fontSize=9.5, textColor=colors.HexColor("#4a4a4a"), spaceAfter=16,
fontSize=8.5, leading=10.5, textColor=colors.HexColor("#4a4a4a"), spaceAfter=4,
),
"shiftHeader": ParagraphStyle(
"ShiftHeader", parent=base["Heading3"], fontSize=13, spaceBefore=12, spaceAfter=4
"ShiftHeader", parent=base["Heading3"],
fontSize=11, leading=13, spaceBefore=7, spaceAfter=3,
),
"shiftHeaderWarning": ParagraphStyle(
"ShiftHeaderWarning", parent=base["Heading3"],
fontSize=13, spaceBefore=12, spaceAfter=4, textColor=colors.HexColor("#b23a1f"),
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"],
@@ -136,8 +149,9 @@ class SheetFooter:
message: str
_MARGIN_X = 18 * mm
_MARGIN_BOTTOM = 16 * mm
_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
@@ -186,25 +200,47 @@ def _photo_cell(photo: bytes | None, cache: dict[bytes, tuple[bytes, int, int] |
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 "—"]
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
]
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),
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, 0), 0.75, colors.black),
("GRID", (0, 1), (-1, -1), 0.25, colors.lightgrey),
("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
@@ -221,7 +257,7 @@ def generate_position_sheets_pdf(
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,
leftMargin=_MARGIN_X, rightMargin=_MARGIN_X, topMargin=_MARGIN_TOP,
bottomMargin=_MARGIN_BOTTOM + (footer_height + _FOOTER_GAP if paragraphs else 0),
)
@@ -279,7 +315,7 @@ def generate_position_sheets_pdf(
style = styles["shiftHeaderWarning"] if shift.understaffed else styles["shiftHeader"]
story.append(Paragraph(header, style))
if shift.volunteers:
story.append(_shift_table(shift, thumbnails))
story.append(_shift_table(shift, thumbnails, styles, width))
else:
story.append(Paragraph("Personne affecté pour l'instant.", styles["normal"]))
story.append(Spacer(1, 0))
+25 -1
View File
@@ -186,7 +186,7 @@ def test_footer_message_is_printed_at_the_bottom_of_every_page():
from app.sheets.pdf import SheetFooter
# Deux feuilles, dont une assez longue pour déborder sur une 2e page.
many = tuple(SheetVolunteer(name=f"Bénévole {i}", phone=None, photo=None) for i in range(40))
many = tuple(SheetVolunteer(name=f"Bénévole {i}", phone=None, photo=None) for i in range(120))
pdf = generate_position_sheets_pdf(
[_sheet(volunteers=many), _sheet()],
SheetFooter(title="Message de l'organisation", message="Repas à 18h30\nBadges à l'accueil"),
@@ -202,3 +202,27 @@ def test_footer_message_is_printed_at_the_bottom_of_every_page():
def test_no_footer_without_message():
pages = _page_texts(generate_position_sheets_pdf([_sheet()]))
assert "Message de l'organisation" not in pages[0]
def test_volunteers_are_listed_two_per_row():
from app.sheets.pdf import _shift_table, _styles
shift = _sheet(
volunteers=tuple(SheetVolunteer(name=f"B{i}", phone=None, photo=None) for i in range(5))
).shifts[0]
table = _shift_table(shift, {}, _styles(), 180)
# 5 bénévoles, deux par ligne : 3 lignes (la dernière à moitié vide).
assert len(table._cellvalues) == 3
def test_a_typical_sheet_fits_on_one_page():
volunteers = tuple(
SheetVolunteer(name=f"Prénom{i} Nom{i}", phone="06 12 34 56 78", photo=_PNG)
for i in range(8)
)
shift = _sheet(volunteers=volunteers).shifts[0]
sheet = _sheet(volunteers=volunteers)
many_shifts = PositionDaySheet(
**{**sheet.__dict__, "shifts": (shift, shift, shift, shift)}
)
assert len(_page_texts(generate_position_sheets_pdf([many_shifts]))) == 1