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:
co-authored by
Claude Sonnet 5
parent
b7fc80a7dc
commit
579ea5d9db
+66
-12
@@ -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",
|
||||
|
||||
@@ -367,6 +367,10 @@ class BadgeTemplateRead(ApiModel):
|
||||
edition_id: uuid.UUID
|
||||
has_front_image: bool
|
||||
has_back_image: bool
|
||||
# Dérivée de l'image du recto (voir `app/badges/pdf.py:image_size_mm`) ;
|
||||
# format CR80 par défaut tant qu'aucune image n'est importée.
|
||||
badge_width_mm: float
|
||||
badge_height_mm: float
|
||||
fields: list[BadgeFieldRead]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user