"""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, 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 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}" if field.field_type == BadgeFieldType.FREE_TEXT: return volunteer.badge_text or "" 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, is_photo=field.field_type == BadgeFieldType.PHOTO, image=volunteer.photo if field.field_type == BadgeFieldType.PHOTO else None, ) 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"'}, )