É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"'},
)