Fiches de poste rapides, badges sans initiales, vue par bénévole sur l'axe des temps
ci-cd / build-backend (push) Successful in 34s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 11s

Étape 62 : les fiches de poste intégraient la photo d'origine de chaque
bénévole pour chaque créneau (CPU à 300 %, puis erreur) — photos réduites
à la vignette une fois par PDF ; badges : photo recadrée une fois par
génération et décodée à taille réduite.

Étape 61 : sans photo, le cadre de la photo reste vide sur le badge.

Étape 60 : vue par bénévole en tranches de 30 min, affectations étirées
sur toute leur durée, postes simultanés empilés, ligne de l'heure actuelle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-28 19:01:48 +01:00
co-authored by Claude Opus 5.5
parent c57c70569c
commit c6ea7378c7
13 changed files with 424 additions and 296 deletions
+41 -8
View File
@@ -13,11 +13,12 @@ import datetime as dt
import io
from dataclasses import dataclass
from PIL import Image as PILImage
from PIL import ImageOps, UnidentifiedImageError
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.platypus import (
Image,
PageBreak,
@@ -29,6 +30,8 @@ from reportlab.platypus import (
)
_PHOTO_BOX_MM = 14 * mm
# Côté de la vignette en pixels : 14 mm à 300 dpi.
_PHOTO_BOX_PX = round(14 / 25.4 * 300)
_WEEKDAYS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"]
_MONTHS_FR = [
@@ -115,16 +118,45 @@ def _styles() -> dict[str, ParagraphStyle]:
}
def _photo_thumbnail(photo: bytes) -> Image:
"""Vignette carrée (14 mm de côté max), proportions préservées."""
width_px, height_px = ImageReader(io.BytesIO(photo)).getSize()
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
d'origine (souvent plusieurs Mo, prise au téléphone) intégrée telle
quelle pour chaque bénévole de chaque créneau faisait tourner la
génération plusieurs minutes et produisait un PDF énorme — jusqu'à
l'erreur (bug corrigé une fois)."""
try:
with PILImage.open(io.BytesIO(photo)) as img:
# JPEG : décodage directement à taille réduite (bien plus rapide).
img.draft("RGB", (_PHOTO_BOX_PX, _PHOTO_BOX_PX))
thumb = ImageOps.exif_transpose(img).convert("RGB")
thumb.thumbnail((_PHOTO_BOX_PX, _PHOTO_BOX_PX))
except (UnidentifiedImageError, OSError, ValueError):
return None
out = io.BytesIO()
thumb.save(out, format="JPEG", quality=85)
return out.getvalue(), thumb.width, thumb.height
def _photo_cell(photo: bytes | None, cache: dict[bytes, tuple[bytes, int, int] | None]):
"""Vignette carrée (14 mm de côté max), proportions préservées. Chaque
photo n'est réduite qu'une fois par PDF (`cache`), même si le bénévole
figure sur plusieurs créneaux."""
if not photo:
return ""
if photo not in cache:
cache[photo] = _thumbnail(photo)
thumb = cache[photo]
if thumb is None:
return ""
data, width_px, height_px = thumb
scale = _PHOTO_BOX_MM / max(width_px, height_px)
return Image(io.BytesIO(photo), 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) -> Table:
def _shift_table(shift: SheetShift, cache: dict[bytes, tuple[bytes, int, int] | None]) -> Table:
rows = [["", "Bénévole", "Téléphone"]] + [
[_photo_thumbnail(v.photo) if v.photo else "", v.name, v.phone or "—"]
[_photo_cell(v.photo, cache), v.name, v.phone or "—"]
for v in shift.volunteers
]
table = Table(rows, colWidths=[18 * mm, 92 * mm, 55 * mm])
@@ -153,6 +185,7 @@ def generate_position_sheets_pdf(sheets: list[PositionDaySheet]) -> bytes:
)
story = []
thumbnails: dict[bytes, tuple[bytes, int, int] | None] = {}
for i, sheet in enumerate(sheets):
if i > 0:
story.append(PageBreak())
@@ -190,7 +223,7 @@ def generate_position_sheets_pdf(sheets: list[PositionDaySheet]) -> bytes:
style = styles["shiftHeaderWarning"] if shift.understaffed else styles["shiftHeader"]
story.append(Paragraph(header, style))
if shift.volunteers:
story.append(_shift_table(shift))
story.append(_shift_table(shift, thumbnails))
else:
story.append(Paragraph("Personne affecté pour l'instant.", styles["normal"]))
story.append(Spacer(1, 0))