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 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. avec les bénévoles affectés et leur téléphone, pour affichage sur place.
`POST /editions/{id}/position-sheets/generate` (COORDINATION, `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 affectation n'apparaît pas (rien à imprimer). Le **message de
l'organisation** (`Edition.volunteer_message`, s'il y en a un) est l'organisation** (`Edition.volunteer_message`, s'il y en a un) est
imprimé en pied de **chaque** page, suite d'une feuille comprise imprimé en pied de **chaque** page, suite d'une feuille comprise
(`SheetFooter`, marge du bas agrandie de sa hauteur pour que le contenu (`SheetFooter`, marge du bas agrandie de sa hauteur pour que le contenu
ne passe jamais dessous). Photo de chaque bénévole en 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 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 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 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 | | 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 | | 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 | | 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 **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, IA. Frontend : squelette + 6 écrans + de formulaire, IA. Frontend : squelette + 6 écrans +
+66 -30
View File
@@ -30,9 +30,12 @@ from reportlab.platypus import (
TableStyle, TableStyle,
) )
_PHOTO_BOX_MM = 14 * mm # Mise en page compacte (une feuille = une page autant que possible) :
# Côté de la vignette en pixels : 14 mm à 300 dpi. # vignette de 10 mm, deux bénévoles par ligne.
_PHOTO_BOX_PX = round(14 / 25.4 * 300) _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"] _WEEKDAYS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"]
_MONTHS_FR = [ _MONTHS_FR = [
@@ -94,26 +97,36 @@ def _styles() -> dict[str, ParagraphStyle]:
base = getSampleStyleSheet() base = getSampleStyleSheet()
return { return {
"title": ParagraphStyle( "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( "subtitle": ParagraphStyle(
"SheetSubtitle", parent=base["Normal"], "SheetSubtitle", parent=base["Normal"],
fontSize=12, textColor=colors.grey, spaceAfter=4, fontSize=10.5, leading=13, textColor=colors.grey, spaceAfter=2,
), ),
"referent": ParagraphStyle( "referent": ParagraphStyle(
"SheetReferent", parent=base["Normal"], "SheetReferent", parent=base["Normal"],
fontSize=11.5, fontName="Helvetica-Bold", spaceAfter=6, fontSize=10.5, leading=13, fontName="Helvetica-Bold", spaceAfter=3,
), ),
"artists": ParagraphStyle( "artists": ParagraphStyle(
"SheetArtists", parent=base["Normal"], "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": 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": ParagraphStyle(
"ShiftHeaderWarning", parent=base["Heading3"], "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( "footerTitle": ParagraphStyle(
"SheetFooterTitle", parent=base["Normal"], "SheetFooterTitle", parent=base["Normal"],
@@ -136,8 +149,9 @@ class SheetFooter:
message: str message: str
_MARGIN_X = 18 * mm _MARGIN_X = 15 * mm
_MARGIN_BOTTOM = 16 * mm _MARGIN_TOP = 12 * mm
_MARGIN_BOTTOM = 12 * mm
# Écart entre le bas du contenu et le filet du pied de page. # Écart entre le bas du contenu et le filet du pied de page.
_FOOTER_GAP = 5 * mm _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) 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: def _shift_table(
rows = [["", "Bénévole", "Téléphone"]] + [ shift: SheetShift,
[_photo_cell(v.photo, cache), v.name, v.phone or "—"] 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 for v in shift.volunteers
] ]
table = Table(rows, colWidths=[18 * mm, 92 * mm, 55 * mm]) rows = []
table.setStyle( for i in range(0, len(cells), _PER_ROW):
TableStyle( chunk = cells[i : i + _PER_ROW]
[ chunk += [["", ""]] * (_PER_ROW - len(chunk))
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), rows.append([cell for pair in chunk for cell in pair])
("FONTSIZE", (0, 0), (-1, -1), 10.5), table = Table(rows, colWidths=[photo_w, text_w] * _PER_ROW)
("TOPPADDING", (0, 0), (-1, -1), 5), style = [
("BOTTOMPADDING", (0, 0), (-1, -1), 5), ("TOPPADDING", (0, 0), (-1, -1), 2),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"), ("BOTTOMPADDING", (0, 0), (-1, -1), 2),
("LINEBELOW", (0, 0), (-1, 0), 0.75, colors.black), ("LEFTPADDING", (0, 0), (-1, -1), 3),
("GRID", (0, 1), (-1, -1), 0.25, colors.lightgrey), ("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 return table
@@ -221,7 +257,7 @@ def generate_position_sheets_pdf(
footer_height = sum(p.wrap(width, A4[1])[1] for p in paragraphs) footer_height = sum(p.wrap(width, A4[1])[1] for p in paragraphs)
doc = SimpleDocTemplate( doc = SimpleDocTemplate(
buffer, pagesize=A4, 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), 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"] style = styles["shiftHeaderWarning"] if shift.understaffed else styles["shiftHeader"]
story.append(Paragraph(header, style)) story.append(Paragraph(header, style))
if shift.volunteers: if shift.volunteers:
story.append(_shift_table(shift, thumbnails)) story.append(_shift_table(shift, thumbnails, styles, width))
else: else:
story.append(Paragraph("Personne affecté pour l'instant.", styles["normal"])) story.append(Paragraph("Personne affecté pour l'instant.", styles["normal"]))
story.append(Spacer(1, 0)) 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 from app.sheets.pdf import SheetFooter
# Deux feuilles, dont une assez longue pour déborder sur une 2e page. # 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( pdf = generate_position_sheets_pdf(
[_sheet(volunteers=many), _sheet()], [_sheet(volunteers=many), _sheet()],
SheetFooter(title="Message de l'organisation", message="Repas à 18h30\nBadges à l'accueil"), 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(): def test_no_footer_without_message():
pages = _page_texts(generate_position_sheets_pdf([_sheet()])) pages = _page_texts(generate_position_sheets_pdf([_sheet()]))
assert "Message de l'organisation" not in pages[0] 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