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
+2 -3
View File
@@ -37,7 +37,6 @@ from app.badges.pdf import (
BadgeTableSection,
generate_badge_sheets_pdf,
image_size_mm,
initials,
)
from app.db import get_session
from app.i18n import t
@@ -320,8 +319,8 @@ def _field_text(
# Prénom puis nom, chacun sur sa ligne.
return f"{volunteer.first_name}\n{volunteer.last_name}"
if field.field_type == BadgeFieldType.PHOTO:
# Imprimées à la place d'une photo absente ou illisible.
return initials(volunteer.first_name, volunteer.last_name)
# Une image, pas un texte (rien d'imprimé sans photo).
return ""
if field.field_type == BadgeFieldType.FREE_TEXT:
# Texte commun saisi dans l'éditeur, remplacé pour un bénévole par
# le « Texte du badge » de sa fiche s'il en a un.
+35 -34
View File
@@ -42,8 +42,6 @@ _AUTO_FIT_STEP_PT = 0.5
_AUTO_FIT_MIN_PT = 4.0
# Photo absente ou illisible : initiales sur ce fond, à cette taille (part de
# la largeur du cadre).
_INITIALS_BG = "#E7E0CE"
_INITIALS_SIZE_RATIO = 0.28
# Résolution supposée d'une image de fond importée, pour convertir ses pixels
# en millimètres réels (donc en taille de badge) — 300 dpi est la résolution
@@ -86,9 +84,8 @@ class BadgeFieldSpec:
`is_photo` : le champ est la photo du bénévole (`image`, ses octets
bruts tels que stockés) plutôt qu'un texte, découpée selon
`photo_shape` (`RECT`/`OVAL`). Sans photo (ou photo illisible), les
initiales du bénévole (`text`) sont imprimées à la place, sur un fond
neutre de la même forme.
`photo_shape` (`RECT`/`OVAL`). Sans photo (ou photo illisible), rien
n'est imprimé : le cadre reste vide.
`table` : le champ est un tableau (`text` ignoré) — voir `_draw_table`.
@@ -193,6 +190,9 @@ def _fit_photo(image: bytes, width_mm: float, height_mm: float) -> bytes | None:
)
try:
with Image.open(io.BytesIO(image)) as img:
# JPEG : décodage directement à taille réduite (bien plus rapide
# qu'une photo de téléphone décodée en pleine résolution).
img.draft("RGB", (target[0] * 2, target[1] * 2))
fitted = ImageOps.fit(ImageOps.exif_transpose(img).convert("RGB"), target)
except (UnidentifiedImageError, OSError):
return None
@@ -224,38 +224,37 @@ def _shape_path(c: canvas.Canvas, shape: str, x: float, y: float, w: float, h: f
return path
def initials(first_name: str, last_name: str) -> str:
"""Initiales imprimées à la place d'une photo absente : première lettre
du prénom et du nom, en majuscules."""
return "".join(part.strip()[:1] for part in (first_name, last_name)).upper()
def _draw_initials(c: canvas.Canvas, f: BadgeFieldSpec, x: float, y: float, w: float, h: float):
c.setFillColor(HexColor(_INITIALS_BG))
c.drawPath(_shape_path(c, f.photo_shape, x, y, w, h), stroke=0, fill=1)
if not f.text:
return
size = max(_AUTO_FIT_MIN_PT, w * _INITIALS_SIZE_RATIO)
c.setFont("Helvetica-Bold", size)
c.setFillColor(HexColor(f.color))
# Ligne de base décalée d'environ un tiers de la taille : centre
# visuel des majuscules (hauteur de capitale ≈ 0,7 × la taille).
c.drawCentredString(x + w / 2, y + h / 2 - size * 0.35, f.text)
# Photos déjà recadrées pendant une génération, par (photo, largeur,
# hauteur du cadre) : un bénévole a souvent la même photo au recto, au verso
# et sur plusieurs exemplaires (`badge_count`) — elle n'est traitée qu'une
# fois.
PhotoCache = dict[tuple[bytes, float, float], bytes | None]
def _draw_photo(
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
c: canvas.Canvas,
f: BadgeFieldSpec,
x0_mm: float,
y0_mm: float,
badge_height_mm: float,
cache: PhotoCache,
) -> None:
x, y, w, h = _frame_rect(f, x0_mm, y0_mm, badge_height_mm)
photo = _fit_photo(f.image, f.width_mm, f.height_mm) if f.image else None
c.saveState()
photo = None
if f.image:
key = (f.image, f.width_mm, f.height_mm)
if key not in cache:
cache[key] = _fit_photo(f.image, f.width_mm, f.height_mm)
photo = cache[key]
if photo is None:
_draw_initials(c, f, x, y, w, h)
else:
if f.photo_shape == "OVAL":
# Découpe en ellipse inscrite dans le cadre.
c.clipPath(_shape_path(c, "OVAL", x, y, w, h), stroke=0, fill=0)
c.drawImage(ImageReader(io.BytesIO(photo)), x, y, width=w, height=h)
# Pas de photo (ou illisible) : cadre laissé vide, choix de la
# coordination — avant, les initiales étaient imprimées à la place.
return
c.saveState()
if f.photo_shape == "OVAL":
# Découpe en ellipse inscrite dans le cadre.
c.clipPath(_shape_path(c, "OVAL", x, y, w, h), stroke=0, fill=0)
c.drawImage(ImageReader(io.BytesIO(photo)), x, y, width=w, height=h)
c.restoreState()
@@ -495,10 +494,11 @@ def _draw_fields(
x0_mm: float,
y0_mm: float,
badge_height_mm: float,
photos: PhotoCache,
) -> None:
for f in fields:
if f.is_photo:
_draw_photo(c, f, x0_mm, y0_mm, badge_height_mm)
_draw_photo(c, f, x0_mm, y0_mm, badge_height_mm, photos)
continue
_draw_zone_background(c, f, x0_mm, y0_mm, badge_height_mm)
if f.table is not None:
@@ -522,17 +522,18 @@ def generate_badge_sheets_pdf(
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=A4)
photos: PhotoCache = {}
for page_badges in pages:
for i, badge in enumerate(page_badges):
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm)
_draw_background(c, front_image, x0, y0, badge_width_mm, badge_height_mm)
_draw_fields(c, badge.fields, x0, y0, badge_height_mm)
_draw_fields(c, badge.fields, x0, y0, badge_height_mm, photos)
c.showPage()
for i, badge in enumerate(page_badges):
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm, mirrored=True)
_draw_background(c, back_image, x0, y0, badge_width_mm, badge_height_mm)
_draw_fields(c, badge.back_fields, x0, y0, badge_height_mm)
_draw_fields(c, badge.back_fields, x0, y0, badge_height_mm, photos)
c.showPage()
c.save()
return buffer.getvalue()
+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))
+6 -2
View File
@@ -360,7 +360,7 @@ def test_generating_with_back_fields(client, edition, session, monkeypatch):
assert back[False].bg_color == "#FFD23F"
assert back[False].auto_fit is True
assert back[True].photo_shape == "OVAL"
assert back[True].text == "AM" # initiales, faute de photo
assert back[True].text == "" # pas de photo : rien d'imprimé
def test_generating_prints_as_many_badges_as_requested(client, edition, session, monkeypatch):
@@ -427,7 +427,11 @@ def test_fields_are_drawn_in_stacking_order(client, edition, session, monkeypatc
assert client.post(f"{_badges_url(edition)}/generate", json={}).status_code == 200
(badge,) = captured["badges"]
assert [f.text for f in badge.fields] == ["Alex\nMartin", "AM", "Staff"]
assert [(f.is_photo, f.text) for f in badge.fields] == [
(False, "Alex\nMartin"),
(True, ""),
(False, "Staff"),
]
def test_photo_is_drawn_below_text_at_equal_stacking_order(client, edition):
+3 -2
View File
@@ -115,10 +115,11 @@ def test_field_text_full_name_is_first_name_then_last_name_on_two_lines():
assert _field_text(field, volunteer, {}, set()) == "Alex\nMartin"
def test_field_text_photo_is_the_initials_used_without_a_photo():
def test_field_text_photo_is_empty():
volunteer = _volunteer(first_name="alex", last_name="martin")
field = BadgeField(
edition_id=uuid.uuid4(), field_type=BadgeFieldType.PHOTO,
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9,
)
assert _field_text(field, volunteer, {}, set()) == "AM"
# Une image, pas un texte : rien n'est imprimé sans photo.
assert _field_text(field, volunteer, {}, set()) == ""
+14 -21
View File
@@ -242,7 +242,7 @@ def test_table_rows_beyond_the_frame_are_not_drawn():
# Éditeur v2 : verso, fond de zone, réduction automatique, photo #
# --------------------------------------------------------------------------- #
from app.badges import pdf as pdf_module # noqa: E402
from app.badges.pdf import fit_text, initials # noqa: E402
from app.badges.pdf import fit_text # noqa: E402
def _text_field(**kw) -> BadgeFieldSpec:
@@ -269,9 +269,9 @@ def test_back_page_draws_each_volunteer_back_fields_at_the_mirrored_slot(monkeyp
calls = []
original = pdf_module._draw_fields
def spy(c, fields, x0, y0, badge_height_mm):
def spy(c, fields, x0, y0, badge_height_mm, photos):
calls.append((fields, x0, y0))
original(c, fields, x0, y0, badge_height_mm)
original(c, fields, x0, y0, badge_height_mm, photos)
monkeypatch.setattr(pdf_module, "_draw_fields", spy)
back = (_text_field(text="Verso Alex"),)
@@ -376,30 +376,23 @@ def test_middle_aligned_text_is_lower_than_top_aligned_text(monkeypatch):
assert middle < top
def test_initials_are_the_uppercase_first_letters():
assert initials("alex", "martin") == "AM"
assert initials("Élodie", "de la Tour") == "ÉD"
def test_missing_photo_prints_the_initials(monkeypatch):
drawn = []
monkeypatch.setattr(
pdf_module.canvas.Canvas, "drawCentredString",
lambda self, x, y, text, *a, **k: drawn.append(text),
)
for image in (None, b"pas une image"):
badge = BadgeSpec(
fields=(BadgeFieldSpec(**{**_photo_badge(image).fields[0].__dict__, "text": "AM"}),)
def test_missing_photo_prints_nothing(monkeypatch):
calls = []
for name in ("drawImage", "drawPath", "drawCentredString", "drawString"):
monkeypatch.setattr(
pdf_module.canvas.Canvas, name,
lambda self, *a, _name=name, **k: calls.append(_name),
)
generate_badge_sheets_pdf([badge], None, None)
assert drawn == ["AM", "AM"]
for image in (None, b"pas une image"):
generate_badge_sheets_pdf([_photo_badge(image)], None, None)
assert calls == []
def test_oval_photo_and_oval_initials_generate_a_valid_pdf():
def test_oval_photo_and_missing_oval_photo_generate_a_valid_pdf():
for image in (_jpeg(400, 500), None):
field = _photo_badge(image).fields[0]
badge = BadgeSpec(
fields=(BadgeFieldSpec(**{**field.__dict__, "photo_shape": "OVAL", "text": "AM"}),)
fields=(BadgeFieldSpec(**{**field.__dict__, "photo_shape": "OVAL"}),)
)
pdf = generate_badge_sheets_pdf([badge], None, None)
assert pdf.startswith(b"%PDF")
+32
View File
@@ -2,6 +2,7 @@
import base64
import datetime as dt
import io
from app.sheets.pdf import (
PositionDaySheet,
@@ -142,3 +143,34 @@ def test_generates_a_valid_pdf_without_a_lineup():
assert sheet.artists == ()
pdf = generate_position_sheets_pdf([sheet])
assert pdf.startswith(b"%PDF")
def _phone_photo(seed: int) -> bytes:
"""Photo de téléphone : 3000 × 4000 px, plusieurs Mo."""
from PIL import Image as PILImage
img = PILImage.new("RGB", (3000, 4000), (seed * 40 % 255, 120, 60))
img.paste(PILImage.effect_noise((800, 800), 80).convert("RGB"), (seed * 100, 200))
out = io.BytesIO()
img.save(out, format="JPEG", quality=92)
return out.getvalue()
def test_large_photos_are_shrunk_to_the_thumbnail():
# Régression : la photo d'origine était intégrée telle quelle pour
# chaque bénévole de chaque créneau — génération interminable, PDF
# énorme, puis erreur.
photos = [_phone_photo(i) for i in range(5)]
volunteers = tuple(
SheetVolunteer(name=f"Bénévole {i}", phone=None, photo=p) for i, p in enumerate(photos)
)
pdf = generate_position_sheets_pdf([_sheet(volunteers=volunteers)])
assert sum(len(p) for p in photos) > 2_000_000
assert len(pdf) < 200_000
def test_unreadable_photo_does_not_break_the_sheet():
pdf = generate_position_sheets_pdf(
[_sheet(volunteers=(SheetVolunteer(name="Alex", phone=None, photo=b"pas une image"),))]
)
assert pdf.startswith(b"%PDF")