Badges : photo du bénévole et texte libre

Champ « Photo du bénévole » (PHOTO) : photo du formulaire recadrée au
cadre, orientation EXIF appliquée, réduite à 300 dpi ; cadre vide sans
photo. Champ « Texte libre » (FREE_TEXT) alimenté par le nouveau « Texte
du badge » de la fiche bénévole (Volunteer.badge_text, migration 0024),
repris dans l'export Excel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-27 18:12:09 +01:00
co-authored by Claude Opus 5.5
parent 5b493c9737
commit 54cba467fc
17 changed files with 325 additions and 33 deletions
+9 -2
View File
@@ -3,8 +3,9 @@ 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) — voir `app/badges/pdf.py` pour la composition du PDF et
`app/models/domain.py` (`BadgeTemplate`, `BadgeField`) pour le modèle.
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 io
@@ -278,6 +279,8 @@ def _field_text(
) -> str:
if field.field_type == BadgeFieldType.FULL_NAME:
return f"{volunteer.first_name} {volunteer.last_name}"
if field.field_type == BadgeFieldType.FREE_TEXT:
return volunteer.badge_text or ""
assignments = assignments_by_volunteer.get(volunteer.id, [])
if field.field_type == BadgeFieldType.POSITIONS:
return "\n".join(
@@ -355,6 +358,10 @@ def generate_badges(
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,
)
for field in fields
)
+3
View File
@@ -74,6 +74,8 @@ class VolunteerUpdate(ApiModel):
# Issus du formulaire public (`app/api/forms.py`), éditables ensuite
# depuis la fiche — voir CLAUDE.md § 2 (Volunteer).
companion_wish_note: str | None = None
# Texte libre du badge (champ `FREE_TEXT`), saisi par la coordination.
badge_text: str | None = None
self_declared_available: bool | None = None
willing_to_pay_fee: bool | None = None
@@ -91,6 +93,7 @@ class VolunteerRead(ApiModel):
wished_companion_ids: list[uuid.UUID]
has_photo: bool
companion_wish_note: str | None
badge_text: str | None
self_declared_available: bool
willing_to_pay_fee: bool
# Paiement de la cotisation via SumUp (voir CLAUDE.md § 2, `Volunteer`) —
+52 -1
View File
@@ -18,6 +18,7 @@ recto-verso classique, page de verso n directement après la page de recto n.
import io
from dataclasses import dataclass
from PIL import Image, ImageOps, UnidentifiedImageError
from reportlab.lib.colors import HexColor
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
@@ -50,7 +51,11 @@ def image_size_mm(width_px: int, height_px: int, dpi: float = ASSUMED_DPI) -> tu
class BadgeFieldSpec:
"""Un champ déjà résolu en texte (plusieurs lignes séparées par `\\n`
possibles — ex. un poste par ligne avec son horaire), positionné depuis
le coin haut-gauche du badge (comme dans l'éditeur visuel du front-end)."""
le coin haut-gauche du badge (comme dans l'éditeur visuel du front-end).
`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é."""
text: str
x_mm: float
@@ -61,6 +66,8 @@ class BadgeFieldSpec:
align: str # "LEFT" | "CENTER" | "RIGHT"
bold: bool
color: str # "#rrggbb"
is_photo: bool = False
image: bytes | None = None
@dataclass(frozen=True)
@@ -114,6 +121,47 @@ def _draw_background(
)
def _fit_photo(image: bytes, width_mm: float, height_mm: float) -> bytes | None:
"""Recadre la photo au ratio du cadre (centrée, sans déformation — une
photo de téléphone n'a jamais le ratio du cadre) et la réduit à la
résolution d'impression : une photo de plusieurs Mo répétée sur 200
badges alourdirait inutilement le PDF. Applique l'orientation EXIF (une
photo prise en portrait au téléphone est souvent stockée couchée).
`None` si l'image est illisible : le badge s'imprime sans photo plutôt
que de faire échouer toute la planche."""
target = (
max(1, round(width_mm / 25.4 * ASSUMED_DPI)),
max(1, round(height_mm / 25.4 * ASSUMED_DPI)),
)
try:
with Image.open(io.BytesIO(image)) as img:
fitted = ImageOps.fit(ImageOps.exif_transpose(img).convert("RGB"), target)
except (UnidentifiedImageError, OSError):
return None
out = io.BytesIO()
fitted.save(out, format="JPEG", quality=88)
return out.getvalue()
def _draw_photo(
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
) -> None:
if not f.image:
return
photo = _fit_photo(f.image, f.width_mm, f.height_mm)
if photo is None:
return
c.drawImage(
ImageReader(io.BytesIO(photo)),
(x0_mm + f.x_mm) * mm,
# `f.y_mm` est le haut du cadre depuis le HAUT du badge ; reportlab
# place l'image par son coin bas-gauche, depuis le BAS de la page.
(y0_mm + badge_height_mm - f.y_mm - f.height_mm) * mm,
width=f.width_mm * mm,
height=f.height_mm * mm,
)
def _draw_fields(
c: canvas.Canvas,
fields: tuple[BadgeFieldSpec, ...],
@@ -122,6 +170,9 @@ def _draw_fields(
badge_height_mm: float,
) -> None:
for f in 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)
c.setFillColor(HexColor(f.color))
left_x = x0_mm + f.x_mm
+2
View File
@@ -36,6 +36,7 @@ _HEADER = [
"Mécénat — code postal",
"Mécénat — pays",
"Commentaire",
"Texte du badge",
"Inscrit le",
]
@@ -123,6 +124,7 @@ def generate_volunteers_xlsx(
_safe_text(v.mecenat_postal_code or ""),
_safe_text(v.mecenat_country or ""),
_safe_text(v.comment or ""),
_safe_text(v.badge_text or ""),
_fmt_dt(v.created_at),
]
)
@@ -0,0 +1,30 @@
"""Texte libre du badge
Revision ID: 0024_volunteer_badge_text
Revises: 0023_fee_transaction
Create Date: 2026-10-27
`volunteer.badge_text` : texte propre à chaque bénévole, saisi par la
coordination sur sa fiche et imprimé sur le badge par le champ
`FREE_TEXT`. Les nouveaux types de champ de badge (`PHOTO`, `FREE_TEXT`)
tiennent dans la colonne `badge_field.field_type` existante (VARCHAR sans
contrainte CHECK, longueur 9 inchangée) : rien à migrer de ce côté.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0024_volunteer_badge_text"
down_revision: str | None = "0023_fee_transaction"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("volunteer", sa.Column("badge_text", sa.String(), nullable=True))
def downgrade() -> None:
op.drop_column("volunteer", "badge_text")
+3
View File
@@ -183,6 +183,9 @@ class Volunteer(TimestampMixin, table=True):
# cette note sur la fiche et la transforme, si besoin, en un vrai
# `VolunteerCompanionWish` une fois les deux bénévoles inscrits.
companion_wish_note: str | None = None
# Texte libre imprimé sur le badge (champ `BadgeFieldType.FREE_TEXT`),
# saisi par la coordination sur la fiche — ex. « Accès backstage ».
badge_text: str | None = None
# Cases à cocher du formulaire public — purement informatives : aucune
# logique n'y est attachée (pas de workflow de paiement).
self_declared_available: bool = Field(
+5
View File
@@ -37,6 +37,11 @@ class BadgeFieldType(enum.StrEnum):
POSITIONS = "POSITIONS"
CATEGORY = "CATEGORY"
ROLE = "ROLE"
# Photo du bénévole (`Volunteer.photo`), ajustée au cadre du champ.
PHOTO = "PHOTO"
# Texte libre propre à chaque bénévole, saisi par la coordination sur sa
# fiche (`Volunteer.badge_text`).
FREE_TEXT = "FREE_TEXT"
class BadgeAlign(enum.StrEnum):