Badges : format dérivé de l'image importée, postes avec horaire et référent
- Le format du badge n'est plus fixé à CR80 : il est déduit des pixels de l'image du recto à l'import (résolution supposée 300 dpi, app/badges/pdf.py:image_size_mm), stocké sur BadgeTemplate (migration 0012). L'éditeur (BadgesPage.tsx) et la génération PDF s'alignent sur ce format réel ; CR80 reste le repère par défaut tant qu'aucune image n'est importée. Le verso est ajusté sans déformation (preserveAspectRatio). - Le champ « Postes affectés » liste désormais une ligne par créneau (triées chronologiquement) : nom du poste + horaire, et le référent du poste s'il y en a un et que ce n'est pas le bénévole badgé lui-même. Texte multi-lignes pris en charge par la génération PDF (découpe sur `\n`, hauteur de ligne calculée en mm) et par l'aperçu de l'éditeur. - Corrige au passage un bug d'unités dans le positionnement vertical du texte (police en points confondue avec des millimètres). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b7fc80a7dc
commit
579ea5d9db
@@ -2,6 +2,8 @@
|
||||
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import Role
|
||||
from tests.factories import dt_at, make_position, make_shift, make_volunteer
|
||||
|
||||
@@ -49,6 +51,20 @@ def test_template_starts_empty(client, edition):
|
||||
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):
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Texte résolu des champs du badge — fonctions pures (pas de base)."""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from app.api.badges import _field_text, _position_line
|
||||
from app.models import (
|
||||
BadgeField,
|
||||
BadgeFieldType,
|
||||
Position,
|
||||
PositionCategory,
|
||||
Shift,
|
||||
Volunteer,
|
||||
)
|
||||
|
||||
|
||||
def _volunteer(**kw) -> Volunteer:
|
||||
return Volunteer(id=uuid.uuid4(), edition_id=uuid.uuid4(), **kw)
|
||||
|
||||
|
||||
def _position(**kw) -> Position:
|
||||
return Position(id=uuid.uuid4(), edition_id=uuid.uuid4(), category=PositionCategory.BAR, **kw)
|
||||
|
||||
|
||||
def _shift(start: dt.datetime, end: dt.datetime) -> Shift:
|
||||
return Shift(id=uuid.uuid4(), edition_id=uuid.uuid4(), position_id=uuid.uuid4(), starts_at=start, ends_at=end)
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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 = (
|
||||
_position(name="Accueil"),
|
||||
_shift(dt.datetime(2026, 7, 9, 9, 0), dt.datetime(2026, 7, 9, 13, 0)),
|
||||
None,
|
||||
)
|
||||
evening = (
|
||||
_position(name="Bar central"),
|
||||
_shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0)),
|
||||
None,
|
||||
)
|
||||
|
||||
text = _field_text(
|
||||
field, volunteer,
|
||||
assignments_by_volunteer={volunteer.id: [morning, evening]},
|
||||
lead_volunteer_ids=set(),
|
||||
)
|
||||
|
||||
assert text == "Accueil 09:00–13:00\nBar central 19:00–22:00"
|
||||
|
||||
|
||||
def test_field_text_role_is_referent_only_for_lead_accounts():
|
||||
volunteer = _volunteer(first_name="Sam", last_name="Nuit")
|
||||
field = BadgeField(
|
||||
edition_id=uuid.uuid4(), field_type=BadgeFieldType.ROLE,
|
||||
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9,
|
||||
)
|
||||
|
||||
assert _field_text(field, volunteer, {}, lead_volunteer_ids=set()) == "Bénévole"
|
||||
assert _field_text(field, volunteer, {}, lead_volunteer_ids={volunteer.id}) == "Référent"
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Génération des planches de badges (module pur, sans base)."""
|
||||
|
||||
from app.badges.pdf import (
|
||||
DEFAULT_BADGE_HEIGHT_MM,
|
||||
DEFAULT_BADGE_WIDTH_MM,
|
||||
BadgeFieldSpec,
|
||||
BadgeSpec,
|
||||
_cell_origin,
|
||||
_grid_size,
|
||||
generate_badge_sheets_pdf,
|
||||
image_size_mm,
|
||||
)
|
||||
|
||||
|
||||
@@ -27,18 +30,30 @@ def _badge(text="Alex Martin") -> BadgeSpec:
|
||||
)
|
||||
|
||||
|
||||
def test_image_size_mm_uses_the_assumed_print_resolution():
|
||||
# 300 dpi (résolution d'impression standard) : 1013 px / 300 * 25,4 mm.
|
||||
width_mm, height_mm = image_size_mm(1013, 638, dpi=300)
|
||||
assert width_mm == 1013 / 300 * 25.4
|
||||
assert height_mm == 638 / 300 * 25.4
|
||||
|
||||
|
||||
def test_grid_size_fits_a4_with_cr80_badges():
|
||||
# A4 (210 × 297 mm), marge 10 mm, gouttière 4 mm, badge CR80 85,6 × 54 mm
|
||||
# ⇒ 2 colonnes (190 mm utiles / 89,6 mm par badge+gouttière) x 4 rangées
|
||||
# (277 mm utiles / 58 mm par badge+gouttière).
|
||||
assert _grid_size() == (2, 4)
|
||||
assert _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM) == (2, 4)
|
||||
|
||||
|
||||
def test_grid_size_adapts_to_a_different_badge_format():
|
||||
# Badge carré 50 × 50 mm : plus de colonnes/rangées qu'un CR80.
|
||||
assert _grid_size(50, 50) == (3, 5)
|
||||
|
||||
|
||||
def test_cell_origin_places_badges_left_to_right_top_to_bottom():
|
||||
cols, _ = _grid_size()
|
||||
x0, y0 = _cell_origin(0, cols)
|
||||
x1, _ = _cell_origin(1, cols)
|
||||
_, y_row2 = _cell_origin(cols, cols)
|
||||
cols, _ = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
|
||||
x0, y0 = _cell_origin(0, cols, DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
|
||||
x1, _ = _cell_origin(1, cols, DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
|
||||
_, y_row2 = _cell_origin(cols, cols, DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
|
||||
assert x1 > x0 # la 2e colonne est à droite de la 1re
|
||||
assert y_row2 < y0 # la 2e rangée est plus bas (repère PDF : Y croît vers le haut)
|
||||
|
||||
@@ -49,8 +64,30 @@ def test_generates_a_valid_pdf_for_a_single_badge():
|
||||
assert pdf.rstrip().endswith(b"%%EOF")
|
||||
|
||||
|
||||
def test_generates_a_valid_pdf_with_a_custom_badge_format():
|
||||
pdf = generate_badge_sheets_pdf(
|
||||
[_badge()], front_image=None, back_image=None,
|
||||
badge_width_mm=50, badge_height_mm=50,
|
||||
)
|
||||
assert pdf.startswith(b"%PDF")
|
||||
|
||||
|
||||
def test_multiline_field_text_does_not_crash_generation():
|
||||
badge = BadgeSpec(
|
||||
fields=(
|
||||
BadgeFieldSpec(
|
||||
text="Bar central 19:00–22:00\nAccueil 09:00–13:00 (Référent : Sam Nuit)",
|
||||
x_mm=5, y_mm=5, width_mm=70, height_mm=20,
|
||||
font_size=9, align="LEFT", bold=False, color="#000000",
|
||||
),
|
||||
)
|
||||
)
|
||||
pdf = generate_badge_sheets_pdf([badge], front_image=None, back_image=None)
|
||||
assert pdf.startswith(b"%PDF")
|
||||
|
||||
|
||||
def test_more_badges_than_one_sheet_produces_a_larger_pdf():
|
||||
cols, rows = _grid_size()
|
||||
cols, rows = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
|
||||
per_page = cols * rows
|
||||
one_page = generate_badge_sheets_pdf(
|
||||
[_badge() for _ in range(per_page)], front_image=None, back_image=None
|
||||
|
||||
Reference in New Issue
Block a user