Files
AdamThomasandClaude Opus 5.5 c6ea7378c7
ci-cd / build-backend (push) Successful in 34s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 11s
Fiches de poste rapides, badges sans initiales, vue par bénévole sur l'axe des temps
É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>
2026-10-28 19:01:48 +01:00

443 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"]