Étape 20 : planches de badges bénévoles (PDF)

Recto personnalisable (fond image + champs positionnés : nom, poste(s)
affectés, catégorie, rôle) via un éditeur visuel glisser-déposer ; verso
fixe, la même image pour tous les bénévoles. Nouvel écran « Badges »
(COORDINATION uniquement, /badges).

Backend : modèle BadgeTemplate/BadgeField (migration 0011), génération
PDF isolée (app/badges/pdf.py, ReportLab — planches recto suivies des
planches verso correspondantes pour une impression recto-verso), route
POST /editions/{id}/badges/generate, tests (module pur + API).

Frontend : client HTTP (upload multipart, réponses binaires images/PDF),
client mock (mode démonstration), éditeur de positionnement en
millimètres (coordonnées partagées avec le backend), écran de génération
avec sélection des bénévoles et téléchargement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-09-20 20:05:03 +02:00
co-authored by Claude Sonnet 5
parent 4bcdafca96
commit b7fc80a7dc
25 changed files with 1800 additions and 3 deletions
+291
View File
@@ -0,0 +1,291 @@
"""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) — voir `app/badges/pdf.py` pour la composition du PDF et
`app/models/domain.py` (`BadgeTemplate`, `BadgeField`) pour le modèle.
"""
import uuid
from collections import defaultdict
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
from sqlalchemy import delete
from sqlmodel import Session, select
from app.api.deps import get_edition
from app.api.schemas import (
BadgeFieldRead,
BadgeFieldsSet,
BadgeGenerateRequest,
BadgeTemplateRead,
)
from app.auth.deps import require_coordinator
from app.badges.pdf import BadgeFieldSpec, BadgeSpec, generate_badge_sheets_pdf
from app.db import get_session
from app.i18n import t
from app.models import (
Account,
Assignment,
BadgeField,
BadgeFieldType,
BadgeTemplate,
Edition,
Position,
Role,
Shift,
Volunteer,
)
router = APIRouter(prefix="/editions/{edition_id}/badges", tags=["badges"])
_MAX_IMAGE_BYTES = 8 * 1024 * 1024
_ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg"}
# --------------------------------------------------------------------------- #
# Configuration (recto/verso, champs) #
# --------------------------------------------------------------------------- #
def _get_or_create_template(session: Session, edition_id: uuid.UUID) -> BadgeTemplate:
template = session.exec(
select(BadgeTemplate).where(BadgeTemplate.edition_id == edition_id)
).first()
if template is None:
template = BadgeTemplate(edition_id=edition_id)
session.add(template)
session.commit()
session.refresh(template)
return template
def _fields(session: Session, edition_id: uuid.UUID) -> list[BadgeField]:
return list(
session.exec(
select(BadgeField)
.where(BadgeField.edition_id == edition_id)
.order_by(BadgeField.field_type)
)
)
def _template_read(template: BadgeTemplate, fields: list[BadgeField]) -> BadgeTemplateRead:
return BadgeTemplateRead(
edition_id=template.edition_id,
has_front_image=template.front_image is not None,
has_back_image=template.back_image is not None,
fields=fields,
)
@router.get("/template", response_model=BadgeTemplateRead)
def get_template(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> BadgeTemplateRead:
template = _get_or_create_template(session, edition.id)
return _template_read(template, _fields(session, edition.id))
async def _read_image(file: UploadFile) -> tuple[bytes, str]:
content_type = file.content_type or ""
if content_type not in _ALLOWED_CONTENT_TYPES:
raise HTTPException(
status_code=422, detail={"code": "badge.image_type_not_supported"}
)
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail={"code": "badge.empty_image"})
if len(content) > _MAX_IMAGE_BYTES:
raise HTTPException(status_code=413, detail={"code": "badge.image_too_large"})
return content, content_type
@router.put("/template/front-image", response_model=BadgeTemplateRead)
async def set_front_image(
file: UploadFile = File(...),
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> BadgeTemplateRead:
template = _get_or_create_template(session, edition.id)
content, content_type = await _read_image(file)
template.front_image = content
template.front_image_content_type = content_type
session.add(template)
session.commit()
return _template_read(template, _fields(session, edition.id))
@router.put("/template/back-image", response_model=BadgeTemplateRead)
async def set_back_image(
file: UploadFile = File(...),
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> BadgeTemplateRead:
template = _get_or_create_template(session, edition.id)
content, content_type = await _read_image(file)
template.back_image = content
template.back_image_content_type = content_type
session.add(template)
session.commit()
return _template_read(template, _fields(session, edition.id))
@router.get("/template/front-image")
def get_front_image(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Response:
template = _get_or_create_template(session, edition.id)
if template.front_image is None:
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
return Response(
content=template.front_image,
media_type=template.front_image_content_type or "application/octet-stream",
)
@router.get("/template/back-image")
def get_back_image(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Response:
template = _get_or_create_template(session, edition.id)
if template.back_image is None:
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
return Response(
content=template.back_image,
media_type=template.back_image_content_type or "application/octet-stream",
)
@router.put("/template/fields", response_model=list[BadgeFieldRead])
def set_fields(
payload: BadgeFieldsSet,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> list[BadgeField]:
seen: set[BadgeFieldType] = set()
for f in payload.fields:
if f.field_type in seen:
raise HTTPException(
status_code=422,
detail={
"code": "badge.duplicate_field_type",
"params": {"field_type": f.field_type},
},
)
seen.add(f.field_type)
session.execute(delete(BadgeField).where(BadgeField.edition_id == edition.id))
for f in payload.fields:
session.add(BadgeField(edition_id=edition.id, **f.model_dump()))
session.commit()
return _fields(session, edition.id)
# --------------------------------------------------------------------------- #
# Génération du PDF #
# --------------------------------------------------------------------------- #
def _role_label(volunteer_id: uuid.UUID, lead_volunteer_ids: set[uuid.UUID]) -> str:
return t("badge.role.referent") if volunteer_id in lead_volunteer_ids else t("badge.role.volunteer")
def _category_label(category) -> str:
return t(f"position.category.{category.value.lower()}")
def _field_text(
field: BadgeField,
volunteer: Volunteer,
positions_by_volunteer: dict[uuid.UUID, list[Position]],
lead_volunteer_ids: set[uuid.UUID],
) -> str:
if field.field_type == BadgeFieldType.FULL_NAME:
return f"{volunteer.first_name} {volunteer.last_name}"
positions = positions_by_volunteer.get(volunteer.id, [])
if field.field_type == BadgeFieldType.POSITIONS:
names = sorted({p.name for p in positions})
return ", ".join(names)
if field.field_type == BadgeFieldType.CATEGORY:
categories = sorted({_category_label(p.category) for p in positions})
return ", ".join(categories)
if field.field_type == BadgeFieldType.ROLE:
return _role_label(volunteer.id, lead_volunteer_ids)
return ""
@router.post("/generate")
def generate_badges(
payload: BadgeGenerateRequest,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Response:
template = _get_or_create_template(session, edition.id)
fields = _fields(session, edition.id)
if not fields:
raise HTTPException(status_code=422, detail={"code": "badge.no_fields"})
query = select(Volunteer).where(Volunteer.edition_id == edition.id)
if payload.volunteer_ids is not None:
query = query.where(Volunteer.id.in_(payload.volunteer_ids))
volunteers = list(session.exec(query.order_by(Volunteer.last_name, Volunteer.first_name)))
if not volunteers:
raise HTTPException(status_code=422, detail={"code": "badge.no_volunteers"})
volunteer_ids = [v.id for v in volunteers]
rows = session.exec(
select(Assignment.volunteer_id, Position)
.join(Shift, Shift.id == Assignment.shift_id)
.join(Position, Position.id == Shift.position_id)
.where(Assignment.volunteer_id.in_(volunteer_ids))
).all()
positions_by_volunteer: dict[uuid.UUID, list[Position]] = defaultdict(list)
for volunteer_id, position in rows:
positions_by_volunteer[volunteer_id].append(position)
lead_volunteer_ids = {
account.volunteer_id
for account in session.exec(
select(Account).where(
Account.edition_id == edition.id,
Account.role == Role.LEAD,
Account.volunteer_id.in_(volunteer_ids),
)
)
if account.volunteer_id is not None
}
badges = [
BadgeSpec(
fields=tuple(
BadgeFieldSpec(
text=_field_text(field, volunteer, positions_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,
)
for field in fields
)
)
for volunteer in volunteers
]
pdf_bytes = generate_badge_sheets_pdf(badges, template.front_image, template.back_image)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": 'attachment; filename="badges.pdf"'},
)
+41
View File
@@ -7,6 +7,8 @@ from pydantic import BaseModel, ConfigDict, Field
from app.models import (
AssignmentSource,
BadgeAlign,
BadgeFieldType,
PositionCategory,
Role,
)
@@ -334,6 +336,45 @@ class MatrixResponse(ApiModel):
positions: list[MatrixPosition]
# --------------------------------------------------------------------------- #
# Badges (planches PDF) #
# --------------------------------------------------------------------------- #
class BadgeFieldIn(ApiModel):
field_type: BadgeFieldType
x_mm: float
y_mm: float
width_mm: float
height_mm: float
font_size: float = 10.0
align: BadgeAlign = BadgeAlign.LEFT
bold: bool = False
color: str = "#000000"
class BadgeFieldsSet(ApiModel):
"""Remplace l'intégralité des champs du recto — au plus un par
`field_type` (décocher un champ dans l'éditeur retire sa ligne)."""
fields: list[BadgeFieldIn] = Field(default_factory=list)
class BadgeFieldRead(BadgeFieldIn):
id: uuid.UUID
edition_id: uuid.UUID
class BadgeTemplateRead(ApiModel):
edition_id: uuid.UUID
has_front_image: bool
has_back_image: bool
fields: list[BadgeFieldRead]
class BadgeGenerateRequest(ApiModel):
# None/absent = tous les bénévoles de l'édition.
volunteer_ids: list[uuid.UUID] | None = None
# --------------------------------------------------------------------------- #
# Authentification et comptes #
# --------------------------------------------------------------------------- #
+3
View File
@@ -0,0 +1,3 @@
from app.badges.pdf import BadgeFieldSpec, BadgeSpec, generate_badge_sheets_pdf
__all__ = ["BadgeFieldSpec", "BadgeSpec", "generate_badge_sheets_pdf"]
+131
View File
@@ -0,0 +1,131 @@
"""Génération des planches de badges bénévoles (PDF).
Module isolé, fonction pure (`generate_badge_sheets_pdf`) : aucun accès base
ici — voir `app/api/badges.py` pour l'assemblage des données (bénévole →
champs résolus en texte).
Format badge : CR80 (85,6 × 54 mm — taille standard des badges/cartes
d'événement). Planche : grille sur page A4 portrait. Le verso est identique
pour tous les bénévoles : la planche imprime toutes les pages de recto
(personnalisées) puis, pour chacune, la page de verso correspondante (même
disposition, même image répétée à chaque emplacement rempli) — impression
recto-verso classique, page de verso n directement après la page de recto n.
"""
import io
from dataclasses import dataclass
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.pdfgen import canvas
BADGE_WIDTH_MM = 85.6
BADGE_HEIGHT_MM = 54.0
_MARGIN_MM = 10.0
_GUTTER_MM = 4.0
_PAGE_WIDTH_MM = A4[0] / mm
_PAGE_HEIGHT_MM = A4[1] / mm
@dataclass(frozen=True)
class BadgeFieldSpec:
"""Un champ déjà résolu en texte, positionné depuis le coin haut-gauche
du badge (comme dans l'éditeur visuel du front-end)."""
text: str
x_mm: float
y_mm: float
width_mm: float
height_mm: float
font_size: float
align: str # "LEFT" | "CENTER" | "RIGHT"
bold: bool
color: str # "#rrggbb"
@dataclass(frozen=True)
class BadgeSpec:
"""Un badge = un bénévole, avec ses champs déjà résolus."""
fields: tuple[BadgeFieldSpec, ...]
def _grid_size() -> tuple[int, int]:
usable_w = _PAGE_WIDTH_MM - 2 * _MARGIN_MM
usable_h = _PAGE_HEIGHT_MM - 2 * _MARGIN_MM
cols = max(1, int((usable_w + _GUTTER_MM) // (BADGE_WIDTH_MM + _GUTTER_MM)))
rows = max(1, int((usable_h + _GUTTER_MM) // (BADGE_HEIGHT_MM + _GUTTER_MM)))
return cols, rows
def _cell_origin(index: int, cols: int) -> tuple[float, float]:
"""Coin bas-gauche (repère PDF) de l'emplacement `index` sur la page,
en mm depuis le coin bas-gauche de la page."""
col, row = index % cols, index // cols
x0 = _MARGIN_MM + col * (BADGE_WIDTH_MM + _GUTTER_MM)
y0 = _PAGE_HEIGHT_MM - _MARGIN_MM - (row + 1) * BADGE_HEIGHT_MM - row * _GUTTER_MM
return x0, y0
def _draw_background(c: canvas.Canvas, image: bytes | None, x0_mm: float, y0_mm: float) -> None:
if not image:
return
c.drawImage(
ImageReader(io.BytesIO(image)),
x0_mm * mm,
y0_mm * mm,
width=BADGE_WIDTH_MM * mm,
height=BADGE_HEIGHT_MM * mm,
preserveAspectRatio=False,
mask="auto",
)
def _draw_fields(
c: canvas.Canvas, fields: tuple[BadgeFieldSpec, ...], x0_mm: float, y0_mm: float
) -> None:
for f in fields:
c.setFont("Helvetica-Bold" if f.bold else "Helvetica", f.font_size)
c.setFillColor(HexColor(f.color))
left_x = x0_mm + f.x_mm
# `f.y_mm` est depuis le HAUT du badge (repère écran) ; reportlab
# dessine depuis le BAS de la page. On approxime un alignement en
# haut de la boîte plutôt qu'un centrage vertical exact.
baseline_y = y0_mm + BADGE_HEIGHT_MM - f.y_mm - f.font_size * 0.8
if f.align == "CENTER":
c.drawCentredString((left_x + f.width_mm / 2) * mm, baseline_y * mm, f.text)
elif f.align == "RIGHT":
c.drawRightString((left_x + f.width_mm) * mm, baseline_y * mm, f.text)
else:
c.drawString(left_x * mm, baseline_y * mm, f.text)
def generate_badge_sheets_pdf(
badges: list[BadgeSpec],
front_image: bytes | None,
back_image: bytes | None,
) -> bytes:
"""`badges` : un par bénévole, dans l'ordre d'impression. Retourne les
octets du PDF (recto puis verso, planche par planche)."""
cols, rows = _grid_size()
per_page = cols * rows
pages = [badges[i : i + per_page] for i in range(0, len(badges), per_page)]
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=A4)
for page_badges in pages:
for i, badge in enumerate(page_badges):
x0, y0 = _cell_origin(i, cols)
_draw_background(c, front_image, x0, y0)
_draw_fields(c, badge.fields, x0, y0)
c.showPage()
for i in range(len(page_badges)):
x0, y0 = _cell_origin(i, cols)
_draw_background(c, back_image, x0, y0)
c.showPage()
c.save()
return buffer.getvalue()
+18 -1
View File
@@ -54,5 +54,22 @@
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
"email.planning.shift_line": "- {position} : {start} → {end}",
"availability.date_outside_edition": "La date {date} est hors des dates de l'édition.",
"availability.empty_window": "Une disponibilité doit avoir une heure de début et de fin différentes."
"availability.empty_window": "Une disponibilité doit avoir une heure de début et de fin différentes.",
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
"badge.empty_image": "L'image est vide.",
"badge.image_too_large": "L'image est trop volumineuse.",
"badge.duplicate_field_type": "Le champ « {field_type} » ne peut apparaître qu'une fois.",
"badge.no_fields": "Aucun champ n'est configuré sur le recto du badge.",
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
"badge.role.referent": "Référent",
"badge.role.volunteer": "Bénévole",
"position.category.bar": "Bar",
"position.category.ticketing": "Billetterie",
"position.category.welcome": "Accueil",
"position.category.parking": "Parking",
"position.category.cleaning": "Propreté",
"position.category.catering": "Catering",
"position.category.setup": "Montage",
"position.category.stage": "Régie",
"position.category.other": "Autre"
}
+2
View File
@@ -8,6 +8,7 @@ from app.api.artists import router as artist_router
from app.api.assignments import router as assignment_router
from app.api.auth import router as auth_router
from app.api.availability import router as availability_router
from app.api.badges import router as badge_router
from app.api.day_parts import router as day_part_router
from app.api.editions import router as edition_router
from app.api.health import router as health_router
@@ -61,3 +62,4 @@ app.include_router(assignment_router)
app.include_router(solver_router)
app.include_router(matrix_router)
app.include_router(notification_router)
app.include_router(badge_router)
@@ -0,0 +1,82 @@
"""Planches de badges bénévoles : recto/verso et champs positionnés
Revision ID: 0011_badge_template
Revises: 0010_companion_wish
Create Date: 2026-09-20
Deux tables : `badge_template` (images recto/verso, une ligne par édition) et
`badge_field` (champs positionnés sur le recto — nom, poste(s), catégorie,
rôle). Voir `app/badges/pdf.py` pour la génération des planches PDF.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0011_badge_template"
down_revision: str | None = "0010_companion_wish"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"badge_template",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("edition_id", sa.Uuid(), nullable=False),
sa.Column("front_image", sa.LargeBinary(), nullable=True),
sa.Column("front_image_content_type", sa.String(), nullable=True),
sa.Column("back_image", sa.LargeBinary(), nullable=True),
sa.Column("back_image_content_type", sa.String(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("edition_id"),
)
op.create_table(
"badge_field",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("edition_id", sa.Uuid(), nullable=False),
sa.Column(
"field_type",
sa.Enum(
"FULL_NAME", "POSITIONS", "CATEGORY", "ROLE",
name="badgefieldtype", native_enum=False,
),
nullable=False,
),
sa.Column("x_mm", sa.Float(), nullable=False),
sa.Column("y_mm", sa.Float(), nullable=False),
sa.Column("width_mm", sa.Float(), nullable=False),
sa.Column("height_mm", sa.Float(), nullable=False),
sa.Column("font_size", sa.Float(), nullable=False),
sa.Column(
"align",
sa.Enum(
"LEFT", "CENTER", "RIGHT", name="badgealign", native_enum=False
),
nullable=False,
server_default="LEFT",
),
sa.Column(
"bold", sa.Boolean(), server_default="false", nullable=False
),
sa.Column("color", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"edition_id", "field_type", name="uq_badge_field_edition_type"
),
)
op.create_index("ix_badge_field_edition_id", "badge_field", ["edition_id"])
def downgrade() -> None:
op.drop_index("ix_badge_field_edition_id", table_name="badge_field")
op.drop_table("badge_field")
op.drop_table("badge_template")
+8
View File
@@ -10,6 +10,8 @@ from app.models.domain import (
Artist,
Assignment,
Availability,
BadgeField,
BadgeTemplate,
DayPart,
Edition,
MagicLinkToken,
@@ -22,6 +24,8 @@ from app.models.domain import (
)
from app.models.enums import (
AssignmentSource,
BadgeAlign,
BadgeFieldType,
PositionCategory,
Role,
)
@@ -32,6 +36,10 @@ __all__ = [
"Assignment",
"AssignmentSource",
"Availability",
"BadgeAlign",
"BadgeField",
"BadgeFieldType",
"BadgeTemplate",
"DayPart",
"Edition",
"MagicLinkToken",
+65 -1
View File
@@ -9,13 +9,15 @@ au niveau base (`ondelete="CASCADE"`) ; les relations ORM utilisent
import datetime as dt
import uuid
from sqlalchemy import Boolean, CheckConstraint, Column, Integer, Time
from sqlalchemy import Boolean, CheckConstraint, Column, Integer, LargeBinary, Time
from sqlalchemy import Enum as SAEnum
from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
from app.models.base import TimestampMixin
from app.models.enums import (
AssignmentSource,
BadgeAlign,
BadgeFieldType,
PositionCategory,
Role,
)
@@ -364,3 +366,65 @@ class Assignment(TimestampMixin, table=True):
volunteer: Volunteer = Relationship(back_populates="assignments")
shift: Shift = Relationship(back_populates="assignments")
# --------------------------------------------------------------------------- #
# Badges (planches recto/verso, voir app/badges/) #
# --------------------------------------------------------------------------- #
class BadgeTemplate(TimestampMixin, table=True):
"""Recto/verso du badge d'une édition. Une ligne par édition (créée à la
demande). Le verso (`back_image`) est le même pour tous les bénévoles ;
le recto combine `front_image` (fond) et les `BadgeField` positionnés
dessus. Images stockées en base (pas de volume de fichiers à gérer)."""
__tablename__ = "badge_template"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
edition_id: uuid.UUID = Field(
foreign_key="edition.id", unique=True, ondelete="CASCADE"
)
front_image: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
front_image_content_type: str | None = None
back_image: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
back_image_content_type: str | None = None
class BadgeField(TimestampMixin, table=True):
"""Un champ positionné sur le recto du badge (nom, poste(s), catégorie,
rôle — voir `BadgeFieldType`). Coordonnées en millimètres depuis le coin
haut-gauche du badge (format CR80, 85,6 × 54 mm — voir `app/badges/pdf.py`).
Au plus un champ par `field_type` et par édition : décocher un champ dans
l'éditeur retire sa ligne (remplacement complet, `PUT .../fields`)."""
__tablename__ = "badge_field"
__table_args__ = (
UniqueConstraint(
"edition_id", "field_type", name="uq_badge_field_edition_type"
),
)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
edition_id: uuid.UUID = Field(
foreign_key="edition.id", index=True, ondelete="CASCADE"
)
field_type: BadgeFieldType = Field(
sa_column=Column(SAEnum(BadgeFieldType, native_enum=False), nullable=False)
)
x_mm: float
y_mm: float
width_mm: float
height_mm: float
font_size: float = Field(default=10.0)
align: BadgeAlign = Field(
default=BadgeAlign.LEFT,
sa_column=Column(
SAEnum(BadgeAlign, native_enum=False),
nullable=False,
server_default=BadgeAlign.LEFT.value,
),
)
bold: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="false"),
)
color: str = Field(default="#000000")
+15
View File
@@ -28,3 +28,18 @@ class PositionCategory(enum.StrEnum):
SETUP = "SETUP"
STAGE = "STAGE"
OTHER = "OTHER"
class BadgeFieldType(enum.StrEnum):
"""Champ affichable sur le recto d'un badge (voir `app/badges/`)."""
FULL_NAME = "FULL_NAME"
POSITIONS = "POSITIONS"
CATEGORY = "CATEGORY"
ROLE = "ROLE"
class BadgeAlign(enum.StrEnum):
LEFT = "LEFT"
CENTER = "CENTER"
RIGHT = "RIGHT"
+2
View File
@@ -15,6 +15,8 @@ dependencies = [
"pyjwt>=2.9",
"bcrypt>=4.1",
"ortools>=9.11",
"reportlab>=4.2",
"pillow>=11.0",
]
[dependency-groups]
+162
View File
@@ -0,0 +1,162 @@
"""Configuration et génération des planches de badges (API)."""
import base64
from app.models import Role
from tests.factories import dt_at, make_position, make_shift, make_volunteer
# PNG 1×1 valide (transparent), le plus petit possible.
_PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY"
"42YAAAAASUVORK5CYII="
)
def _badges_url(edition):
return f"/editions/{edition.id}/badges"
def _set_full_name_field(client, edition):
return client.put(
f"{_badges_url(edition)}/template/fields",
json={
"fields": [
{
"field_type": "FULL_NAME",
"x_mm": 5,
"y_mm": 5,
"width_mm": 60,
"height_mm": 10,
"font_size": 12,
}
]
},
)
def test_template_is_coordinator_only(edition, make_account, as_account):
lead = make_account(Role.LEAD, email="lead@e.fr")
client = as_account(lead)
assert client.get(f"{_badges_url(edition)}/template").status_code == 403
resp = client.put(f"{_badges_url(edition)}/template/fields", json={"fields": []})
assert resp.status_code == 403
def test_template_starts_empty(client, edition):
resp = client.get(f"{_badges_url(edition)}/template")
assert resp.status_code == 200
body = resp.json()
assert body["has_front_image"] is False
assert body["has_back_image"] is False
assert body["fields"] == []
def test_uploading_front_and_back_images(client, edition):
front = client.put(
f"{_badges_url(edition)}/template/front-image",
files={"file": ("front.png", _PNG, "image/png")},
)
assert front.status_code == 200
assert front.json()["has_front_image"] is True
back = client.put(
f"{_badges_url(edition)}/template/back-image",
files={"file": ("back.png", _PNG, "image/png")},
)
assert back.status_code == 200
assert back.json()["has_back_image"] is True
served = client.get(f"{_badges_url(edition)}/template/front-image")
assert served.status_code == 200
assert served.headers["content-type"] == "image/png"
assert served.content == _PNG
def test_uploading_an_unsupported_image_type_is_rejected(client, edition):
resp = client.put(
f"{_badges_url(edition)}/template/front-image",
files={"file": ("front.gif", b"not-a-real-gif", "image/gif")},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "badge.image_type_not_supported"
def test_setting_the_same_field_type_twice_is_rejected(client, edition):
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={
"fields": [
{
"field_type": "FULL_NAME", "x_mm": 0, "y_mm": 0,
"width_mm": 10, "height_mm": 10,
},
{
"field_type": "FULL_NAME", "x_mm": 5, "y_mm": 5,
"width_mm": 10, "height_mm": 10,
},
]
},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "badge.duplicate_field_type"
def test_generating_without_any_field_configured_is_rejected(client, edition, session):
make_volunteer(session, edition, first_name="Alex", last_name="Martin")
resp = client.post(f"{_badges_url(edition)}/generate", json={})
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "badge.no_fields"
def test_generating_with_no_matching_volunteers_is_rejected(client, edition, session):
_set_full_name_field(client, edition)
make_volunteer(session, edition, first_name="Alex", last_name="Martin")
resp = client.post(
f"{_badges_url(edition)}/generate",
json={"volunteer_ids": ["00000000-0000-0000-0000-000000000000"]},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "badge.no_volunteers"
def test_generating_produces_a_pdf(client, edition, session):
from app.models import Assignment
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={
"fields": [
{
"field_type": "FULL_NAME", "x_mm": 5, "y_mm": 5,
"width_mm": 60, "height_mm": 10, "font_size": 12,
},
{
"field_type": "POSITIONS", "x_mm": 5, "y_mm": 20,
"width_mm": 60, "height_mm": 10, "font_size": 9,
},
]
},
)
assert resp.status_code == 200
client.put(
f"{_badges_url(edition)}/template/front-image",
files={"file": ("front.png", _PNG, "image/png")},
)
client.put(
f"{_badges_url(edition)}/template/back-image",
files={"file": ("back.png", _PNG, "image/png")},
)
volunteer = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
position = make_position(session, edition, name="Bar central")
shift = make_shift(session, edition, position, start=dt_at(9, 18), end=dt_at(9, 22))
session.add(Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id))
session.commit()
resp = client.post(
f"{_badges_url(edition)}/generate",
json={"volunteer_ids": [str(volunteer.id)]},
)
assert resp.status_code == 200
assert resp.headers["content-type"] == "application/pdf"
assert resp.content.startswith(b"%PDF")
+62
View File
@@ -0,0 +1,62 @@
"""Génération des planches de badges (module pur, sans base)."""
from app.badges.pdf import (
BadgeFieldSpec,
BadgeSpec,
_cell_origin,
_grid_size,
generate_badge_sheets_pdf,
)
def _badge(text="Alex Martin") -> BadgeSpec:
return BadgeSpec(
fields=(
BadgeFieldSpec(
text=text,
x_mm=5,
y_mm=5,
width_mm=60,
height_mm=10,
font_size=12,
align="LEFT",
bold=True,
color="#000000",
),
)
)
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)
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)
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)
def test_generates_a_valid_pdf_for_a_single_badge():
pdf = generate_badge_sheets_pdf([_badge()], front_image=None, back_image=None)
assert pdf.startswith(b"%PDF")
assert pdf.rstrip().endswith(b"%%EOF")
def test_more_badges_than_one_sheet_produces_a_larger_pdf():
cols, rows = _grid_size()
per_page = cols * rows
one_page = generate_badge_sheets_pdf(
[_badge() for _ in range(per_page)], front_image=None, back_image=None
)
# Un badge de plus déborde sur une 2e planche (recto + verso en plus).
two_pages = generate_badge_sheets_pdf(
[_badge() for _ in range(per_page + 1)], front_image=None, back_image=None
)
assert len(two_pages) > len(one_page)
+2
View File
@@ -27,6 +27,8 @@ EXPECTED_TABLES = {
"shift",
"availability",
"assignment",
"badge_template",
"badge_field",
}