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]
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
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,
|
||||
)
|
||||
|
||||
__all__ = ["BadgeFieldSpec", "BadgeSpec", "generate_badge_sheets_pdf"]
|
||||
__all__ = [
|
||||
"DEFAULT_BADGE_HEIGHT_MM",
|
||||
"DEFAULT_BADGE_WIDTH_MM",
|
||||
"BadgeFieldSpec",
|
||||
"BadgeSpec",
|
||||
"generate_badge_sheets_pdf",
|
||||
"image_size_mm",
|
||||
]
|
||||
|
||||
+77
-32
@@ -4,8 +4,11 @@ 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
|
||||
Format du badge : dérivé de l'image de fond du recto (pas de format fixe
|
||||
imposé) — voir `image_size_mm`. Tant qu'aucune image n'est importée,
|
||||
`DEFAULT_BADGE_WIDTH_MM`/`DEFAULT_BADGE_HEIGHT_MM` (CR80, 85,6 × 54 mm — la
|
||||
taille standard des badges/cartes d'événement) servent de repère à
|
||||
l'éditeur. 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
|
||||
@@ -21,19 +24,33 @@ 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
|
||||
DEFAULT_BADGE_WIDTH_MM = 85.6
|
||||
DEFAULT_BADGE_HEIGHT_MM = 54.0
|
||||
_MARGIN_MM = 10.0
|
||||
_GUTTER_MM = 4.0
|
||||
_LINE_HEIGHT_FACTOR = 1.25
|
||||
|
||||
# Résolution supposée d'une image de fond importée, pour convertir ses pixels
|
||||
# en millimètres réels (donc en taille de badge) — 300 dpi est la résolution
|
||||
# d'impression standard.
|
||||
ASSUMED_DPI = 300.0
|
||||
|
||||
_PAGE_WIDTH_MM = A4[0] / mm
|
||||
_PAGE_HEIGHT_MM = A4[1] / mm
|
||||
|
||||
|
||||
def image_size_mm(width_px: int, height_px: int, dpi: float = ASSUMED_DPI) -> tuple[float, float]:
|
||||
"""Taille physique (mm) d'une image, en supposant qu'elle a été exportée
|
||||
à `dpi` — c'est cette taille qui devient celle du badge (voir
|
||||
`app/api/badges.py:set_front_image`)."""
|
||||
return (width_px / dpi * 25.4, height_px / dpi * 25.4)
|
||||
|
||||
|
||||
@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)."""
|
||||
"""Un champ déjà résolu en texte (plusieurs lignes séparées par `\\n`
|
||||
possibles — ex. un poste par ligne avec son horaire), positionné depuis
|
||||
le coin haut-gauche du badge (comme dans l'éditeur visuel du front-end)."""
|
||||
|
||||
text: str
|
||||
x_mm: float
|
||||
@@ -53,64 +70,92 @@ class BadgeSpec:
|
||||
fields: tuple[BadgeFieldSpec, ...]
|
||||
|
||||
|
||||
def _grid_size() -> tuple[int, int]:
|
||||
def _grid_size(badge_width_mm: float, badge_height_mm: float) -> 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)))
|
||||
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]:
|
||||
def _cell_origin(
|
||||
index: int, cols: int, badge_width_mm: float, badge_height_mm: float
|
||||
) -> 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
|
||||
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:
|
||||
def _draw_background(
|
||||
c: canvas.Canvas,
|
||||
image: bytes | None,
|
||||
x0_mm: float,
|
||||
y0_mm: float,
|
||||
badge_width_mm: float,
|
||||
badge_height_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,
|
||||
width=badge_width_mm * mm,
|
||||
height=badge_height_mm * mm,
|
||||
# Le format du badge est désormais dérivé de l'image du recto (voir
|
||||
# `image_size_mm`), donc déjà au bon ratio ; `True` évite en plus
|
||||
# toute déformation du verso s'il n'a pas exactement le même ratio.
|
||||
preserveAspectRatio=True,
|
||||
anchor="c",
|
||||
mask="auto",
|
||||
)
|
||||
|
||||
|
||||
def _draw_fields(
|
||||
c: canvas.Canvas, fields: tuple[BadgeFieldSpec, ...], x0_mm: float, y0_mm: float
|
||||
c: canvas.Canvas,
|
||||
fields: tuple[BadgeFieldSpec, ...],
|
||||
x0_mm: float,
|
||||
y0_mm: float,
|
||||
badge_height_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.font_size` est en points (unité reportlab) ; le reste du calcul
|
||||
# travaille en mm ⇒ on convertit d'abord (1 pt = 1/mm mm, `mm` étant
|
||||
# ici le facteur de conversion pt/mm de reportlab).
|
||||
font_size_mm = f.font_size / mm
|
||||
line_height_mm = font_size_mm * _LINE_HEIGHT_FACTOR
|
||||
# `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)
|
||||
# dessine depuis le BAS de la page.
|
||||
top_y = y0_mm + badge_height_mm - f.y_mm
|
||||
bottom_limit = top_y - f.height_mm
|
||||
for i, line in enumerate(f.text.split("\n")):
|
||||
baseline_y = top_y - font_size_mm * 0.85 - i * line_height_mm
|
||||
if baseline_y < bottom_limit:
|
||||
break # la ligne déborderait de la boîte du champ
|
||||
if f.align == "CENTER":
|
||||
c.drawCentredString((left_x + f.width_mm / 2) * mm, baseline_y * mm, line)
|
||||
elif f.align == "RIGHT":
|
||||
c.drawRightString((left_x + f.width_mm) * mm, baseline_y * mm, line)
|
||||
else:
|
||||
c.drawString(left_x * mm, baseline_y * mm, line)
|
||||
|
||||
|
||||
def generate_badge_sheets_pdf(
|
||||
badges: list[BadgeSpec],
|
||||
front_image: bytes | None,
|
||||
back_image: bytes | None,
|
||||
badge_width_mm: float = DEFAULT_BADGE_WIDTH_MM,
|
||||
badge_height_mm: float = DEFAULT_BADGE_HEIGHT_MM,
|
||||
) -> 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()
|
||||
cols, rows = _grid_size(badge_width_mm, badge_height_mm)
|
||||
per_page = cols * rows
|
||||
pages = [badges[i : i + per_page] for i in range(0, len(badges), per_page)]
|
||||
|
||||
@@ -118,14 +163,14 @@ def generate_badge_sheets_pdf(
|
||||
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)
|
||||
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm)
|
||||
_draw_background(c, front_image, x0, y0, badge_width_mm, badge_height_mm)
|
||||
_draw_fields(c, badge.fields, x0, y0, badge_height_mm)
|
||||
c.showPage()
|
||||
|
||||
for i in range(len(page_badges)):
|
||||
x0, y0 = _cell_origin(i, cols)
|
||||
_draw_background(c, back_image, x0, y0)
|
||||
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm)
|
||||
_draw_background(c, back_image, x0, y0, badge_width_mm, badge_height_mm)
|
||||
c.showPage()
|
||||
c.save()
|
||||
return buffer.getvalue()
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"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.",
|
||||
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
|
||||
"badge.image_unreadable": "L'image est illisible ou corrompue.",
|
||||
"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.",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Format du badge dérivé de l'image de fond du recto
|
||||
|
||||
Revision ID: 0012_badge_template_size
|
||||
Revises: 0011_badge_template
|
||||
Create Date: 2026-09-21
|
||||
|
||||
`badge_template.front_image_width_mm`/`front_image_height_mm` : taille
|
||||
physique (mm) déduite des pixels de l'image importée (résolution supposée
|
||||
300 dpi, voir `app/badges/pdf.py:image_size_mm`) — remplace le format CR80
|
||||
fixe pour la génération du PDF (`app/api/badges.py`).
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0012_badge_template_size"
|
||||
down_revision: str | None = "0011_badge_template"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"badge_template", sa.Column("front_image_width_mm", sa.Float(), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
"badge_template", sa.Column("front_image_height_mm", sa.Float(), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("badge_template", "front_image_height_mm")
|
||||
op.drop_column("badge_template", "front_image_width_mm")
|
||||
@@ -375,7 +375,13 @@ 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)."""
|
||||
dessus. Images stockées en base (pas de volume de fichiers à gérer).
|
||||
|
||||
Le format du badge (`front_image_width_mm`/`height_mm`) est **dérivé de
|
||||
l'image du recto** à l'import (voir `app/badges/pdf.py:image_size_mm`,
|
||||
résolution supposée 300 dpi) plutôt qu'un format fixe imposé — `None`
|
||||
tant qu'aucune image n'a été importée (l'éditeur retombe alors sur le
|
||||
format CR80 par défaut, voir `DEFAULT_BADGE_WIDTH_MM`)."""
|
||||
|
||||
__tablename__ = "badge_template"
|
||||
|
||||
@@ -385,6 +391,8 @@ class BadgeTemplate(TimestampMixin, table=True):
|
||||
)
|
||||
front_image: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
|
||||
front_image_content_type: str | None = None
|
||||
front_image_width_mm: float | None = None
|
||||
front_image_height_mm: float | None = None
|
||||
back_image: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
|
||||
back_image_content_type: str | None = None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user