Fiches de poste : message de l'organisation en bas de chaque page
ci-cd / build-backend (push) Successful in 29s
ci-cd / build-frontend (push) Successful in 7s
ci-cd / deploy (push) Successful in 11s

Edition.volunteer_message imprimé en pied de chaque page (suite d'une
feuille comprise), la marge du bas agrandie de sa hauteur ; rien sans
message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-28 19:11:33 +01:00
co-authored by Claude Opus 5.5
parent c6ea7378c7
commit cc8c2e61ab
6 changed files with 126 additions and 5 deletions
+6 -1
View File
@@ -281,7 +281,11 @@ les tables sauf les tables d'association.
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
affectation n'apparaît pas (rien à imprimer). Photo de chaque bénévole en
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`,
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
@@ -1054,6 +1058,7 @@ Aucune dépendance locale requise hors Docker.
| 60 | Vue par bénévole sur un vrai axe des temps (tranches de 30 min par jour, comme la grille des besoins) : affectations étirées sur toute leur durée avec le nom du poste, deux postes simultanés empilés sur deux lignes et signalés en jaune, ligne verticale orange de l'heure actuelle avec l'heure — une affectation n'apparaissait que dans la colonne de son propre créneau | à valider |
| 61 | Badges : sans photo, le cadre de la photo reste vide (plus d'initiales sur fond beige) — PDF, aperçu de l'éditeur (repère pointillé non imprimé) et messages de contrôle | à 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 |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, IA. Frontend : squelette + 6 écrans +
+10 -1
View File
@@ -17,10 +17,12 @@ from app.api.deps import get_edition
from app.api.schemas import PositionSheetsRequest
from app.auth.deps import require_coordinator
from app.db import get_session
from app.i18n import t
from app.models import Account, Artist, Assignment, DayPart, Edition, Position, Shift, Volunteer
from app.sheets.pdf import (
PositionDaySheet,
SheetArtist,
SheetFooter,
SheetShift,
SheetVolunteer,
generate_position_sheets_pdf,
@@ -151,7 +153,14 @@ def generate_position_sheets(
status_code=422, detail={"code": "position_sheets.no_shifts"}
)
pdf_bytes = generate_position_sheets_pdf(sheets)
# Message de l'organisation (le même que sur l'écran bénévole) en bas de
# chaque page.
footer = (
SheetFooter(title=t("position_sheets.footer_title"), message=edition.volunteer_message)
if edition.volunteer_message
else None
)
pdf_bytes = generate_position_sheets_pdf(sheets, footer)
return Response(
content=pdf_bytes,
media_type="application/pdf",
+1
View File
@@ -66,6 +66,7 @@
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
"badge.role.referent": "Référent",
"badge.role.volunteer": "Bénévole",
"position_sheets.footer_title": "Message de l'organisation",
"badge.table.day_label": "{weekday} {day}",
"badge.weekday.0": "Lundi",
"badge.weekday.1": "Mardi",
+59 -3
View File
@@ -12,6 +12,7 @@ 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
@@ -114,10 +115,41 @@ def _styles() -> dict[str, 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", "<br/>")
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
@@ -176,14 +208,38 @@ def _shift_table(shift: SheetShift, cache: dict[bytes, tuple[bytes, int, int] |
return table
def generate_position_sheets_pdf(sheets: list[PositionDaySheet]) -> bytes:
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=18 * mm, rightMargin=18 * mm, topMargin=16 * mm, bottomMargin=16 * mm,
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):
@@ -228,5 +284,5 @@ def generate_position_sheets_pdf(sheets: list[PositionDaySheet]) -> bytes:
story.append(Paragraph("Personne affecté pour l'instant.", styles["normal"]))
story.append(Spacer(1, 0))
doc.build(story)
doc.build(story, onFirstPage=draw_footer, onLaterPages=draw_footer)
return buffer.getvalue()
+22
View File
@@ -174,3 +174,25 @@ def test_a_night_that_passes_midnight_stays_on_its_festival_day(client, edition,
text = reader.pages[0].extract_text()
assert "jeudi 9 juillet" in text.lower()
assert "01:00" in text
def test_organisation_message_is_passed_as_the_page_footer(client, edition, session, monkeypatch):
import app.api.position_sheets as sheets_api
captured = {}
original = sheets_api.generate_position_sheets_pdf
def spy(sheets, footer=None):
captured["footer"] = footer
return original(sheets, footer)
monkeypatch.setattr(sheets_api, "generate_position_sheets_pdf", spy)
position = make_position(session, edition)
make_shift(session, edition, position, start=dt_at(9, 19), end=dt_at(9, 22))
assert client.post(_url(edition), json={}).status_code == 200
assert captured["footer"] is None
client.patch(f"/editions/{edition.id}", json={"volunteer_message": "Repas à 18h30"})
assert client.post(_url(edition), json={}).status_code == 200
assert captured["footer"].message == "Repas à 18h30"
+28
View File
@@ -174,3 +174,31 @@ def test_unreadable_photo_does_not_break_the_sheet():
[_sheet(volunteers=(SheetVolunteer(name="Alex", phone=None, photo=b"pas une image"),))]
)
assert pdf.startswith(b"%PDF")
def _page_texts(pdf: bytes) -> list[str]:
from pypdf import PdfReader
return [page.extract_text() for page in PdfReader(io.BytesIO(pdf)).pages]
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))
pdf = generate_position_sheets_pdf(
[_sheet(volunteers=many), _sheet()],
SheetFooter(title="Message de l'organisation", message="Repas à 18h30\nBadges à l'accueil"),
)
pages = _page_texts(pdf)
assert len(pages) >= 3
for text in pages:
assert "Message de l'organisation" in text
assert "Repas à 18h30" in text
assert "Badges à l'accueil" in text
def test_no_footer_without_message():
pages = _page_texts(generate_position_sheets_pdf([_sheet()]))
assert "Message de l'organisation" not in pages[0]