Étape 62 : les fiches de poste intégraient la photo d'origine de chaque bénévole pour chaque créneau (CPU à 300 %, puis erreur) — photos réduites à la vignette une fois par PDF ; badges : photo recadrée une fois par génération et décodée à taille réduite. Étape 61 : sans photo, le cadre de la photo reste vide sur le badge. Étape 60 : vue par bénévole en tranches de 30 min, affectations étirées sur toute leur durée, postes simultanés empilés, ligne de l'heure actuelle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
435 lines
16 KiB
Python
435 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,
|
||
)
|
||
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)
|
||
# Ordre de dessin (voir `BadgeField.z_order`) ; à égalité, la
|
||
# photo dessous, pour ne jamais masquer un texte.
|
||
.order_by(
|
||
BadgeField.face,
|
||
BadgeField.z_order,
|
||
(BadgeField.field_type == BadgeFieldType.PHOTO).desc(),
|
||
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:
|
||
# Une image, pas un texte (rien d'imprimé sans photo).
|
||
return ""
|
||
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"'},
|
||
)
|