Étape 20 : planches de badges bénévoles (PDF)
Recto personnalisable (fond image + champs positionnés : nom, poste(s)
affectés, catégorie, rôle) via un éditeur visuel glisser-déposer ; verso
fixe, la même image pour tous les bénévoles. Nouvel écran « Badges »
(COORDINATION uniquement, /badges).
Backend : modèle BadgeTemplate/BadgeField (migration 0011), génération
PDF isolée (app/badges/pdf.py, ReportLab — planches recto suivies des
planches verso correspondantes pour une impression recto-verso), route
POST /editions/{id}/badges/generate, tests (module pur + API).
Frontend : client HTTP (upload multipart, réponses binaires images/PDF),
client mock (mode démonstration), éditeur de positionnement en
millimètres (coordonnées partagées avec le backend), écran de génération
avec sélection des bénévoles et téléchargement.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
4bcdafca96
commit
b7fc80a7dc
@@ -0,0 +1,162 @@
|
||||
"""Configuration et génération des planches de badges (API)."""
|
||||
|
||||
import base64
|
||||
|
||||
from app.models import Role
|
||||
from tests.factories import dt_at, make_position, make_shift, make_volunteer
|
||||
|
||||
# PNG 1×1 valide (transparent), le plus petit possible.
|
||||
_PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY"
|
||||
"42YAAAAASUVORK5CYII="
|
||||
)
|
||||
|
||||
|
||||
def _badges_url(edition):
|
||||
return f"/editions/{edition.id}/badges"
|
||||
|
||||
|
||||
def _set_full_name_field(client, edition):
|
||||
return client.put(
|
||||
f"{_badges_url(edition)}/template/fields",
|
||||
json={
|
||||
"fields": [
|
||||
{
|
||||
"field_type": "FULL_NAME",
|
||||
"x_mm": 5,
|
||||
"y_mm": 5,
|
||||
"width_mm": 60,
|
||||
"height_mm": 10,
|
||||
"font_size": 12,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_template_is_coordinator_only(edition, make_account, as_account):
|
||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||
client = as_account(lead)
|
||||
assert client.get(f"{_badges_url(edition)}/template").status_code == 403
|
||||
resp = client.put(f"{_badges_url(edition)}/template/fields", json={"fields": []})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_template_starts_empty(client, edition):
|
||||
resp = client.get(f"{_badges_url(edition)}/template")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["has_front_image"] is False
|
||||
assert body["has_back_image"] is False
|
||||
assert body["fields"] == []
|
||||
|
||||
|
||||
def test_uploading_front_and_back_images(client, edition):
|
||||
front = client.put(
|
||||
f"{_badges_url(edition)}/template/front-image",
|
||||
files={"file": ("front.png", _PNG, "image/png")},
|
||||
)
|
||||
assert front.status_code == 200
|
||||
assert front.json()["has_front_image"] is True
|
||||
|
||||
back = client.put(
|
||||
f"{_badges_url(edition)}/template/back-image",
|
||||
files={"file": ("back.png", _PNG, "image/png")},
|
||||
)
|
||||
assert back.status_code == 200
|
||||
assert back.json()["has_back_image"] is True
|
||||
|
||||
served = client.get(f"{_badges_url(edition)}/template/front-image")
|
||||
assert served.status_code == 200
|
||||
assert served.headers["content-type"] == "image/png"
|
||||
assert served.content == _PNG
|
||||
|
||||
|
||||
def test_uploading_an_unsupported_image_type_is_rejected(client, edition):
|
||||
resp = client.put(
|
||||
f"{_badges_url(edition)}/template/front-image",
|
||||
files={"file": ("front.gif", b"not-a-real-gif", "image/gif")},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "badge.image_type_not_supported"
|
||||
|
||||
|
||||
def test_setting_the_same_field_type_twice_is_rejected(client, edition):
|
||||
resp = client.put(
|
||||
f"{_badges_url(edition)}/template/fields",
|
||||
json={
|
||||
"fields": [
|
||||
{
|
||||
"field_type": "FULL_NAME", "x_mm": 0, "y_mm": 0,
|
||||
"width_mm": 10, "height_mm": 10,
|
||||
},
|
||||
{
|
||||
"field_type": "FULL_NAME", "x_mm": 5, "y_mm": 5,
|
||||
"width_mm": 10, "height_mm": 10,
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "badge.duplicate_field_type"
|
||||
|
||||
|
||||
def test_generating_without_any_field_configured_is_rejected(client, edition, session):
|
||||
make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
resp = client.post(f"{_badges_url(edition)}/generate", json={})
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "badge.no_fields"
|
||||
|
||||
|
||||
def test_generating_with_no_matching_volunteers_is_rejected(client, edition, session):
|
||||
_set_full_name_field(client, edition)
|
||||
make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
resp = client.post(
|
||||
f"{_badges_url(edition)}/generate",
|
||||
json={"volunteer_ids": ["00000000-0000-0000-0000-000000000000"]},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "badge.no_volunteers"
|
||||
|
||||
|
||||
def test_generating_produces_a_pdf(client, edition, session):
|
||||
from app.models import Assignment
|
||||
|
||||
resp = client.put(
|
||||
f"{_badges_url(edition)}/template/fields",
|
||||
json={
|
||||
"fields": [
|
||||
{
|
||||
"field_type": "FULL_NAME", "x_mm": 5, "y_mm": 5,
|
||||
"width_mm": 60, "height_mm": 10, "font_size": 12,
|
||||
},
|
||||
{
|
||||
"field_type": "POSITIONS", "x_mm": 5, "y_mm": 20,
|
||||
"width_mm": 60, "height_mm": 10, "font_size": 9,
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
client.put(
|
||||
f"{_badges_url(edition)}/template/front-image",
|
||||
files={"file": ("front.png", _PNG, "image/png")},
|
||||
)
|
||||
client.put(
|
||||
f"{_badges_url(edition)}/template/back-image",
|
||||
files={"file": ("back.png", _PNG, "image/png")},
|
||||
)
|
||||
|
||||
volunteer = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
position = make_position(session, edition, name="Bar central")
|
||||
shift = make_shift(session, edition, position, start=dt_at(9, 18), end=dt_at(9, 22))
|
||||
session.add(Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id))
|
||||
session.commit()
|
||||
|
||||
resp = client.post(
|
||||
f"{_badges_url(edition)}/generate",
|
||||
json={"volunteer_ids": [str(volunteer.id)]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "application/pdf"
|
||||
assert resp.content.startswith(b"%PDF")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Génération des planches de badges (module pur, sans base)."""
|
||||
|
||||
from app.badges.pdf import (
|
||||
BadgeFieldSpec,
|
||||
BadgeSpec,
|
||||
_cell_origin,
|
||||
_grid_size,
|
||||
generate_badge_sheets_pdf,
|
||||
)
|
||||
|
||||
|
||||
def _badge(text="Alex Martin") -> BadgeSpec:
|
||||
return BadgeSpec(
|
||||
fields=(
|
||||
BadgeFieldSpec(
|
||||
text=text,
|
||||
x_mm=5,
|
||||
y_mm=5,
|
||||
width_mm=60,
|
||||
height_mm=10,
|
||||
font_size=12,
|
||||
align="LEFT",
|
||||
bold=True,
|
||||
color="#000000",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_grid_size_fits_a4_with_cr80_badges():
|
||||
# A4 (210 × 297 mm), marge 10 mm, gouttière 4 mm, badge CR80 85,6 × 54 mm
|
||||
# ⇒ 2 colonnes (190 mm utiles / 89,6 mm par badge+gouttière) x 4 rangées
|
||||
# (277 mm utiles / 58 mm par badge+gouttière).
|
||||
assert _grid_size() == (2, 4)
|
||||
|
||||
|
||||
def test_cell_origin_places_badges_left_to_right_top_to_bottom():
|
||||
cols, _ = _grid_size()
|
||||
x0, y0 = _cell_origin(0, cols)
|
||||
x1, _ = _cell_origin(1, cols)
|
||||
_, y_row2 = _cell_origin(cols, cols)
|
||||
assert x1 > x0 # la 2e colonne est à droite de la 1re
|
||||
assert y_row2 < y0 # la 2e rangée est plus bas (repère PDF : Y croît vers le haut)
|
||||
|
||||
|
||||
def test_generates_a_valid_pdf_for_a_single_badge():
|
||||
pdf = generate_badge_sheets_pdf([_badge()], front_image=None, back_image=None)
|
||||
assert pdf.startswith(b"%PDF")
|
||||
assert pdf.rstrip().endswith(b"%%EOF")
|
||||
|
||||
|
||||
def test_more_badges_than_one_sheet_produces_a_larger_pdf():
|
||||
cols, rows = _grid_size()
|
||||
per_page = cols * rows
|
||||
one_page = generate_badge_sheets_pdf(
|
||||
[_badge() for _ in range(per_page)], front_image=None, back_image=None
|
||||
)
|
||||
# Un badge de plus déborde sur une 2e planche (recto + verso en plus).
|
||||
two_pages = generate_badge_sheets_pdf(
|
||||
[_badge() for _ in range(per_page + 1)], front_image=None, back_image=None
|
||||
)
|
||||
assert len(two_pages) > len(one_page)
|
||||
@@ -27,6 +27,8 @@ EXPECTED_TABLES = {
|
||||
"shift",
|
||||
"availability",
|
||||
"assignment",
|
||||
"badge_template",
|
||||
"badge_field",
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user