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
+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()