Badges : retour à la ligne et créneaux en tableau par jour
ci-cd / build-backend (push) Successful in 37s
ci-cd / build-frontend (push) Successful in 22s
ci-cd / deploy (push) Successful in 13s

Le texte des champs passe à la ligne pour tenir dans la largeur de son
cadre (wrap_text) au lieu de déborder. Les créneaux affectés s'affichent
en tableau : le jour de festival en en-tête, puis une colonne horaire et
une colonne poste (plus de référent sur le badge) ; même tableau dans
l'aperçu de l'éditeur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-27 18:44:55 +01:00
co-authored by Claude Opus 5.5
parent cfed76c239
commit 1a23881e81
8 changed files with 391 additions and 86 deletions
+31 -51
View File
@@ -3,7 +3,7 @@
import datetime as dt
import uuid
from app.api.badges import _field_text, _position_line
from app.api.badges import _field_text, _positions_table
from app.models import (
BadgeField,
BadgeFieldType,
@@ -29,65 +29,45 @@ def _shift(start: dt.datetime, end: dt.datetime) -> Shift:
)
def test_position_line_shows_the_schedule():
volunteer = _volunteer(first_name="Alex", last_name="Martin")
position = _position(name="Bar central")
shift = _shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0))
line = _position_line(position, shift, referent=None, volunteer_id=volunteer.id)
assert line == "Bar central 19:00–22:00"
_BOUNDARY = dt.time(2, 0) # journée de festival de 02:00 à 02:00
def test_position_line_shows_the_referent_when_there_is_one():
volunteer = _volunteer(first_name="Alex", last_name="Martin")
position = _position(name="Bar central")
shift = _shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0))
referent = _volunteer(first_name="Sam", last_name="Nuit")
line = _position_line(position, shift, referent=referent, volunteer_id=volunteer.id)
assert line == "Bar central 19:00–22:00 (Référent : Sam Nuit)"
def test_position_line_hides_the_referent_when_it_is_the_badged_volunteer():
# Pas besoin de se dire à soi-même qu'on est referent de son propre poste.
volunteer = _volunteer(first_name="Sam", last_name="Nuit")
line = _position_line(
_position(name="Bar central"),
_shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0)),
referent=volunteer,
volunteer_id=volunteer.id,
)
assert "Référent" not in line
def test_field_text_positions_lists_one_line_per_shift_in_chronological_order():
volunteer = _volunteer(first_name="Alex", last_name="Martin")
field = BadgeField(
edition_id=uuid.uuid4(), field_type=BadgeFieldType.POSITIONS,
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9,
)
morning = (
def test_positions_table_groups_shifts_under_their_day():
accueil = (
_position(name="Accueil"),
_shift(dt.datetime(2026, 7, 9, 9, 0), dt.datetime(2026, 7, 9, 13, 0)),
None,
_shift(dt.datetime(2026, 7, 11, 9, 0), dt.datetime(2026, 7, 11, 13, 0)),
)
evening = (
bar = (
_position(name="Bar central"),
_shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0)),
None,
_shift(dt.datetime(2026, 7, 11, 19, 0), dt.datetime(2026, 7, 11, 22, 0)),
)
demontage = (
_position(name="Démontage"),
_shift(dt.datetime(2026, 7, 12, 14, 0), dt.datetime(2026, 7, 12, 18, 0)),
)
text = _field_text(
field, volunteer,
assignments_by_volunteer={volunteer.id: [morning, evening]},
lead_volunteer_ids=set(),
table = _positions_table([accueil, bar, demontage], _BOUNDARY)
assert [(s.title, s.rows) for s in table.sections] == [
("Samedi 11", (("09:00–13:00", "Accueil"), ("19:00–22:00", "Bar central"))),
("Dimanche 12", (("14:00–18:00", "Démontage"),)),
]
def test_positions_table_keeps_a_night_shift_on_the_day_it_started():
night = (
_position(name="Bar central"),
_shift(dt.datetime(2026, 7, 12, 0, 0), dt.datetime(2026, 7, 12, 1, 0)),
)
assert text == "Accueil 09:00–13:00\nBar central 19:00–22:00"
table = _positions_table([night], _BOUNDARY)
assert table.sections[0].title == "Samedi 11"
assert table.sections[0].rows == (("00:00–01:00", "Bar central"),)
def test_positions_table_is_empty_without_any_shift():
assert _positions_table([], _BOUNDARY) is None
def test_field_text_role_is_referent_only_for_lead_accounts():
+85
View File
@@ -3,6 +3,8 @@
import io
from PIL import Image
from reportlab.lib.units import mm
from reportlab.pdfbase.pdfmetrics import stringWidth
from app.badges.pdf import (
ASSUMED_DPI,
@@ -10,11 +12,15 @@ from app.badges.pdf import (
DEFAULT_BADGE_WIDTH_MM,
BadgeFieldSpec,
BadgeSpec,
BadgeTable,
BadgeTableSection,
_cell_origin,
_column_widths,
_fit_photo,
_grid_size,
generate_badge_sheets_pdf,
image_size_mm,
wrap_text,
)
@@ -151,3 +157,82 @@ def test_unreadable_photo_does_not_crash_generation():
[_photo_badge(b"pas une image")], front_image=None, back_image=None
)
assert pdf.startswith(b"%PDF")
def test_wrap_text_keeps_a_short_text_on_one_line():
assert wrap_text("Alex Martin", "Helvetica", 12, 60 * mm) == ["Alex Martin"]
def test_wrap_text_breaks_between_words_to_fit_the_width():
# Assez large pour « Marie-Christine » seul, trop étroit pour le tout.
width = 40 * mm
lines = wrap_text("Marie-Christine de La Fontaine", "Helvetica-Bold", 12, width)
assert len(lines) > 1
assert " ".join(lines) == "Marie-Christine de La Fontaine"
assert all(stringWidth(line, "Helvetica-Bold", 12) <= width for line in lines)
def test_wrap_text_keeps_explicit_line_breaks_and_wraps_each_line():
text = "Bar central 19:00–22:00 (Référent : Sam Nuit)\nAccueil 09:00–13:00"
width = 40 * mm
lines = wrap_text(text, "Helvetica", 8, width)
assert lines[0].startswith("Bar central 19:00–22:00")
assert "Accueil 09:00–13:00" in lines
assert all(stringWidth(line, "Helvetica", 8) <= width for line in lines)
def test_wrap_text_cuts_a_single_word_too_long_for_the_frame():
width = 10 * mm
lines = wrap_text("Anticonstitutionnellement", "Helvetica", 12, width)
assert "".join(lines) == "Anticonstitutionnellement"
assert all(stringWidth(line, "Helvetica", 12) <= width for line in lines)
def _table_badge(rows: int, height_mm: float = 30) -> BadgeSpec:
return BadgeSpec(
fields=(
BadgeFieldSpec(
text="", x_mm=5, y_mm=5, width_mm=60, height_mm=height_mm,
font_size=7, align="LEFT", bold=False, color="#000000",
table=BadgeTable(
sections=(
BadgeTableSection(
title="Samedi 11",
rows=tuple(
("19:00–22:00", "Bar central très très long")
for _ in range(rows)
),
),
)
),
),
)
)
def test_column_widths_keep_short_columns_and_share_the_rest():
widths = _column_widths([20, 30, 200, 100], available=200)
assert widths[:2] == [20, 30]
assert widths[2] == widths[3] == 75
assert sum(widths) == 200
def test_column_widths_give_the_spare_room_to_the_last_column():
assert _column_widths([20, 30, 40], available=150) == [20, 30, 100]
def test_table_field_generates_a_valid_pdf():
pdf = generate_badge_sheets_pdf([_table_badge(3)], front_image=None, back_image=None)
assert pdf.startswith(b"%PDF")
def test_table_rows_beyond_the_frame_are_not_drawn():
# Cadre trop petit pour 50 lignes : celles qui débordent sont omises,
# la planche fait la même taille qu'avec quelques lignes seulement.
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