Créateur de badges refait d'après la maquette : champs sur le recto et le verso (BadgeField.face, verso placé en miroir dans le PDF pour le recto-verso), fond de zone, réduction automatique du texte, photo ovale, initiales sans photo, nom sur deux lignes (migration 0026) ; éditeur en trois colonnes (champs, aperçu recto/verso avec de vrais bénévoles, inspecteur), formats prédéfinis, annuler, flèches, zoom, repères de découpe, dépôt d'image sur l'aperçu, contrôle de tous les badges avant génération. Nombre de badges par bénévole (Volunteer.badge_count, 1 par défaut, migration 0027) : champ sur la fiche, autant d'exemplaires sur les planches, total affiché à la génération, colonne dans l'export Excel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
429 lines
16 KiB
Python
429 lines
16 KiB
Python
"""Planches de badges bénévoles : configuration du recto/verso (COORDINATION)
|
||
et génération du PDF.
|
||
|
||
Chaque face (recto, verso) combine une image de fond (la même pour tous les
|
||
bénévoles) 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 re
|
||
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,
|
||
initials,
|
||
)
|
||
from app.db import get_session
|
||
from app.i18n import t
|
||
from app.models import (
|
||
Account,
|
||
Assignment,
|
||
BadgeFace,
|
||
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"}
|
||
_HEX_COLOR = re.compile(r"#[0-9a-fA-F]{6}")
|
||
# Champs centrés verticalement dans leur cadre (les autres partent du haut).
|
||
_MIDDLE_ALIGNED = {BadgeFieldType.FREE_TEXT, BadgeFieldType.ROLE}
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 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.face, 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]:
|
||
# Au plus un champ de chaque type par face ; le même sur les deux faces
|
||
# est permis.
|
||
seen: set[tuple[BadgeFace, BadgeFieldType]] = set()
|
||
for f in payload.fields:
|
||
if (f.face, 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.face, f.field_type))
|
||
if f.bg_color is not None and not _HEX_COLOR.fullmatch(f.bg_color):
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail={"code": "badge.invalid_color", "params": {"color": f.bg_color}},
|
||
)
|
||
|
||
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:
|
||
# Prénom puis nom, chacun sur sa ligne.
|
||
return f"{volunteer.first_name}\n{volunteer.last_name}"
|
||
if field.field_type == BadgeFieldType.PHOTO:
|
||
# Imprimées à la place d'une photo absente ou illisible.
|
||
return initials(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
|
||
}
|
||
|
||
def spec(field: BadgeField, volunteer: Volunteer) -> BadgeFieldSpec:
|
||
return 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,
|
||
bg_color=field.bg_color,
|
||
auto_fit=field.auto_fit,
|
||
valign="MIDDLE" if field.field_type in _MIDDLE_ALIGNED else "TOP",
|
||
photo_shape=field.photo_shape.value,
|
||
)
|
||
|
||
front_fields = [f for f in fields if f.face == BadgeFace.FRONT]
|
||
back_fields = [f for f in fields if f.face == BadgeFace.BACK]
|
||
# Un badge par exemplaire demandé sur la fiche (`Volunteer.badge_count`),
|
||
# à la suite les uns des autres.
|
||
badges = [
|
||
badge
|
||
for volunteer in volunteers
|
||
for badge in [
|
||
BadgeSpec(
|
||
fields=tuple(spec(field, volunteer) for field in front_fields),
|
||
back_fields=tuple(spec(field, volunteer) for field in back_fields),
|
||
)
|
||
]
|
||
* volunteer.badge_count
|
||
]
|
||
|
||
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"'},
|
||
)
|