É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:
co-authored by
Claude Sonnet 5
parent
4bcdafca96
commit
b7fc80a7dc
@@ -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"'},
|
||||
)
|
||||
Reference in New Issue
Block a user