Refonte du créateur de badges et nombre de badges par bénévole
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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1a23881e81
commit
ced0855d47
@@ -247,3 +247,154 @@ def test_generating_with_photo_and_free_text_fields(client, edition, session):
|
||||
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
|
||||
|
||||
@@ -104,3 +104,21 @@ def test_field_text_free_text_falls_back_to_the_common_editor_text():
|
||||
|
||||
assert _field_text(field, own, {}, set()) == "Accès backstage"
|
||||
assert _field_text(field, other, {}, set()) == "Staff 2026"
|
||||
|
||||
|
||||
def test_field_text_full_name_is_first_name_then_last_name_on_two_lines():
|
||||
volunteer = _volunteer(first_name="Alex", last_name="Martin")
|
||||
field = BadgeField(
|
||||
edition_id=uuid.uuid4(), field_type=BadgeFieldType.FULL_NAME,
|
||||
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9,
|
||||
)
|
||||
assert _field_text(field, volunteer, {}, set()) == "Alex\nMartin"
|
||||
|
||||
|
||||
def test_field_text_photo_is_the_initials_used_without_a_photo():
|
||||
volunteer = _volunteer(first_name="alex", last_name="martin")
|
||||
field = BadgeField(
|
||||
edition_id=uuid.uuid4(), field_type=BadgeFieldType.PHOTO,
|
||||
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9,
|
||||
)
|
||||
assert _field_text(field, volunteer, {}, set()) == "AM"
|
||||
|
||||
@@ -236,3 +236,170 @@ def test_table_rows_beyond_the_frame_are_not_drawn():
|
||||
few = generate_badge_sheets_pdf([_table_badge(4, height_mm=12)], None, None)
|
||||
many = generate_badge_sheets_pdf([_table_badge(50, height_mm=12)], None, None)
|
||||
assert abs(len(many) - len(few)) < 50
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Éditeur v2 : verso, fond de zone, réduction automatique, photo #
|
||||
# --------------------------------------------------------------------------- #
|
||||
from app.badges import pdf as pdf_module # noqa: E402
|
||||
from app.badges.pdf import fit_text, initials # noqa: E402
|
||||
|
||||
|
||||
def _text_field(**kw) -> BadgeFieldSpec:
|
||||
base = dict(
|
||||
text="Alex", x_mm=5, y_mm=5, width_mm=60, height_mm=10,
|
||||
font_size=12, align="LEFT", bold=False, color="#000000",
|
||||
)
|
||||
base.update(kw)
|
||||
return BadgeFieldSpec(**base)
|
||||
|
||||
|
||||
def test_cell_origin_mirrors_the_column_on_the_back_page():
|
||||
cols, _ = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
|
||||
w, h = DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM
|
||||
# Retournement bord long : colonne 0 au recto ⇒ dernière colonne au
|
||||
# verso, même rangée.
|
||||
assert _cell_origin(0, cols, w, h, mirrored=True) == _cell_origin(cols - 1, cols, w, h)
|
||||
assert _cell_origin(cols, cols, w, h, mirrored=True) == _cell_origin(
|
||||
2 * cols - 1, cols, w, h
|
||||
)
|
||||
|
||||
|
||||
def test_back_page_draws_each_volunteer_back_fields_at_the_mirrored_slot(monkeypatch):
|
||||
calls = []
|
||||
original = pdf_module._draw_fields
|
||||
|
||||
def spy(c, fields, x0, y0, badge_height_mm):
|
||||
calls.append((fields, x0, y0))
|
||||
original(c, fields, x0, y0, badge_height_mm)
|
||||
|
||||
monkeypatch.setattr(pdf_module, "_draw_fields", spy)
|
||||
back = (_text_field(text="Verso Alex"),)
|
||||
# Un seul badge : planche incomplète, le verso doit quand même être en
|
||||
# miroir (colonne de droite).
|
||||
generate_badge_sheets_pdf(
|
||||
[BadgeSpec(fields=(_text_field(),), back_fields=back)], None, None
|
||||
)
|
||||
|
||||
cols, _ = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
|
||||
w, h = DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM
|
||||
(front_fields, fx, fy), (back_fields, bx, by) = calls
|
||||
assert (fx, fy) == _cell_origin(0, cols, w, h)
|
||||
assert back_fields == back
|
||||
assert (bx, by) == _cell_origin(cols - 1, cols, w, h)
|
||||
|
||||
|
||||
def test_fit_text_without_auto_fit_drops_the_lines_that_overflow():
|
||||
text = "\n".join(["ligne"] * 10)
|
||||
size, lines = fit_text(text, "Helvetica", 12, 60, 10, auto_fit=False)
|
||||
assert size == 12
|
||||
assert 0 < len(lines) < 10
|
||||
|
||||
|
||||
def test_fit_text_with_auto_fit_shrinks_until_every_line_fits():
|
||||
text = "\n".join(["ligne"] * 4)
|
||||
assert len(fit_text(text, "Helvetica", 12, 60, 10, auto_fit=False)[1]) < 4
|
||||
size, lines = fit_text(text, "Helvetica", 12, 60, 10, auto_fit=True)
|
||||
assert 4 < size < 12
|
||||
assert (size * 2) == int(size * 2) # pas de 0,5 pt
|
||||
assert len(lines) == 4
|
||||
|
||||
|
||||
def test_fit_text_auto_fit_stops_at_the_minimum_size():
|
||||
text = "\n".join(["ligne"] * 200)
|
||||
size, lines = fit_text(text, "Helvetica", 12, 60, 10, auto_fit=True)
|
||||
assert size == 4.0
|
||||
assert len(lines) < 200
|
||||
|
||||
|
||||
def test_fit_text_auto_fit_keeps_the_size_when_the_text_already_fits():
|
||||
assert fit_text("Alex", "Helvetica", 12, 60, 10, auto_fit=True) == (12, ["Alex"])
|
||||
|
||||
|
||||
def _table_lines_drawn(monkeypatch, auto_fit: bool) -> int:
|
||||
drawn = []
|
||||
monkeypatch.setattr(
|
||||
pdf_module.canvas.Canvas, "drawString",
|
||||
lambda self, x, y, text, *a, **k: drawn.append(text),
|
||||
)
|
||||
badge = _table_badge(4, height_mm=20)
|
||||
field = badge.fields[0]
|
||||
badge = BadgeSpec(fields=(BadgeFieldSpec(**{**field.__dict__, "auto_fit": auto_fit}),))
|
||||
generate_badge_sheets_pdf([badge], None, None)
|
||||
return sum(1 for text in drawn if text == "19:00–22:00")
|
||||
|
||||
|
||||
def test_table_auto_fit_draws_every_row(monkeypatch):
|
||||
# En-tête + 4 lignes : la dernière ne tient pas à 7 pt dans 20 mm.
|
||||
assert _table_lines_drawn(monkeypatch, auto_fit=False) < 4
|
||||
assert _table_lines_drawn(monkeypatch, auto_fit=True) == 4
|
||||
|
||||
|
||||
def _filled_rects(monkeypatch) -> list:
|
||||
fills = []
|
||||
original = pdf_module.canvas.Canvas.rect
|
||||
|
||||
def spy(self, x, y, w, h, stroke=1, fill=0):
|
||||
if fill:
|
||||
fills.append((round(w / mm, 3), round(h / mm, 3)))
|
||||
return original(self, x, y, w, h, stroke=stroke, fill=fill)
|
||||
|
||||
monkeypatch.setattr(pdf_module.canvas.Canvas, "rect", spy)
|
||||
return fills
|
||||
|
||||
|
||||
def test_zone_background_fills_the_field_frame(monkeypatch):
|
||||
fills = _filled_rects(monkeypatch)
|
||||
generate_badge_sheets_pdf(
|
||||
[BadgeSpec(fields=(_text_field(bg_color="#FFD23F"),))], None, None
|
||||
)
|
||||
assert fills == [(60, 10)]
|
||||
|
||||
|
||||
def test_no_zone_background_by_default(monkeypatch):
|
||||
fills = _filled_rects(monkeypatch)
|
||||
generate_badge_sheets_pdf([BadgeSpec(fields=(_text_field(),))], None, None)
|
||||
assert fills == []
|
||||
|
||||
|
||||
def test_middle_aligned_text_is_lower_than_top_aligned_text(monkeypatch):
|
||||
baselines = []
|
||||
monkeypatch.setattr(
|
||||
pdf_module.canvas.Canvas, "drawString",
|
||||
lambda self, x, y, text, *a, **k: baselines.append(y),
|
||||
)
|
||||
for valign in ("TOP", "MIDDLE"):
|
||||
generate_badge_sheets_pdf(
|
||||
[BadgeSpec(fields=(_text_field(height_mm=30, valign=valign),))], None, None
|
||||
)
|
||||
top, middle = baselines
|
||||
assert middle < top
|
||||
|
||||
|
||||
def test_initials_are_the_uppercase_first_letters():
|
||||
assert initials("alex", "martin") == "AM"
|
||||
assert initials("Élodie", "de la Tour") == "ÉD"
|
||||
|
||||
|
||||
def test_missing_photo_prints_the_initials(monkeypatch):
|
||||
drawn = []
|
||||
monkeypatch.setattr(
|
||||
pdf_module.canvas.Canvas, "drawCentredString",
|
||||
lambda self, x, y, text, *a, **k: drawn.append(text),
|
||||
)
|
||||
for image in (None, b"pas une image"):
|
||||
badge = BadgeSpec(
|
||||
fields=(BadgeFieldSpec(**{**_photo_badge(image).fields[0].__dict__, "text": "AM"}),)
|
||||
)
|
||||
generate_badge_sheets_pdf([badge], None, None)
|
||||
assert drawn == ["AM", "AM"]
|
||||
|
||||
|
||||
def test_oval_photo_and_oval_initials_generate_a_valid_pdf():
|
||||
for image in (_jpeg(400, 500), None):
|
||||
field = _photo_badge(image).fields[0]
|
||||
badge = BadgeSpec(
|
||||
fields=(BadgeFieldSpec(**{**field.__dict__, "photo_shape": "OVAL", "text": "AM"}),)
|
||||
)
|
||||
pdf = generate_badge_sheets_pdf([badge], None, None)
|
||||
assert pdf.startswith(b"%PDF")
|
||||
|
||||
Reference in New Issue
Block a user