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
+43 -24
View File
@@ -8,6 +8,7 @@ pour la composition du PDF et `app/models/domain.py` (`BadgeTemplate`,
`BadgeField`) pour le modèle.
"""
import datetime as dt
import io
import uuid
from collections import defaultdict
@@ -15,7 +16,6 @@ from collections import defaultdict
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
from PIL import Image, UnidentifiedImageError
from sqlalchemy import delete
from sqlalchemy.orm import aliased
from sqlmodel import Session, select
from app.api.deps import get_edition
@@ -32,6 +32,8 @@ from app.badges.pdf import (
DEFAULT_BADGE_WIDTH_MM,
BadgeFieldSpec,
BadgeSpec,
BadgeTable,
BadgeTableSection,
generate_badge_sheets_pdf,
image_size_mm,
)
@@ -43,12 +45,14 @@ from app.models import (
BadgeField,
BadgeFieldType,
BadgeTemplate,
DayPart,
Edition,
Position,
Role,
Shift,
Volunteer,
)
from app.validation.engine import day_boundary, festival_day
router = APIRouter(prefix="/editions/{edition_id}/badges", tags=["badges"])
@@ -246,7 +250,7 @@ def set_size(
# --------------------------------------------------------------------------- #
# Génération du PDF #
# --------------------------------------------------------------------------- #
AssignmentRow = tuple[Position, Shift, Volunteer | None] # (poste, créneau, référent)
AssignmentRow = tuple[Position, Shift]
def _role_label(volunteer_id: uuid.UUID, lead_volunteer_ids: set[uuid.UUID]) -> str:
@@ -259,16 +263,29 @@ def _category_label(category) -> str:
return t(f"position.category.{category.value.lower()}")
def _position_line(
position: Position, shift: Shift, referent: Volunteer | None, volunteer_id: uuid.UUID
) -> str:
"""Une ligne par créneau affecté : poste + horaire, et le référent du
poste s'il y en a un — sauf si le bénévole badgé est lui-même ce
référent (pas besoin de se l'indiquer à soi-même)."""
line = f"{position.name} {shift.starts_at:%H:%M}–{shift.ends_at:%H:%M}"
if referent is not None and referent.id != volunteer_id:
line += f" ({t('badge.role.referent')} : {referent.first_name} {referent.last_name})"
return line
def _day_label(day: dt.date) -> str:
"""En-tête d'un jour dans le tableau des créneaux : « Samedi 11 »."""
return t("badge.table.day_label", weekday=t(f"badge.weekday.{day.weekday()}"), day=day.day)
def _positions_table(assignments: list[AssignmentRow], boundary: dt.time) -> BadgeTable | None:
"""Créneaux affectés regroupés par jour de festival (une soirée qui
passe minuit reste sur le jour où elle a commencé — `festival_day`) :
le jour en en-tête, puis une ligne horaire / poste par créneau, dans
l'ordre chronologique. `None` sans aucun créneau (rien à dessiner)."""
by_day: dict[dt.date, list[tuple[str, str]]] = defaultdict(list)
for position, shift in assignments:
by_day[festival_day(shift.starts_at, boundary)].append(
(f"{shift.starts_at:%H:%M}–{shift.ends_at:%H:%M}", position.name)
)
if not by_day:
return None
return BadgeTable(
sections=tuple(
BadgeTableSection(title=_day_label(day), rows=tuple(rows))
for day, rows in sorted(by_day.items())
)
)
def _field_text(
@@ -277,6 +294,8 @@ def _field_text(
assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]],
lead_volunteer_ids: set[uuid.UUID],
) -> str:
"""Texte d'un champ — `POSITIONS` n'en a pas : c'est un tableau
(`_positions_table`)."""
if field.field_type == BadgeFieldType.FULL_NAME:
return f"{volunteer.first_name} {volunteer.last_name}"
if field.field_type == BadgeFieldType.FREE_TEXT:
@@ -284,14 +303,9 @@ def _field_text(
# le « Texte du badge » de sa fiche s'il en a un.
return volunteer.badge_text or field.text or ""
assignments = assignments_by_volunteer.get(volunteer.id, [])
if field.field_type == BadgeFieldType.POSITIONS:
return "\n".join(
_position_line(position, shift, referent, volunteer.id)
for position, shift, referent in assignments
)
if field.field_type == BadgeFieldType.CATEGORY:
categories = sorted(
{_category_label(position.category) for position, _shift, _r in assignments}
{_category_label(position.category) for position, _shift in assignments}
)
return ", ".join(categories)
if field.field_type == BadgeFieldType.ROLE:
@@ -319,20 +333,20 @@ def generate_badges(
raise HTTPException(status_code=422, detail={"code": "badge.no_volunteers"})
volunteer_ids = [v.id for v in volunteers]
Referent = aliased(Volunteer)
rows = session.exec(
select(Assignment.volunteer_id, Position, Shift, Referent)
select(Assignment.volunteer_id, Position, Shift)
.join(Shift, Shift.id == Assignment.shift_id)
.join(Position, Position.id == Shift.position_id)
.join(Account, Account.id == Position.lead_account_id, isouter=True)
.join(Referent, Referent.id == Account.volunteer_id, isouter=True)
.where(Assignment.volunteer_id.in_(volunteer_ids))
.order_by(Shift.starts_at)
).all()
assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]] = defaultdict(list)
for volunteer_id, position, shift, referent in rows:
assignments_by_volunteer[volunteer_id].append((position, shift, referent))
for volunteer_id, position, shift in rows:
assignments_by_volunteer[volunteer_id].append((position, shift))
boundary = day_boundary(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
)
lead_volunteer_ids = {
account.volunteer_id
for account in session.exec(
@@ -364,6 +378,11 @@ def generate_badges(
image=volunteer.photo
if field.field_type == BadgeFieldType.PHOTO
else None,
table=_positions_table(
assignments_by_volunteer.get(volunteer.id, []), boundary
)
if field.field_type == BadgeFieldType.POSITIONS
else None,
)
for field in fields
)
+158 -3
View File
@@ -23,6 +23,7 @@ from reportlab.lib.colors import HexColor
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfgen import canvas
DEFAULT_BADGE_WIDTH_MM = 85.6
@@ -30,6 +31,11 @@ DEFAULT_BADGE_HEIGHT_MM = 54.0
_MARGIN_MM = 10.0
_GUTTER_MM = 4.0
_LINE_HEIGHT_FACTOR = 1.25
# Tableau (champ « postes ») : marges intérieures des cellules et épaisseur
# des filets.
_CELL_PAD_X_MM = 0.8
_CELL_PAD_Y_MM = 0.5
_TABLE_LINE_WIDTH_PT = 0.4
# Résolution supposée d'une image de fond importée, pour convertir ses pixels
# en millimètres réels (donc en taille de badge) — 300 dpi est la résolution
@@ -47,6 +53,23 @@ def image_size_mm(width_px: int, height_px: int, dpi: float = ASSUMED_DPI) -> tu
return (width_px / dpi * 25.4, height_px / dpi * 25.4)
@dataclass(frozen=True)
class BadgeTableSection:
"""Un bloc du tableau : une ligne d'en-tête sur toute la largeur (le
jour), puis ses lignes (horaire, poste)."""
title: str
rows: tuple[tuple[str, ...], ...]
@dataclass(frozen=True)
class BadgeTable:
"""Contenu d'un champ affiché en tableau plutôt qu'en texte libre : les
créneaux du bénévole regroupés par jour."""
sections: tuple[BadgeTableSection, ...]
@dataclass(frozen=True)
class BadgeFieldSpec:
"""Un champ déjà résolu en texte (plusieurs lignes séparées par `\\n`
@@ -55,7 +78,9 @@ class BadgeFieldSpec:
`is_photo` : le champ est la photo du bénévole (`image`, ses octets
bruts tels que stockés) plutôt qu'un texte — `text` et le style sont
alors ignorés. Sans photo (`image` à `None`), rien n'est dessiné."""
alors ignorés. Sans photo (`image` à `None`), rien n'est dessiné.
`table` : le champ est un tableau (`text` ignoré) — voir `_draw_table`."""
text: str
x_mm: float
@@ -68,6 +93,7 @@ class BadgeFieldSpec:
color: str # "#rrggbb"
is_photo: bool = False
image: bytes | None = None
table: BadgeTable | None = None
@dataclass(frozen=True)
@@ -162,6 +188,130 @@ def _draw_photo(
)
def wrap_text(text: str, font_name: str, font_size: float, max_width_pt: float) -> list[str]:
"""Découpe `text` en lignes qui tiennent dans `max_width_pt` : les `\\n`
du texte restent des retours à la ligne, puis chaque ligne trop longue
passe à la ligne entre deux mots — ou au milieu d'un mot qui, seul, ne
tient pas (nom très long dans un cadre étroit). Sans cela, un prénom +
nom ou une ligne de poste avec horaire débordait du cadre du champ."""
def fits(candidate: str) -> bool:
return stringWidth(candidate, font_name, font_size) <= max_width_pt
lines: list[str] = []
for paragraph in text.split("\n"):
current = ""
# `split(" ")` (et non `split()`) garde les doubles espaces voulus,
# ex. entre le nom du poste et son horaire.
for word in paragraph.split(" "):
candidate = f"{current} {word}" if current else word
if fits(candidate):
current = candidate
continue
if current:
lines.append(current.rstrip())
current = word
while current and not fits(current):
cut = len(current) - 1
while cut > 1 and not fits(current[:cut]):
cut -= 1
lines.append(current[:cut])
current = current[cut:]
lines.append(current.rstrip())
return lines
def _column_widths(natural: list[float], available: float) -> list[float]:
"""Largeurs des colonnes d'un tableau dans `available` : une colonne
courte (jour, horaire) garde sa largeur naturelle, les plus longues
(poste, référent) se partagent le reste à parts égales et passent à la
ligne. S'il reste de la place, la dernière colonne la prend."""
widths = [0.0] * len(natural)
pending = sorted(range(len(natural)), key=lambda i: natural[i])
remaining = available
while pending:
share = remaining / len(pending)
if natural[pending[0]] > share:
for i in pending:
widths[i] = share
return widths
i = pending.pop(0)
widths[i] = natural[i]
remaining -= natural[i]
widths[-1] += remaining
return widths
def _draw_table(
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
) -> None:
"""Tableau dans le cadre du champ : pour chaque section, une ligne
d'en-tête en gras sur toute la largeur (le jour), puis ses lignes ;
filets à la couleur du champ, texte de chaque cellule replié dans sa
colonne (`wrap_text`). Une ligne qui ne tient plus dans la hauteur du
cadre n'est pas dessinée, ni les suivantes — comme les lignes d'un champ
texte."""
table = f.table
if table is None or not table.sections:
return
regular, bold = "Helvetica", "Helvetica-Bold"
pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm
line_height = f.font_size * _LINE_HEIGHT_FACTOR
body = [row for section in table.sections for row in section.rows]
columns = max(len(row) for row in body) if body else 1
natural = [
max(stringWidth(row[col], regular, f.font_size) for row in body) + 2 * pad_x
for col in range(columns)
] if body else [0.0]
widths = _column_widths(natural, f.width_mm * mm)
total_width = sum(widths)
# (cellules, police, en-tête de section ?) — un en-tête n'a qu'une
# cellule, sur toute la largeur.
lines_to_draw = []
for section in table.sections:
lines_to_draw.append(((section.title,), bold, True))
lines_to_draw.extend((row, regular, False) for row in section.rows)
left = (x0_mm + f.x_mm) * mm
top = (y0_mm + badge_height_mm - f.y_mm) * mm
bottom_limit = top - f.height_mm * mm
color = HexColor(f.color)
c.setFillColor(color)
c.setStrokeColor(color)
c.setLineWidth(_TABLE_LINE_WIDTH_PT)
y = top
for cells, font, is_title in lines_to_draw:
cell_widths = [total_width] if is_title else widths
wrapped = [
wrap_text(text, font, f.font_size, max(1.0, cell_widths[col] - 2 * pad_x))
for col, text in enumerate(cells)
]
row_height = max(len(lines) for lines in wrapped) * line_height + 2 * pad_y
if y - row_height < bottom_limit - 0.01:
break # la ligne déborderait du cadre du champ
c.setFont(font, f.font_size)
x = left
for col, lines in enumerate(wrapped):
for i, line in enumerate(lines):
baseline = y - pad_y - f.font_size * 0.85 - i * line_height
c.drawString(x + pad_x, baseline, line)
x += cell_widths[col]
if not is_title:
# Séparations de colonnes, sur la hauteur de cette ligne seulement.
x = left
for width in widths[:-1]:
x += width
c.line(x, y, x, y - row_height)
y -= row_height
c.line(left, y, left + total_width, y)
if y == top:
return # pas même le premier en-tête ne tient
c.rect(left, y, total_width, top - y, stroke=1, fill=0)
def _draw_fields(
c: canvas.Canvas,
fields: tuple[BadgeFieldSpec, ...],
@@ -173,7 +323,11 @@ def _draw_fields(
if f.is_photo:
_draw_photo(c, f, x0_mm, y0_mm, badge_height_mm)
continue
c.setFont("Helvetica-Bold" if f.bold else "Helvetica", f.font_size)
if f.table is not None:
_draw_table(c, f, x0_mm, y0_mm, badge_height_mm)
continue
font_name = "Helvetica-Bold" if f.bold else "Helvetica"
c.setFont(font_name, f.font_size)
c.setFillColor(HexColor(f.color))
left_x = x0_mm + f.x_mm
# `f.font_size` est en points (unité reportlab) ; le reste du calcul
@@ -185,7 +339,8 @@ def _draw_fields(
# dessine depuis le BAS de la page.
top_y = y0_mm + badge_height_mm - f.y_mm
bottom_limit = top_y - f.height_mm
for i, line in enumerate(f.text.split("\n")):
lines = wrap_text(f.text, font_name, f.font_size, f.width_mm * mm)
for i, line in enumerate(lines):
baseline_y = top_y - font_size_mm * 0.85 - i * line_height_mm
if baseline_y < bottom_limit:
break # la ligne déborderait de la boîte du champ
+8
View File
@@ -65,6 +65,14 @@
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
"badge.role.referent": "Référent",
"badge.role.volunteer": "Bénévole",
"badge.table.day_label": "{weekday} {day}",
"badge.weekday.0": "Lundi",
"badge.weekday.1": "Mardi",
"badge.weekday.2": "Mercredi",
"badge.weekday.3": "Jeudi",
"badge.weekday.4": "Vendredi",
"badge.weekday.5": "Samedi",
"badge.weekday.6": "Dimanche",
"position.category.bar": "Bar",
"position.category.ticketing": "Billetterie",
"position.category.welcome": "Accueil",