Créateur de badges refait d'après la maquette : champs sur le recto et le verso (BadgeField.face, verso placé en miroir dans le PDF pour le recto-verso), fond de zone, réduction automatique du texte, photo ovale, initiales sans photo, nom sur deux lignes (migration 0026) ; éditeur en trois colonnes (champs, aperçu recto/verso avec de vrais bénévoles, inspecteur), formats prédéfinis, annuler, flèches, zoom, repères de découpe, dépôt d'image sur l'aperçu, contrôle de tous les badges avant génération. Nombre de badges par bénévole (Volunteer.badge_count, 1 par défaut, migration 0027) : champ sur la fiche, autant d'exemplaires sur les planches, total affiché à la génération, colonne dans l'export Excel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
401 lines
14 KiB
Python
401 lines
14 KiB
Python
"""Configuration et génération des planches de badges (API)."""
|
||
|
||
import base64
|
||
|
||
import pytest
|
||
|
||
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"] == []
|
||
# Format CR80 par défaut tant qu'aucune image n'a été importée.
|
||
assert body["badge_width_mm"] == 85.6
|
||
assert body["badge_height_mm"] == 54.0
|
||
|
||
|
||
def test_uploading_front_image_derives_the_badge_format_from_its_pixels(client, edition):
|
||
resp = client.put(
|
||
f"{_badges_url(edition)}/template/front-image",
|
||
files={"file": ("front.png", _PNG, "image/png")},
|
||
)
|
||
body = resp.json()
|
||
# PNG 1×1 px, résolution supposée 300 dpi ⇒ 25,4/300 mm de côté.
|
||
assert body["badge_width_mm"] == pytest.approx(25.4 / 300)
|
||
assert body["badge_height_mm"] == pytest.approx(25.4 / 300)
|
||
|
||
|
||
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_resizing_the_badge_scales_it_independently_of_the_image(client, edition):
|
||
client.put(
|
||
f"{_badges_url(edition)}/template/front-image",
|
||
files={"file": ("front.png", _PNG, "image/png")},
|
||
)
|
||
# Image 1×1 px à 300 dpi supposés ⇒ badge minuscule (~0,08 mm) : on
|
||
# l'agrandit à un format exploitable, en conservant le ratio (ici 1:1).
|
||
resp = client.put(
|
||
f"{_badges_url(edition)}/template/size",
|
||
json={"badge_width_mm": 90, "badge_height_mm": 90},
|
||
)
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["badge_width_mm"] == 90
|
||
assert body["badge_height_mm"] == 90
|
||
|
||
# Persisté : une relecture du gabarit renvoie toujours la taille agrandie.
|
||
again = client.get(f"{_badges_url(edition)}/template").json()
|
||
assert again["badge_width_mm"] == 90
|
||
assert again["badge_height_mm"] == 90
|
||
|
||
|
||
def test_resizing_the_badge_to_a_non_positive_size_is_rejected(client, edition):
|
||
resp = client.put(
|
||
f"{_badges_url(edition)}/template/size",
|
||
json={"badge_width_mm": 0, "badge_height_mm": 54},
|
||
)
|
||
assert resp.status_code == 422
|
||
assert resp.json()["detail"]["code"] == "badge.invalid_size"
|
||
|
||
|
||
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")
|
||
|
||
|
||
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,
|
||
"text": "Staff 2026",
|
||
},
|
||
]
|
||
},
|
||
)
|
||
assert resp.status_code == 200
|
||
by_type = {f["field_type"]: f for f in resp.json()}
|
||
assert set(by_type) == {"PHOTO", "FREE_TEXT"}
|
||
assert by_type["FREE_TEXT"]["text"] == "Staff 2026"
|
||
|
||
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")
|
||
|
||
|
||
def _field(**kw):
|
||
return {"x_mm": 0, "y_mm": 0, "width_mm": 10, "height_mm": 10, **kw}
|
||
|
||
|
||
def test_new_field_attributes_default_and_round_trip(client, edition):
|
||
resp = client.put(
|
||
f"{_badges_url(edition)}/template/fields",
|
||
json={
|
||
"fields": [
|
||
_field(field_type="FULL_NAME"),
|
||
_field(
|
||
field_type="PHOTO", face="BACK", photo_shape="OVAL",
|
||
bg_color="#FFD23F", auto_fit=True,
|
||
),
|
||
]
|
||
},
|
||
)
|
||
assert resp.status_code == 200
|
||
by_face = {f["face"]: f for f in resp.json()}
|
||
front, back = by_face["FRONT"], by_face["BACK"]
|
||
assert front["field_type"] == "FULL_NAME"
|
||
assert front["bg_color"] is None
|
||
assert front["auto_fit"] is False
|
||
assert front["photo_shape"] == "RECT"
|
||
assert back["field_type"] == "PHOTO"
|
||
assert back["photo_shape"] == "OVAL"
|
||
assert back["bg_color"] == "#FFD23F"
|
||
assert back["auto_fit"] is True
|
||
|
||
again = client.get(f"{_badges_url(edition)}/template").json()["fields"]
|
||
assert {(f["face"], f["field_type"]) for f in again} == {
|
||
("FRONT", "FULL_NAME"), ("BACK", "PHOTO"),
|
||
}
|
||
|
||
|
||
def test_same_field_type_on_front_and_back_is_allowed(client, edition):
|
||
resp = client.put(
|
||
f"{_badges_url(edition)}/template/fields",
|
||
json={
|
||
"fields": [
|
||
_field(field_type="FREE_TEXT", face="FRONT", text="Recto"),
|
||
_field(field_type="FREE_TEXT", face="BACK", text="Verso"),
|
||
]
|
||
},
|
||
)
|
||
assert resp.status_code == 200
|
||
assert {(f["face"], f["text"]) for f in resp.json()} == {
|
||
("FRONT", "Recto"), ("BACK", "Verso"),
|
||
}
|
||
|
||
|
||
def test_same_field_type_twice_on_the_back_is_rejected(client, edition):
|
||
resp = client.put(
|
||
f"{_badges_url(edition)}/template/fields",
|
||
json={
|
||
"fields": [
|
||
_field(field_type="ROLE", face="BACK"),
|
||
_field(field_type="ROLE", face="BACK"),
|
||
]
|
||
},
|
||
)
|
||
assert resp.status_code == 422
|
||
assert resp.json()["detail"]["code"] == "badge.duplicate_field_type"
|
||
|
||
|
||
@pytest.mark.parametrize("color", ["red", "#FFF", "#GGGGGG", "FFD23F", "#FFD23F0"])
|
||
def test_invalid_background_color_is_rejected(client, edition, color):
|
||
resp = client.put(
|
||
f"{_badges_url(edition)}/template/fields",
|
||
json={"fields": [_field(field_type="ROLE", bg_color=color)]},
|
||
)
|
||
assert resp.status_code == 422
|
||
assert resp.json()["detail"]["code"] == "badge.invalid_color"
|
||
|
||
|
||
def test_generating_with_back_fields(client, edition, session, monkeypatch):
|
||
import app.api.badges as badges_api
|
||
|
||
captured = {}
|
||
original = badges_api.generate_badge_sheets_pdf
|
||
|
||
def spy(badges, *args, **kwargs):
|
||
captured["badges"] = badges
|
||
return original(badges, *args, **kwargs)
|
||
|
||
monkeypatch.setattr(badges_api, "generate_badge_sheets_pdf", spy)
|
||
resp = client.put(
|
||
f"{_badges_url(edition)}/template/fields",
|
||
json={
|
||
"fields": [
|
||
_field(field_type="FULL_NAME", width_mm=60, font_size=12),
|
||
_field(field_type="ROLE", face="BACK", bg_color="#FFD23F", auto_fit=True),
|
||
_field(field_type="PHOTO", face="BACK", photo_shape="OVAL"),
|
||
]
|
||
},
|
||
)
|
||
assert resp.status_code == 200
|
||
make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||
|
||
resp = client.post(f"{_badges_url(edition)}/generate", json={})
|
||
assert resp.status_code == 200
|
||
assert resp.content.startswith(b"%PDF")
|
||
|
||
(badge,) = captured["badges"]
|
||
assert [f.text for f in badge.fields] == ["Alex\nMartin"]
|
||
back = {f.is_photo: f for f in badge.back_fields}
|
||
assert back[False].text == "Bénévole"
|
||
assert back[False].valign == "MIDDLE"
|
||
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
|
||
|
||
|
||
def test_generating_prints_as_many_badges_as_requested(client, edition, session, monkeypatch):
|
||
import app.api.badges as badges_api
|
||
|
||
captured = {}
|
||
original = badges_api.generate_badge_sheets_pdf
|
||
|
||
def spy(badges, *args, **kwargs):
|
||
captured["badges"] = badges
|
||
return original(badges, *args, **kwargs)
|
||
|
||
monkeypatch.setattr(badges_api, "generate_badge_sheets_pdf", spy)
|
||
client.put(
|
||
f"{_badges_url(edition)}/template/fields",
|
||
json={"fields": [_field(field_type="FULL_NAME", width_mm=60, font_size=12)]},
|
||
)
|
||
many = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||
make_volunteer(session, edition, first_name="Sam", last_name="Nuit")
|
||
resp = client.patch(f"/editions/{edition.id}/volunteers/{many.id}", json={"badge_count": 3})
|
||
assert resp.status_code == 200
|
||
assert resp.json()["badge_count"] == 3
|
||
|
||
resp = client.post(f"{_badges_url(edition)}/generate", json={})
|
||
assert resp.status_code == 200
|
||
|
||
names = [badge.fields[0].text for badge in captured["badges"]]
|
||
assert names == ["Alex\nMartin"] * 3 + ["Sam\nNuit"]
|
||
|
||
|
||
@pytest.mark.parametrize("count", [0, -1, 101])
|
||
def test_badge_count_out_of_range_is_rejected(client, edition, session, count):
|
||
volunteer = make_volunteer(session, edition)
|
||
resp = client.patch(
|
||
f"/editions/{edition.id}/volunteers/{volunteer.id}", json={"badge_count": count}
|
||
)
|
||
assert resp.status_code == 422
|