Étape 20 : planches de badges bénévoles (PDF)
Recto personnalisable (fond image + champs positionnés : nom, poste(s)
affectés, catégorie, rôle) via un éditeur visuel glisser-déposer ; verso
fixe, la même image pour tous les bénévoles. Nouvel écran « Badges »
(COORDINATION uniquement, /badges).
Backend : modèle BadgeTemplate/BadgeField (migration 0011), génération
PDF isolée (app/badges/pdf.py, ReportLab — planches recto suivies des
planches verso correspondantes pour une impression recto-verso), route
POST /editions/{id}/badges/generate, tests (module pur + API).
Frontend : client HTTP (upload multipart, réponses binaires images/PDF),
client mock (mode démonstration), éditeur de positionnement en
millimètres (coordonnées partagées avec le backend), écran de génération
avec sélection des bénévoles et téléchargement.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
4bcdafca96
commit
b7fc80a7dc
@@ -0,0 +1,291 @@
|
||||
"""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 uuid
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
|
||||
from sqlalchemy import delete
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api.deps import get_edition
|
||||
from app.api.schemas import (
|
||||
BadgeFieldRead,
|
||||
BadgeFieldsSet,
|
||||
BadgeGenerateRequest,
|
||||
BadgeTemplateRead,
|
||||
)
|
||||
from app.auth.deps import require_coordinator
|
||||
from app.badges.pdf import BadgeFieldSpec, BadgeSpec, generate_badge_sheets_pdf
|
||||
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,
|
||||
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
|
||||
|
||||
|
||||
@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:
|
||||
template = _get_or_create_template(session, edition.id)
|
||||
content, content_type = await _read_image(file)
|
||||
template.front_image = content
|
||||
template.front_image_content_type = content_type
|
||||
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)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Génération du PDF #
|
||||
# --------------------------------------------------------------------------- #
|
||||
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")
|
||||
|
||||
|
||||
def _category_label(category) -> str:
|
||||
return t(f"position.category.{category.value.lower()}")
|
||||
|
||||
|
||||
def _field_text(
|
||||
field: BadgeField,
|
||||
volunteer: Volunteer,
|
||||
positions_by_volunteer: dict[uuid.UUID, list[Position]],
|
||||
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, [])
|
||||
if field.field_type == BadgeFieldType.POSITIONS:
|
||||
names = sorted({p.name for p in positions})
|
||||
return ", ".join(names)
|
||||
if field.field_type == BadgeFieldType.CATEGORY:
|
||||
categories = sorted({_category_label(p.category) for p in positions})
|
||||
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)
|
||||
.join(Shift, Shift.id == Assignment.shift_id)
|
||||
.join(Position, Position.id == Shift.position_id)
|
||||
.where(Assignment.volunteer_id.in_(volunteer_ids))
|
||||
).all()
|
||||
positions_by_volunteer: dict[uuid.UUID, list[Position]] = defaultdict(list)
|
||||
for volunteer_id, position in rows:
|
||||
positions_by_volunteer[volunteer_id].append(position)
|
||||
|
||||
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, positions_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)
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": 'attachment; filename="badges.pdf"'},
|
||||
)
|
||||
@@ -7,6 +7,8 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models import (
|
||||
AssignmentSource,
|
||||
BadgeAlign,
|
||||
BadgeFieldType,
|
||||
PositionCategory,
|
||||
Role,
|
||||
)
|
||||
@@ -334,6 +336,45 @@ class MatrixResponse(ApiModel):
|
||||
positions: list[MatrixPosition]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Badges (planches PDF) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class BadgeFieldIn(ApiModel):
|
||||
field_type: BadgeFieldType
|
||||
x_mm: float
|
||||
y_mm: float
|
||||
width_mm: float
|
||||
height_mm: float
|
||||
font_size: float = 10.0
|
||||
align: BadgeAlign = BadgeAlign.LEFT
|
||||
bold: bool = False
|
||||
color: str = "#000000"
|
||||
|
||||
|
||||
class BadgeFieldsSet(ApiModel):
|
||||
"""Remplace l'intégralité des champs du recto — au plus un par
|
||||
`field_type` (décocher un champ dans l'éditeur retire sa ligne)."""
|
||||
|
||||
fields: list[BadgeFieldIn] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BadgeFieldRead(BadgeFieldIn):
|
||||
id: uuid.UUID
|
||||
edition_id: uuid.UUID
|
||||
|
||||
|
||||
class BadgeTemplateRead(ApiModel):
|
||||
edition_id: uuid.UUID
|
||||
has_front_image: bool
|
||||
has_back_image: bool
|
||||
fields: list[BadgeFieldRead]
|
||||
|
||||
|
||||
class BadgeGenerateRequest(ApiModel):
|
||||
# None/absent = tous les bénévoles de l'édition.
|
||||
volunteer_ids: list[uuid.UUID] | None = None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Authentification et comptes #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.badges.pdf import BadgeFieldSpec, BadgeSpec, generate_badge_sheets_pdf
|
||||
|
||||
__all__ = ["BadgeFieldSpec", "BadgeSpec", "generate_badge_sheets_pdf"]
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Génération des planches de badges bénévoles (PDF).
|
||||
|
||||
Module isolé, fonction pure (`generate_badge_sheets_pdf`) : aucun accès base
|
||||
ici — voir `app/api/badges.py` pour l'assemblage des données (bénévole →
|
||||
champs résolus en texte).
|
||||
|
||||
Format badge : CR80 (85,6 × 54 mm — taille standard des badges/cartes
|
||||
d'événement). Planche : grille sur page A4 portrait. Le verso est identique
|
||||
pour tous les bénévoles : la planche imprime toutes les pages de recto
|
||||
(personnalisées) puis, pour chacune, la page de verso correspondante (même
|
||||
disposition, même image répétée à chaque emplacement rempli) — impression
|
||||
recto-verso classique, page de verso n directement après la page de recto n.
|
||||
"""
|
||||
|
||||
import io
|
||||
from dataclasses import dataclass
|
||||
|
||||
from reportlab.lib.colors import HexColor
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
BADGE_WIDTH_MM = 85.6
|
||||
BADGE_HEIGHT_MM = 54.0
|
||||
_MARGIN_MM = 10.0
|
||||
_GUTTER_MM = 4.0
|
||||
|
||||
_PAGE_WIDTH_MM = A4[0] / mm
|
||||
_PAGE_HEIGHT_MM = A4[1] / mm
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BadgeFieldSpec:
|
||||
"""Un champ déjà résolu en texte, positionné depuis le coin haut-gauche
|
||||
du badge (comme dans l'éditeur visuel du front-end)."""
|
||||
|
||||
text: str
|
||||
x_mm: float
|
||||
y_mm: float
|
||||
width_mm: float
|
||||
height_mm: float
|
||||
font_size: float
|
||||
align: str # "LEFT" | "CENTER" | "RIGHT"
|
||||
bold: bool
|
||||
color: str # "#rrggbb"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BadgeSpec:
|
||||
"""Un badge = un bénévole, avec ses champs déjà résolus."""
|
||||
|
||||
fields: tuple[BadgeFieldSpec, ...]
|
||||
|
||||
|
||||
def _grid_size() -> tuple[int, int]:
|
||||
usable_w = _PAGE_WIDTH_MM - 2 * _MARGIN_MM
|
||||
usable_h = _PAGE_HEIGHT_MM - 2 * _MARGIN_MM
|
||||
cols = max(1, int((usable_w + _GUTTER_MM) // (BADGE_WIDTH_MM + _GUTTER_MM)))
|
||||
rows = max(1, int((usable_h + _GUTTER_MM) // (BADGE_HEIGHT_MM + _GUTTER_MM)))
|
||||
return cols, rows
|
||||
|
||||
|
||||
def _cell_origin(index: int, cols: int) -> tuple[float, float]:
|
||||
"""Coin bas-gauche (repère PDF) de l'emplacement `index` sur la page,
|
||||
en mm depuis le coin bas-gauche de la page."""
|
||||
col, row = index % cols, index // cols
|
||||
x0 = _MARGIN_MM + col * (BADGE_WIDTH_MM + _GUTTER_MM)
|
||||
y0 = _PAGE_HEIGHT_MM - _MARGIN_MM - (row + 1) * BADGE_HEIGHT_MM - row * _GUTTER_MM
|
||||
return x0, y0
|
||||
|
||||
|
||||
def _draw_background(c: canvas.Canvas, image: bytes | None, x0_mm: float, y0_mm: float) -> None:
|
||||
if not image:
|
||||
return
|
||||
c.drawImage(
|
||||
ImageReader(io.BytesIO(image)),
|
||||
x0_mm * mm,
|
||||
y0_mm * mm,
|
||||
width=BADGE_WIDTH_MM * mm,
|
||||
height=BADGE_HEIGHT_MM * mm,
|
||||
preserveAspectRatio=False,
|
||||
mask="auto",
|
||||
)
|
||||
|
||||
|
||||
def _draw_fields(
|
||||
c: canvas.Canvas, fields: tuple[BadgeFieldSpec, ...], x0_mm: float, y0_mm: float
|
||||
) -> None:
|
||||
for f in fields:
|
||||
c.setFont("Helvetica-Bold" if f.bold else "Helvetica", f.font_size)
|
||||
c.setFillColor(HexColor(f.color))
|
||||
left_x = x0_mm + f.x_mm
|
||||
# `f.y_mm` est depuis le HAUT du badge (repère écran) ; reportlab
|
||||
# dessine depuis le BAS de la page. On approxime un alignement en
|
||||
# haut de la boîte plutôt qu'un centrage vertical exact.
|
||||
baseline_y = y0_mm + BADGE_HEIGHT_MM - f.y_mm - f.font_size * 0.8
|
||||
if f.align == "CENTER":
|
||||
c.drawCentredString((left_x + f.width_mm / 2) * mm, baseline_y * mm, f.text)
|
||||
elif f.align == "RIGHT":
|
||||
c.drawRightString((left_x + f.width_mm) * mm, baseline_y * mm, f.text)
|
||||
else:
|
||||
c.drawString(left_x * mm, baseline_y * mm, f.text)
|
||||
|
||||
|
||||
def generate_badge_sheets_pdf(
|
||||
badges: list[BadgeSpec],
|
||||
front_image: bytes | None,
|
||||
back_image: bytes | None,
|
||||
) -> bytes:
|
||||
"""`badges` : un par bénévole, dans l'ordre d'impression. Retourne les
|
||||
octets du PDF (recto puis verso, planche par planche)."""
|
||||
cols, rows = _grid_size()
|
||||
per_page = cols * rows
|
||||
pages = [badges[i : i + per_page] for i in range(0, len(badges), per_page)]
|
||||
|
||||
buffer = io.BytesIO()
|
||||
c = canvas.Canvas(buffer, pagesize=A4)
|
||||
for page_badges in pages:
|
||||
for i, badge in enumerate(page_badges):
|
||||
x0, y0 = _cell_origin(i, cols)
|
||||
_draw_background(c, front_image, x0, y0)
|
||||
_draw_fields(c, badge.fields, x0, y0)
|
||||
c.showPage()
|
||||
|
||||
for i in range(len(page_badges)):
|
||||
x0, y0 = _cell_origin(i, cols)
|
||||
_draw_background(c, back_image, x0, y0)
|
||||
c.showPage()
|
||||
c.save()
|
||||
return buffer.getvalue()
|
||||
@@ -54,5 +54,22 @@
|
||||
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
|
||||
"email.planning.shift_line": "- {position} : {start} → {end}",
|
||||
"availability.date_outside_edition": "La date {date} est hors des dates de l'édition.",
|
||||
"availability.empty_window": "Une disponibilité doit avoir une heure de début et de fin différentes."
|
||||
"availability.empty_window": "Une disponibilité doit avoir une heure de début et de fin différentes.",
|
||||
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
|
||||
"badge.empty_image": "L'image est vide.",
|
||||
"badge.image_too_large": "L'image est trop volumineuse.",
|
||||
"badge.duplicate_field_type": "Le champ « {field_type} » ne peut apparaître qu'une fois.",
|
||||
"badge.no_fields": "Aucun champ n'est configuré sur le recto du badge.",
|
||||
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
|
||||
"badge.role.referent": "Référent",
|
||||
"badge.role.volunteer": "Bénévole",
|
||||
"position.category.bar": "Bar",
|
||||
"position.category.ticketing": "Billetterie",
|
||||
"position.category.welcome": "Accueil",
|
||||
"position.category.parking": "Parking",
|
||||
"position.category.cleaning": "Propreté",
|
||||
"position.category.catering": "Catering",
|
||||
"position.category.setup": "Montage",
|
||||
"position.category.stage": "Régie",
|
||||
"position.category.other": "Autre"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.api.artists import router as artist_router
|
||||
from app.api.assignments import router as assignment_router
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.availability import router as availability_router
|
||||
from app.api.badges import router as badge_router
|
||||
from app.api.day_parts import router as day_part_router
|
||||
from app.api.editions import router as edition_router
|
||||
from app.api.health import router as health_router
|
||||
@@ -61,3 +62,4 @@ app.include_router(assignment_router)
|
||||
app.include_router(solver_router)
|
||||
app.include_router(matrix_router)
|
||||
app.include_router(notification_router)
|
||||
app.include_router(badge_router)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Planches de badges bénévoles : recto/verso et champs positionnés
|
||||
|
||||
Revision ID: 0011_badge_template
|
||||
Revises: 0010_companion_wish
|
||||
Create Date: 2026-09-20
|
||||
|
||||
Deux tables : `badge_template` (images recto/verso, une ligne par édition) et
|
||||
`badge_field` (champs positionnés sur le recto — nom, poste(s), catégorie,
|
||||
rôle). Voir `app/badges/pdf.py` pour la génération des planches PDF.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0011_badge_template"
|
||||
down_revision: str | None = "0010_companion_wish"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"badge_template",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("edition_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("front_image", sa.LargeBinary(), nullable=True),
|
||||
sa.Column("front_image_content_type", sa.String(), nullable=True),
|
||||
sa.Column("back_image", sa.LargeBinary(), nullable=True),
|
||||
sa.Column("back_image_content_type", sa.String(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("edition_id"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"badge_field",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("edition_id", sa.Uuid(), nullable=False),
|
||||
sa.Column(
|
||||
"field_type",
|
||||
sa.Enum(
|
||||
"FULL_NAME", "POSITIONS", "CATEGORY", "ROLE",
|
||||
name="badgefieldtype", native_enum=False,
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("x_mm", sa.Float(), nullable=False),
|
||||
sa.Column("y_mm", sa.Float(), nullable=False),
|
||||
sa.Column("width_mm", sa.Float(), nullable=False),
|
||||
sa.Column("height_mm", sa.Float(), nullable=False),
|
||||
sa.Column("font_size", sa.Float(), nullable=False),
|
||||
sa.Column(
|
||||
"align",
|
||||
sa.Enum(
|
||||
"LEFT", "CENTER", "RIGHT", name="badgealign", native_enum=False
|
||||
),
|
||||
nullable=False,
|
||||
server_default="LEFT",
|
||||
),
|
||||
sa.Column(
|
||||
"bold", sa.Boolean(), server_default="false", nullable=False
|
||||
),
|
||||
sa.Column("color", sa.String(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"edition_id", "field_type", name="uq_badge_field_edition_type"
|
||||
),
|
||||
)
|
||||
op.create_index("ix_badge_field_edition_id", "badge_field", ["edition_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_badge_field_edition_id", table_name="badge_field")
|
||||
op.drop_table("badge_field")
|
||||
op.drop_table("badge_template")
|
||||
@@ -10,6 +10,8 @@ from app.models.domain import (
|
||||
Artist,
|
||||
Assignment,
|
||||
Availability,
|
||||
BadgeField,
|
||||
BadgeTemplate,
|
||||
DayPart,
|
||||
Edition,
|
||||
MagicLinkToken,
|
||||
@@ -22,6 +24,8 @@ from app.models.domain import (
|
||||
)
|
||||
from app.models.enums import (
|
||||
AssignmentSource,
|
||||
BadgeAlign,
|
||||
BadgeFieldType,
|
||||
PositionCategory,
|
||||
Role,
|
||||
)
|
||||
@@ -32,6 +36,10 @@ __all__ = [
|
||||
"Assignment",
|
||||
"AssignmentSource",
|
||||
"Availability",
|
||||
"BadgeAlign",
|
||||
"BadgeField",
|
||||
"BadgeFieldType",
|
||||
"BadgeTemplate",
|
||||
"DayPart",
|
||||
"Edition",
|
||||
"MagicLinkToken",
|
||||
|
||||
@@ -9,13 +9,15 @@ au niveau base (`ondelete="CASCADE"`) ; les relations ORM utilisent
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, CheckConstraint, Column, Integer, Time
|
||||
from sqlalchemy import Boolean, CheckConstraint, Column, Integer, LargeBinary, Time
|
||||
from sqlalchemy import Enum as SAEnum
|
||||
from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
|
||||
|
||||
from app.models.base import TimestampMixin
|
||||
from app.models.enums import (
|
||||
AssignmentSource,
|
||||
BadgeAlign,
|
||||
BadgeFieldType,
|
||||
PositionCategory,
|
||||
Role,
|
||||
)
|
||||
@@ -364,3 +366,65 @@ class Assignment(TimestampMixin, table=True):
|
||||
|
||||
volunteer: Volunteer = Relationship(back_populates="assignments")
|
||||
shift: Shift = Relationship(back_populates="assignments")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Badges (planches recto/verso, voir app/badges/) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class BadgeTemplate(TimestampMixin, table=True):
|
||||
"""Recto/verso du badge d'une édition. Une ligne par édition (créée à la
|
||||
demande). Le verso (`back_image`) est le même pour tous les bénévoles ;
|
||||
le recto combine `front_image` (fond) et les `BadgeField` positionnés
|
||||
dessus. Images stockées en base (pas de volume de fichiers à gérer)."""
|
||||
|
||||
__tablename__ = "badge_template"
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", unique=True, ondelete="CASCADE"
|
||||
)
|
||||
front_image: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
|
||||
front_image_content_type: str | None = None
|
||||
back_image: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
|
||||
back_image_content_type: str | None = None
|
||||
|
||||
|
||||
class BadgeField(TimestampMixin, table=True):
|
||||
"""Un champ positionné sur le recto du badge (nom, poste(s), catégorie,
|
||||
rôle — voir `BadgeFieldType`). Coordonnées en millimètres depuis le coin
|
||||
haut-gauche du badge (format CR80, 85,6 × 54 mm — voir `app/badges/pdf.py`).
|
||||
Au plus un champ par `field_type` et par édition : décocher un champ dans
|
||||
l'éditeur retire sa ligne (remplacement complet, `PUT .../fields`)."""
|
||||
|
||||
__tablename__ = "badge_field"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"edition_id", "field_type", name="uq_badge_field_edition_type"
|
||||
),
|
||||
)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
field_type: BadgeFieldType = Field(
|
||||
sa_column=Column(SAEnum(BadgeFieldType, native_enum=False), nullable=False)
|
||||
)
|
||||
x_mm: float
|
||||
y_mm: float
|
||||
width_mm: float
|
||||
height_mm: float
|
||||
font_size: float = Field(default=10.0)
|
||||
align: BadgeAlign = Field(
|
||||
default=BadgeAlign.LEFT,
|
||||
sa_column=Column(
|
||||
SAEnum(BadgeAlign, native_enum=False),
|
||||
nullable=False,
|
||||
server_default=BadgeAlign.LEFT.value,
|
||||
),
|
||||
)
|
||||
bold: bool = Field(
|
||||
default=False,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||
)
|
||||
color: str = Field(default="#000000")
|
||||
|
||||
@@ -28,3 +28,18 @@ class PositionCategory(enum.StrEnum):
|
||||
SETUP = "SETUP"
|
||||
STAGE = "STAGE"
|
||||
OTHER = "OTHER"
|
||||
|
||||
|
||||
class BadgeFieldType(enum.StrEnum):
|
||||
"""Champ affichable sur le recto d'un badge (voir `app/badges/`)."""
|
||||
|
||||
FULL_NAME = "FULL_NAME"
|
||||
POSITIONS = "POSITIONS"
|
||||
CATEGORY = "CATEGORY"
|
||||
ROLE = "ROLE"
|
||||
|
||||
|
||||
class BadgeAlign(enum.StrEnum):
|
||||
LEFT = "LEFT"
|
||||
CENTER = "CENTER"
|
||||
RIGHT = "RIGHT"
|
||||
|
||||
@@ -15,6 +15,8 @@ dependencies = [
|
||||
"pyjwt>=2.9",
|
||||
"bcrypt>=4.1",
|
||||
"ortools>=9.11",
|
||||
"reportlab>=4.2",
|
||||
"pillow>=11.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Configuration et génération des planches de badges (API)."""
|
||||
|
||||
import base64
|
||||
|
||||
from app.models import Role
|
||||
from tests.factories import dt_at, make_position, make_shift, make_volunteer
|
||||
|
||||
# PNG 1×1 valide (transparent), le plus petit possible.
|
||||
_PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY"
|
||||
"42YAAAAASUVORK5CYII="
|
||||
)
|
||||
|
||||
|
||||
def _badges_url(edition):
|
||||
return f"/editions/{edition.id}/badges"
|
||||
|
||||
|
||||
def _set_full_name_field(client, edition):
|
||||
return client.put(
|
||||
f"{_badges_url(edition)}/template/fields",
|
||||
json={
|
||||
"fields": [
|
||||
{
|
||||
"field_type": "FULL_NAME",
|
||||
"x_mm": 5,
|
||||
"y_mm": 5,
|
||||
"width_mm": 60,
|
||||
"height_mm": 10,
|
||||
"font_size": 12,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_template_is_coordinator_only(edition, make_account, as_account):
|
||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||
client = as_account(lead)
|
||||
assert client.get(f"{_badges_url(edition)}/template").status_code == 403
|
||||
resp = client.put(f"{_badges_url(edition)}/template/fields", json={"fields": []})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_template_starts_empty(client, edition):
|
||||
resp = client.get(f"{_badges_url(edition)}/template")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["has_front_image"] is False
|
||||
assert body["has_back_image"] is False
|
||||
assert body["fields"] == []
|
||||
|
||||
|
||||
def test_uploading_front_and_back_images(client, edition):
|
||||
front = client.put(
|
||||
f"{_badges_url(edition)}/template/front-image",
|
||||
files={"file": ("front.png", _PNG, "image/png")},
|
||||
)
|
||||
assert front.status_code == 200
|
||||
assert front.json()["has_front_image"] is True
|
||||
|
||||
back = client.put(
|
||||
f"{_badges_url(edition)}/template/back-image",
|
||||
files={"file": ("back.png", _PNG, "image/png")},
|
||||
)
|
||||
assert back.status_code == 200
|
||||
assert back.json()["has_back_image"] is True
|
||||
|
||||
served = client.get(f"{_badges_url(edition)}/template/front-image")
|
||||
assert served.status_code == 200
|
||||
assert served.headers["content-type"] == "image/png"
|
||||
assert served.content == _PNG
|
||||
|
||||
|
||||
def test_uploading_an_unsupported_image_type_is_rejected(client, edition):
|
||||
resp = client.put(
|
||||
f"{_badges_url(edition)}/template/front-image",
|
||||
files={"file": ("front.gif", b"not-a-real-gif", "image/gif")},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "badge.image_type_not_supported"
|
||||
|
||||
|
||||
def test_setting_the_same_field_type_twice_is_rejected(client, edition):
|
||||
resp = client.put(
|
||||
f"{_badges_url(edition)}/template/fields",
|
||||
json={
|
||||
"fields": [
|
||||
{
|
||||
"field_type": "FULL_NAME", "x_mm": 0, "y_mm": 0,
|
||||
"width_mm": 10, "height_mm": 10,
|
||||
},
|
||||
{
|
||||
"field_type": "FULL_NAME", "x_mm": 5, "y_mm": 5,
|
||||
"width_mm": 10, "height_mm": 10,
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "badge.duplicate_field_type"
|
||||
|
||||
|
||||
def test_generating_without_any_field_configured_is_rejected(client, edition, session):
|
||||
make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
resp = client.post(f"{_badges_url(edition)}/generate", json={})
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "badge.no_fields"
|
||||
|
||||
|
||||
def test_generating_with_no_matching_volunteers_is_rejected(client, edition, session):
|
||||
_set_full_name_field(client, edition)
|
||||
make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
resp = client.post(
|
||||
f"{_badges_url(edition)}/generate",
|
||||
json={"volunteer_ids": ["00000000-0000-0000-0000-000000000000"]},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "badge.no_volunteers"
|
||||
|
||||
|
||||
def test_generating_produces_a_pdf(client, edition, session):
|
||||
from app.models import Assignment
|
||||
|
||||
resp = client.put(
|
||||
f"{_badges_url(edition)}/template/fields",
|
||||
json={
|
||||
"fields": [
|
||||
{
|
||||
"field_type": "FULL_NAME", "x_mm": 5, "y_mm": 5,
|
||||
"width_mm": 60, "height_mm": 10, "font_size": 12,
|
||||
},
|
||||
{
|
||||
"field_type": "POSITIONS", "x_mm": 5, "y_mm": 20,
|
||||
"width_mm": 60, "height_mm": 10, "font_size": 9,
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
client.put(
|
||||
f"{_badges_url(edition)}/template/front-image",
|
||||
files={"file": ("front.png", _PNG, "image/png")},
|
||||
)
|
||||
client.put(
|
||||
f"{_badges_url(edition)}/template/back-image",
|
||||
files={"file": ("back.png", _PNG, "image/png")},
|
||||
)
|
||||
|
||||
volunteer = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
position = make_position(session, edition, name="Bar central")
|
||||
shift = make_shift(session, edition, position, start=dt_at(9, 18), end=dt_at(9, 22))
|
||||
session.add(Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id))
|
||||
session.commit()
|
||||
|
||||
resp = client.post(
|
||||
f"{_badges_url(edition)}/generate",
|
||||
json={"volunteer_ids": [str(volunteer.id)]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "application/pdf"
|
||||
assert resp.content.startswith(b"%PDF")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Génération des planches de badges (module pur, sans base)."""
|
||||
|
||||
from app.badges.pdf import (
|
||||
BadgeFieldSpec,
|
||||
BadgeSpec,
|
||||
_cell_origin,
|
||||
_grid_size,
|
||||
generate_badge_sheets_pdf,
|
||||
)
|
||||
|
||||
|
||||
def _badge(text="Alex Martin") -> BadgeSpec:
|
||||
return BadgeSpec(
|
||||
fields=(
|
||||
BadgeFieldSpec(
|
||||
text=text,
|
||||
x_mm=5,
|
||||
y_mm=5,
|
||||
width_mm=60,
|
||||
height_mm=10,
|
||||
font_size=12,
|
||||
align="LEFT",
|
||||
bold=True,
|
||||
color="#000000",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_grid_size_fits_a4_with_cr80_badges():
|
||||
# A4 (210 × 297 mm), marge 10 mm, gouttière 4 mm, badge CR80 85,6 × 54 mm
|
||||
# ⇒ 2 colonnes (190 mm utiles / 89,6 mm par badge+gouttière) x 4 rangées
|
||||
# (277 mm utiles / 58 mm par badge+gouttière).
|
||||
assert _grid_size() == (2, 4)
|
||||
|
||||
|
||||
def test_cell_origin_places_badges_left_to_right_top_to_bottom():
|
||||
cols, _ = _grid_size()
|
||||
x0, y0 = _cell_origin(0, cols)
|
||||
x1, _ = _cell_origin(1, cols)
|
||||
_, y_row2 = _cell_origin(cols, cols)
|
||||
assert x1 > x0 # la 2e colonne est à droite de la 1re
|
||||
assert y_row2 < y0 # la 2e rangée est plus bas (repère PDF : Y croît vers le haut)
|
||||
|
||||
|
||||
def test_generates_a_valid_pdf_for_a_single_badge():
|
||||
pdf = generate_badge_sheets_pdf([_badge()], front_image=None, back_image=None)
|
||||
assert pdf.startswith(b"%PDF")
|
||||
assert pdf.rstrip().endswith(b"%%EOF")
|
||||
|
||||
|
||||
def test_more_badges_than_one_sheet_produces_a_larger_pdf():
|
||||
cols, rows = _grid_size()
|
||||
per_page = cols * rows
|
||||
one_page = generate_badge_sheets_pdf(
|
||||
[_badge() for _ in range(per_page)], front_image=None, back_image=None
|
||||
)
|
||||
# Un badge de plus déborde sur une 2e planche (recto + verso en plus).
|
||||
two_pages = generate_badge_sheets_pdf(
|
||||
[_badge() for _ in range(per_page + 1)], front_image=None, back_image=None
|
||||
)
|
||||
assert len(two_pages) > len(one_page)
|
||||
@@ -27,6 +27,8 @@ EXPECTED_TABLES = {
|
||||
"shift",
|
||||
"availability",
|
||||
"assignment",
|
||||
"badge_template",
|
||||
"badge_field",
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user