É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>
443 lines
16 KiB
Python
443 lines
16 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 == "" # pas de photo : rien d'imprimé
|
||
|
||
|
||
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
|
||
|
||
|
||
def test_fields_are_drawn_in_stacking_order(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="FREE_TEXT", text="Staff", z_order=2),
|
||
_field(field_type="FULL_NAME", z_order=0),
|
||
_field(field_type="PHOTO", z_order=1),
|
||
]
|
||
},
|
||
)
|
||
assert resp.status_code == 200
|
||
assert [f["field_type"] for f in resp.json()] == ["FULL_NAME", "PHOTO", "FREE_TEXT"]
|
||
make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||
|
||
assert client.post(f"{_badges_url(edition)}/generate", json={}).status_code == 200
|
||
(badge,) = captured["badges"]
|
||
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):
|
||
resp = client.put(
|
||
f"{_badges_url(edition)}/template/fields",
|
||
json={"fields": [_field(field_type="FULL_NAME"), _field(field_type="PHOTO")]},
|
||
)
|
||
assert [f["field_type"] for f in resp.json()] == ["PHOTO", "FULL_NAME"]
|