Files
benevoles/backend/app/api/badges.py
T
AdamThomasandClaude Opus 5.5 1a23881e81
ci-cd / build-backend (push) Successful in 37s
ci-cd / build-frontend (push) Successful in 22s
ci-cd / deploy (push) Successful in 13s
Badges : retour à la ligne et créneaux en tableau par jour
Le texte des champs passe à la ligne pour tenir dans la largeur de son
cadre (wrap_text) au lieu de déborder. Les créneaux affectés s'affichent
en tableau : le jour de festival en en-tête, puis une colonne horaire et
une colonne poste (plus de référent sur le badge) ; même tableau dans
l'aperçu de l'éditeur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:44:55 +01:00

405 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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`,
`BadgeField`) pour le modèle.
"""
import datetime as dt
import io
import uuid
from collections import defaultdict
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
from PIL import Image, UnidentifiedImageError
from sqlalchemy import delete
from sqlmodel import Session, select
from app.api.deps import get_edition
from app.api.schemas import (
BadgeFieldRead,
BadgeFieldsSet,
BadgeGenerateRequest,
BadgeSizeUpdate,
BadgeTemplateRead,
)
from app.auth.deps import require_coordinator
from app.badges.pdf import (
DEFAULT_BADGE_HEIGHT_MM,
DEFAULT_BADGE_WIDTH_MM,
BadgeFieldSpec,
BadgeSpec,
BadgeTable,
BadgeTableSection,
generate_badge_sheets_pdf,
image_size_mm,
)
from app.db import get_session
from app.i18n import t
from app.models import (
Account,
Assignment,
BadgeField,
BadgeFieldType,
BadgeTemplate,
DayPart,
Edition,
Position,
Role,
Shift,
Volunteer,
)
from app.validation.engine import day_boundary, festival_day
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,
badge_width_mm=template.front_image_width_mm or DEFAULT_BADGE_WIDTH_MM,
badge_height_mm=template.front_image_height_mm or DEFAULT_BADGE_HEIGHT_MM,
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
def _pixel_size(content: bytes) -> tuple[int, int]:
try:
with Image.open(io.BytesIO(content)) as img:
return img.size
except UnidentifiedImageError as exc:
raise HTTPException(
status_code=422, detail={"code": "badge.image_unreadable"}
) from exc
@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:
"""Le format du badge (`front_image_width_mm`/`height_mm`) est dérivé de
cette image (voir `app/badges/pdf.py:image_size_mm`) : le badge prend
désormais la forme de l'image importée plutôt qu'un format fixe imposé."""
template = _get_or_create_template(session, edition.id)
content, content_type = await _read_image(file)
width_px, height_px = _pixel_size(content)
width_mm, height_mm = image_size_mm(width_px, height_px)
template.front_image = content
template.front_image_content_type = content_type
template.front_image_width_mm = width_mm
template.front_image_height_mm = height_mm
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)
@router.put("/template/size", response_model=BadgeTemplateRead)
def set_size(
payload: BadgeSizeUpdate,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> BadgeTemplateRead:
"""Redimensionne le badge indépendamment de l'image (ex. une image
importée à basse résolution donne un format minuscule à 300 dpi
supposés — l'utilisateur l'agrandit ici). Écrase la taille déduite de
l'image ; ré-importer l'image la recalcule à nouveau depuis ses pixels
(`set_front_image`)."""
if payload.badge_width_mm <= 0 or payload.badge_height_mm <= 0:
raise HTTPException(status_code=422, detail={"code": "badge.invalid_size"})
template = _get_or_create_template(session, edition.id)
template.front_image_width_mm = payload.badge_width_mm
template.front_image_height_mm = payload.badge_height_mm
session.add(template)
session.commit()
return _template_read(template, _fields(session, edition.id))
# --------------------------------------------------------------------------- #
# Génération du PDF #
# --------------------------------------------------------------------------- #
AssignmentRow = tuple[Position, Shift]
def _role_label(volunteer_id: uuid.UUID, lead_volunteer_ids: set[uuid.UUID]) -> str:
if volunteer_id in lead_volunteer_ids:
return t("badge.role.referent")
return t("badge.role.volunteer")
def _category_label(category) -> str:
return t(f"position.category.{category.value.lower()}")
def _day_label(day: dt.date) -> str:
"""En-tête d'un jour dans le tableau des créneaux : « Samedi 11 »."""
return t("badge.table.day_label", weekday=t(f"badge.weekday.{day.weekday()}"), day=day.day)
def _positions_table(assignments: list[AssignmentRow], boundary: dt.time) -> BadgeTable | None:
"""Créneaux affectés regroupés par jour de festival (une soirée qui
passe minuit reste sur le jour où elle a commencé — `festival_day`) :
le jour en en-tête, puis une ligne horaire / poste par créneau, dans
l'ordre chronologique. `None` sans aucun créneau (rien à dessiner)."""
by_day: dict[dt.date, list[tuple[str, str]]] = defaultdict(list)
for position, shift in assignments:
by_day[festival_day(shift.starts_at, boundary)].append(
(f"{shift.starts_at:%H:%M}–{shift.ends_at:%H:%M}", position.name)
)
if not by_day:
return None
return BadgeTable(
sections=tuple(
BadgeTableSection(title=_day_label(day), rows=tuple(rows))
for day, rows in sorted(by_day.items())
)
)
def _field_text(
field: BadgeField,
volunteer: Volunteer,
assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]],
lead_volunteer_ids: set[uuid.UUID],
) -> str:
"""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}"
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.
return volunteer.badge_text or field.text or ""
assignments = assignments_by_volunteer.get(volunteer.id, [])
if field.field_type == BadgeFieldType.CATEGORY:
categories = sorted(
{_category_label(position.category) for position, _shift in assignments}
)
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, Shift)
.join(Shift, Shift.id == Assignment.shift_id)
.join(Position, Position.id == Shift.position_id)
.where(Assignment.volunteer_id.in_(volunteer_ids))
.order_by(Shift.starts_at)
).all()
assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]] = defaultdict(list)
for volunteer_id, position, shift in rows:
assignments_by_volunteer[volunteer_id].append((position, shift))
boundary = day_boundary(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
)
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, 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
)
)
for volunteer in volunteers
]
pdf_bytes = generate_badge_sheets_pdf(
badges,
template.front_image,
template.back_image,
badge_width_mm=template.front_image_width_mm or DEFAULT_BADGE_WIDTH_MM,
badge_height_mm=template.front_image_height_mm or DEFAULT_BADGE_HEIGHT_MM,
)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": 'attachment; filename="badges.pdf"'},
)