Feuilles de poste compactées : une page par poste et par jour
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:
co-authored by
Claude Opus 5.5
parent
749c7e0917
commit
5c2cc0a6c3
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user