Le format déduit de l'image importée (300 dpi supposés, étape
précédente) donne un badge minuscule pour une image à résolution web —
ajout d'un contrôle « largeur du badge (mm) » dans l'écran de
personnalisation : la hauteur suit proportionnellement (ratio de
l'image). PUT /editions/{id}/badges/template/size écrase la taille
déduite de l'image ; la réimporter la recalcule à nouveau.
Vérifié : pytest (250 passés), ruff, tsc --noEmit, eslint, vite build,
smoke — tous verts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
377 lines
14 KiB
Python
377 lines
14 KiB
Python
"""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 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
|
||
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,
|
||
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,
|
||
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,
|
||
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, Volunteer | None] # (poste, créneau, référent)
|
||
|
||
|
||
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 _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,
|
||
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}"
|
||
assignments = assignments_by_volunteer.get(volunteer.id, [])
|
||
if field.field_type == BadgeFieldType.POSITIONS:
|
||
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(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)
|
||
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]
|
||
Referent = aliased(Volunteer)
|
||
rows = session.exec(
|
||
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()
|
||
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
|
||
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,
|
||
)
|
||
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"'},
|
||
)
|