Refonte du créateur de badges et nombre de badges par bénévole
ci-cd / build-backend (push) Successful in 30s
ci-cd / deploy (push) Successful in 13s
ci-cd / build-frontend (push) Successful in 22s

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:
AdamThomas
2026-10-27 19:20:42 +01:00
co-authored by Claude Opus 5.5
parent 1a23881e81
commit ced0855d47
28 changed files with 3738 additions and 803 deletions
+60 -36
View File
@@ -1,15 +1,16 @@
"""Planches de badges bénévoles : configuration du recto/verso (COORDINATION)
et génération du PDF.
Le verso est une image fixe, la même pour tous les bénévoles. Le recto
combine une image de fond et des champs positionnés (nom, poste(s) affectés,
catégorie, rôle, photo, texte libre de la fiche) — voir `app/badges/pdf.py`
pour la composition du PDF et `app/models/domain.py` (`BadgeTemplate`,
Chaque face (recto, verso) combine une image de fond (la même pour tous les
bénévoles) et des champs positionnés (nom, poste(s) affectés, catégorie,
rôle, photo, texte libre de la fiche) — voir `app/badges/pdf.py` pour la
composition du PDF et `app/models/domain.py` (`BadgeTemplate`,
`BadgeField`) pour le modèle.
"""
import datetime as dt
import io
import re
import uuid
from collections import defaultdict
@@ -36,12 +37,14 @@ from app.badges.pdf import (
BadgeTableSection,
generate_badge_sheets_pdf,
image_size_mm,
initials,
)
from app.db import get_session
from app.i18n import t
from app.models import (
Account,
Assignment,
BadgeFace,
BadgeField,
BadgeFieldType,
BadgeTemplate,
@@ -58,6 +61,9 @@ router = APIRouter(prefix="/editions/{edition_id}/badges", tags=["badges"])
_MAX_IMAGE_BYTES = 8 * 1024 * 1024
_ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg"}
_HEX_COLOR = re.compile(r"#[0-9a-fA-F]{6}")
# Champs centrés verticalement dans leur cadre (les autres partent du haut).
_MIDDLE_ALIGNED = {BadgeFieldType.FREE_TEXT, BadgeFieldType.ROLE}
# --------------------------------------------------------------------------- #
@@ -80,7 +86,7 @@ def _fields(session: Session, edition_id: uuid.UUID) -> list[BadgeField]:
session.exec(
select(BadgeField)
.where(BadgeField.edition_id == edition_id)
.order_by(BadgeField.field_type)
.order_by(BadgeField.face, BadgeField.field_type)
)
)
@@ -206,9 +212,11 @@ def set_fields(
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> list[BadgeField]:
seen: set[BadgeFieldType] = set()
# Au plus un champ de chaque type par face ; le même sur les deux faces
# est permis.
seen: set[tuple[BadgeFace, BadgeFieldType]] = set()
for f in payload.fields:
if f.field_type in seen:
if (f.face, f.field_type) in seen:
raise HTTPException(
status_code=422,
detail={
@@ -216,7 +224,12 @@ def set_fields(
"params": {"field_type": f.field_type},
},
)
seen.add(f.field_type)
seen.add((f.face, f.field_type))
if f.bg_color is not None and not _HEX_COLOR.fullmatch(f.bg_color):
raise HTTPException(
status_code=422,
detail={"code": "badge.invalid_color", "params": {"color": f.bg_color}},
)
session.execute(delete(BadgeField).where(BadgeField.edition_id == edition.id))
for f in payload.fields:
@@ -297,7 +310,11 @@ def _field_text(
"""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}"
# Prénom puis nom, chacun sur sa ligne.
return f"{volunteer.first_name}\n{volunteer.last_name}"
if field.field_type == BadgeFieldType.PHOTO:
# Imprimées à la place d'une photo absente ou illisible.
return initials(volunteer.first_name, volunteer.last_name)
if field.field_type == BadgeFieldType.FREE_TEXT:
# Texte commun saisi dans l'éditeur, remplacé pour un bénévole par
# le « Texte du badge » de sa fiche s'il en a un.
@@ -359,35 +376,42 @@ def generate_badges(
if account.volunteer_id is not None
}
badges = [
BadgeSpec(
fields=tuple(
BadgeFieldSpec(
text=_field_text(
field, volunteer, assignments_by_volunteer, lead_volunteer_ids
),
x_mm=field.x_mm,
y_mm=field.y_mm,
width_mm=field.width_mm,
height_mm=field.height_mm,
font_size=field.font_size,
align=field.align.value,
bold=field.bold,
color=field.color,
is_photo=field.field_type == BadgeFieldType.PHOTO,
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
)
def spec(field: BadgeField, volunteer: Volunteer) -> BadgeFieldSpec:
return BadgeFieldSpec(
text=_field_text(field, volunteer, assignments_by_volunteer, lead_volunteer_ids),
x_mm=field.x_mm,
y_mm=field.y_mm,
width_mm=field.width_mm,
height_mm=field.height_mm,
font_size=field.font_size,
align=field.align.value,
bold=field.bold,
color=field.color,
is_photo=field.field_type == BadgeFieldType.PHOTO,
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,
bg_color=field.bg_color,
auto_fit=field.auto_fit,
valign="MIDDLE" if field.field_type in _MIDDLE_ALIGNED else "TOP",
photo_shape=field.photo_shape.value,
)
front_fields = [f for f in fields if f.face == BadgeFace.FRONT]
back_fields = [f for f in fields if f.face == BadgeFace.BACK]
# Un badge par exemplaire demandé sur la fiche (`Volunteer.badge_count`),
# à la suite les uns des autres.
badges = [
badge
for volunteer in volunteers
for badge in [
BadgeSpec(
fields=tuple(spec(field, volunteer) for field in front_fields),
back_fields=tuple(spec(field, volunteer) for field in back_fields),
)
]
* volunteer.badge_count
]
pdf_bytes = generate_badge_sheets_pdf(