diff --git a/CLAUDE.md b/CLAUDE.md index 8123819..be6de5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 + diff --git a/backend/app/api/position_sheets.py b/backend/app/api/position_sheets.py index 3e96aad..c21f179 100644 --- a/backend/app/api/position_sheets.py +++ b/backend/app/api/position_sheets.py @@ -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", diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json index d0b0a6c..3604488 100644 --- a/backend/app/i18n/locales/fr.json +++ b/backend/app/i18n/locales/fr.json @@ -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", diff --git a/backend/app/sheets/pdf.py b/backend/app/sheets/pdf.py index ec084d2..4d7975f 100644 --- a/backend/app/sheets/pdf.py +++ b/backend/app/sheets/pdf.py @@ -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", "
") + 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() diff --git a/backend/tests/test_position_sheets_api.py b/backend/tests/test_position_sheets_api.py index 95336f4..8bb6a92 100644 --- a/backend/tests/test_position_sheets_api.py +++ b/backend/tests/test_position_sheets_api.py @@ -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" diff --git a/backend/tests/test_position_sheets_pdf.py b/backend/tests/test_position_sheets_pdf.py index 1d42f61..a810508 100644 --- a/backend/tests/test_position_sheets_pdf.py +++ b/backend/tests/test_position_sheets_pdf.py @@ -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]