Fiches de poste : message de l'organisation en bas de chaque page
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:
co-authored by
Claude Opus 5.5
parent
c6ea7378c7
commit
cc8c2e61ab
@@ -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 +
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user