Badges : format dérivé de l'image importée, postes avec horaire et référent

- Le format du badge n'est plus fixé à CR80 : il est déduit des pixels de
  l'image du recto à l'import (résolution supposée 300 dpi,
  app/badges/pdf.py:image_size_mm), stocké sur BadgeTemplate (migration
  0012). L'éditeur (BadgesPage.tsx) et la génération PDF s'alignent sur ce
  format réel ; CR80 reste le repère par défaut tant qu'aucune image n'est
  importée. Le verso est ajusté sans déformation (preserveAspectRatio).

- Le champ « Postes affectés » liste désormais une ligne par créneau
  (triées chronologiquement) : nom du poste + horaire, et le référent du
  poste s'il y en a un et que ce n'est pas le bénévole badgé lui-même.
  Texte multi-lignes pris en charge par la génération PDF (découpe sur
  `\n`, hauteur de ligne calculée en mm) et par l'aperçu de l'éditeur.

- Corrige au passage un bug d'unités dans le positionnement vertical du
  texte (police en points confondue avec des millimètres).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-09-20 20:35:11 +02:00
co-authored by Claude Sonnet 5
parent b7fc80a7dc
commit 579ea5d9db
17 changed files with 534 additions and 87 deletions
+66 -12
View File
@@ -7,11 +7,14 @@ 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 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 sqlalchemy.orm import aliased
from sqlmodel import Session, select
from app.api.deps import get_edition
@@ -22,7 +25,14 @@ from app.api.schemas import (
BadgeTemplateRead,
)
from app.auth.deps import require_coordinator
from app.badges.pdf import BadgeFieldSpec, BadgeSpec, generate_badge_sheets_pdf
from app.badges.pdf import (
DEFAULT_BADGE_HEIGHT_MM,
DEFAULT_BADGE_WIDTH_MM,
BadgeFieldSpec,
BadgeSpec,
generate_badge_sheets_pdf,
image_size_mm,
)
from app.db import get_session
from app.i18n import t
from app.models import (
@@ -74,6 +84,8 @@ def _template_read(template: BadgeTemplate, fields: list[BadgeField]) -> BadgeTe
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,
)
@@ -102,6 +114,16 @@ async def _read_image(file: UploadFile) -> tuple[bytes, str]:
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(...),
@@ -109,10 +131,17 @@ async def set_front_image(
_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))
@@ -193,6 +222,9 @@ def set_fields(
# --------------------------------------------------------------------------- #
# Génération du PDF #
# --------------------------------------------------------------------------- #
AssignmentRow = tuple[Position, Shift, Volunteer | None] # (poste, créneau, référent)
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")
@@ -201,20 +233,32 @@ def _category_label(category) -> str:
return t(f"position.category.{category.value.lower()}")
def _position_line(position: Position, shift: Shift, referent: Volunteer | None, volunteer_id: uuid.UUID) -> str:
"""Une ligne par créneau affecté : poste + horaire, et le référent du
poste s'il y en a un — sauf si le bénévole badgé est lui-même ce
référent (pas besoin de se l'indiquer à soi-même)."""
line = f"{position.name} {shift.starts_at:%H:%M}–{shift.ends_at:%H:%M}"
if referent is not None and referent.id != volunteer_id:
line += f" ({t('badge.role.referent')} : {referent.first_name} {referent.last_name})"
return line
def _field_text(
field: BadgeField,
volunteer: Volunteer,
positions_by_volunteer: dict[uuid.UUID, list[Position]],
assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]],
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, [])
assignments = assignments_by_volunteer.get(volunteer.id, [])
if field.field_type == BadgeFieldType.POSITIONS:
names = sorted({p.name for p in positions})
return ", ".join(names)
return "\n".join(
_position_line(position, shift, referent, volunteer.id)
for position, shift, referent in assignments
)
if field.field_type == BadgeFieldType.CATEGORY:
categories = sorted({_category_label(p.category) for p in positions})
categories = sorted({_category_label(position.category) for position, _shift, _r in assignments})
return ", ".join(categories)
if field.field_type == BadgeFieldType.ROLE:
return _role_label(volunteer.id, lead_volunteer_ids)
@@ -241,15 +285,19 @@ def generate_badges(
raise HTTPException(status_code=422, detail={"code": "badge.no_volunteers"})
volunteer_ids = [v.id for v in volunteers]
Referent = aliased(Volunteer)
rows = session.exec(
select(Assignment.volunteer_id, Position)
select(Assignment.volunteer_id, Position, Shift, Referent)
.join(Shift, Shift.id == Assignment.shift_id)
.join(Position, Position.id == Shift.position_id)
.join(Account, Account.id == Position.lead_account_id, isouter=True)
.join(Referent, Referent.id == Account.volunteer_id, isouter=True)
.where(Assignment.volunteer_id.in_(volunteer_ids))
.order_by(Shift.starts_at)
).all()
positions_by_volunteer: dict[uuid.UUID, list[Position]] = defaultdict(list)
for volunteer_id, position in rows:
positions_by_volunteer[volunteer_id].append(position)
assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]] = defaultdict(list)
for volunteer_id, position, shift, referent in rows:
assignments_by_volunteer[volunteer_id].append((position, shift, referent))
lead_volunteer_ids = {
account.volunteer_id
@@ -267,7 +315,7 @@ def generate_badges(
BadgeSpec(
fields=tuple(
BadgeFieldSpec(
text=_field_text(field, volunteer, positions_by_volunteer, lead_volunteer_ids),
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,
@@ -283,7 +331,13 @@ def generate_badges(
for volunteer in volunteers
]
pdf_bytes = generate_badge_sheets_pdf(badges, template.front_image, template.back_image)
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",