Badges : photo du bénévole et texte libre

Champ « Photo du bénévole » (PHOTO) : photo du formulaire recadrée au
cadre, orientation EXIF appliquée, réduite à 300 dpi ; cadre vide sans
photo. Champ « Texte libre » (FREE_TEXT) alimenté par le nouveau « Texte
du badge » de la fiche bénévole (Volunteer.badge_text, migration 0024),
repris dans l'export Excel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-27 18:12:09 +01:00
co-authored by Claude Opus 5.5
parent 5b493c9737
commit 54cba467fc
17 changed files with 325 additions and 33 deletions
+37
View File
@@ -207,3 +207,40 @@ def test_generating_produces_a_pdf(client, edition, session):
assert resp.status_code == 200
assert resp.headers["content-type"] == "application/pdf"
assert resp.content.startswith(b"%PDF")
def test_generating_with_photo_and_free_text_fields(client, edition, session):
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={
"fields": [
{
"field_type": "PHOTO", "x_mm": 60, "y_mm": 5,
"width_mm": 20, "height_mm": 25, "font_size": 8,
},
{
"field_type": "FREE_TEXT", "x_mm": 5, "y_mm": 40,
"width_mm": 50, "height_mm": 8, "font_size": 9,
},
]
},
)
assert resp.status_code == 200
assert {f["field_type"] for f in resp.json()} == {"PHOTO", "FREE_TEXT"}
with_photo = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
with_photo.photo = _PNG
with_photo.photo_content_type = "image/png"
without_photo = make_volunteer(session, edition, first_name="Sam", last_name="Nuit")
session.add(with_photo)
session.commit()
resp = client.patch(
f"/editions/{edition.id}/volunteers/{without_photo.id}",
json={"badge_text": "Accès backstage"},
)
assert resp.status_code == 200
assert resp.json()["badge_text"] == "Accès backstage"
resp = client.post(f"{_badges_url(edition)}/generate", json={})
assert resp.status_code == 200
assert resp.content.startswith(b"%PDF")
+12
View File
@@ -99,3 +99,15 @@ def test_field_text_role_is_referent_only_for_lead_accounts():
assert _field_text(field, volunteer, {}, lead_volunteer_ids=set()) == "Bénévole"
assert _field_text(field, volunteer, {}, lead_volunteer_ids={volunteer.id}) == "Référent"
def test_field_text_free_text_is_the_volunteer_badge_text():
field = BadgeField(
edition_id=uuid.uuid4(), field_type=BadgeFieldType.FREE_TEXT,
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=8, color="#000000",
)
with_text = _volunteer(first_name="Alex", last_name="Martin", badge_text="Accès backstage")
without = _volunteer(first_name="Sam", last_name="Nuit")
assert _field_text(field, with_text, {}, set()) == "Accès backstage"
assert _field_text(field, without, {}, set()) == ""
+54
View File
@@ -1,11 +1,17 @@
"""Génération des planches de badges (module pur, sans base)."""
import io
from PIL import Image
from app.badges.pdf import (
ASSUMED_DPI,
DEFAULT_BADGE_HEIGHT_MM,
DEFAULT_BADGE_WIDTH_MM,
BadgeFieldSpec,
BadgeSpec,
_cell_origin,
_fit_photo,
_grid_size,
generate_badge_sheets_pdf,
image_size_mm,
@@ -97,3 +103,51 @@ def test_more_badges_than_one_sheet_produces_a_larger_pdf():
[_badge() for _ in range(per_page + 1)], front_image=None, back_image=None
)
assert len(two_pages) > len(one_page)
def _jpeg(width_px: int, height_px: int) -> bytes:
out = io.BytesIO()
Image.new("RGB", (width_px, height_px), "red").save(out, format="JPEG")
return out.getvalue()
def _photo_badge(image: bytes | None) -> BadgeSpec:
return BadgeSpec(
fields=(
BadgeFieldSpec(
text="", x_mm=60, y_mm=5, width_mm=20, height_mm=25,
font_size=8, align="LEFT", bold=False, color="#000000",
is_photo=True, image=image,
),
)
)
def test_fit_photo_crops_to_the_frame_ratio_at_print_resolution():
# Photo paysage 4000×3000 dans un cadre portrait 20×25 mm : recadrée
# (pas déformée) et réduite à 300 dpi.
fitted = _fit_photo(_jpeg(4000, 3000), width_mm=20, height_mm=25)
with Image.open(io.BytesIO(fitted)) as img:
assert img.size == (round(20 / 25.4 * ASSUMED_DPI), round(25 / 25.4 * ASSUMED_DPI))
def test_fit_photo_returns_none_for_an_unreadable_image():
assert _fit_photo(b"pas une image", width_mm=20, height_mm=25) is None
def test_photo_field_embeds_the_volunteer_photo():
without = generate_badge_sheets_pdf([_photo_badge(None)], front_image=None, back_image=None)
with_photo = generate_badge_sheets_pdf(
[_photo_badge(_jpeg(400, 500))], front_image=None, back_image=None
)
assert without.startswith(b"%PDF")
assert b"/Subtype /Image" in with_photo
assert b"/Subtype /Image" not in without
def test_unreadable_photo_does_not_crash_generation():
pdf = generate_badge_sheets_pdf(
[_photo_badge(b"pas une image")], front_image=None, back_image=None
)
assert pdf.startswith(b"%PDF")