"""Badge generation service for creating attendee badges with QR codes.
Generates PDF and PNG badges using reportlab and Pillow respectively,
with embedded QR codes encoding the attendee's access code for check-in
scanning.
"""
import io
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING
from django.core.files.base import ContentFile
from django.utils import timezone
from django_program.registration.badge import Badge, BadgeTemplate
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from collections.abc import Iterator
from django_program.conference.models import Conference
from django_program.registration.attendee import Attendee
from django_program.registration.models import TicketType
_FONT_CACHE: dict[str, str] = {}
def _resolve_font_path(font_name: str) -> str | None:
"""Resolve a font name to a file path.
Searches in order:
1. Absolute path (if the string is already a valid file)
2. Django STATICFILES_DIRS
3. Django STATIC_ROOT
4. Common system font directories
Args:
font_name: Font filename or path (e.g. "Roboto-Bold.ttf" or "/path/to/font.ttf").
Returns:
Resolved absolute path, or ``None`` if not found.
"""
from pathlib import Path # noqa: PLC0415
if not font_name:
return None
if font_name in _FONT_CACHE:
return _FONT_CACHE[font_name]
# Direct path
if Path(font_name).is_file():
_FONT_CACHE[font_name] = font_name
return font_name
from django.conf import settings # noqa: PLC0415
# Search STATICFILES_DIRS
search_dirs: list[str | Path] = []
search_dirs.extend(getattr(settings, "STATICFILES_DIRS", []))
static_root = getattr(settings, "STATIC_ROOT", None)
if static_root:
search_dirs.append(static_root)
# Common system font dirs
search_dirs.extend(
[
"/usr/share/fonts",
"/usr/local/share/fonts",
Path.home() / ".fonts",
Path.home() / "Library/Fonts",
"/System/Library/Fonts",
"/Library/Fonts",
]
)
for base_dir in search_dirs:
base = Path(base_dir)
if not base.exists():
continue
# Direct match
candidate = base / font_name
if candidate.is_file():
_FONT_CACHE[font_name] = str(candidate)
return str(candidate)
# Recursive search
for match in base.rglob(font_name):
if match.is_file():
_FONT_CACHE[font_name] = str(match)
return str(match)
return None
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
"""Convert a hex color string to an RGB tuple.
Args:
hex_color: A hex color like ``"#4338CA"``.
Returns:
Tuple of (red, green, blue) integers 0-255.
"""
hex_color = hex_color.lstrip("#")
return (
int(hex_color[0:2], 16),
int(hex_color[2:4], 16),
int(hex_color[4:6], 16),
)
def _hex_to_reportlab(hex_color: str) -> tuple[float, float, float]:
"""Convert a hex color string to reportlab's 0-1 float RGB.
Args:
hex_color: A hex color like ``"#4338CA"``.
Returns:
Tuple of (red, green, blue) floats 0.0-1.0.
"""
r, g, b = _hex_to_rgb(hex_color)
return r / 255.0, g / 255.0, b / 255.0
@dataclass
class _PDFLayout:
"""Intermediate layout parameters for PDF badge rendering."""
canvas: object
width: float
height: float
margin: float
mm_unit: float
accent_rgb: tuple[float, float, float]
text_rgb: tuple[float, float, float]
font_name: str = "Helvetica-Bold"
font_body: str = "Helvetica"
[docs]
class BadgeGenerationService:
"""Generates badge PDFs and PNGs with QR codes for attendee check-in."""
[docs]
def generate_qr_code(self, data: str, size: int = 200) -> bytes:
"""Generate a QR code PNG as bytes.
Args:
data: The string to encode in the QR code.
size: The pixel dimensions of the output image.
Returns:
PNG image bytes.
"""
import qrcode # noqa: PLC0415
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_M,
box_size=10,
border=2,
)
qr.add_data(data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
img = img.resize((size, size))
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def _get_attendee_display_name(self, attendee: Attendee) -> str:
"""Get the display name for an attendee.
Args:
attendee: The attendee to get the name for.
Returns:
Full name if available, otherwise the username.
"""
user = attendee.user
full_name = f"{user.first_name} {user.last_name}".strip()
return full_name or str(user.username)
def _get_company(self, attendee: Attendee) -> str:
"""Get the company/organization for an attendee from their order billing info.
Args:
attendee: The attendee whose company to look up.
Returns:
The billing company, or an empty string if not found.
"""
if attendee.order is None:
return ""
return str(attendee.order.billing_company or "")
def _get_ticket_type_label(self, attendee: Attendee) -> str:
"""Get the ticket type label for an attendee's order.
Args:
attendee: The attendee whose ticket type to look up.
Returns:
The ticket type name, or ``"General Admission"`` if not found.
"""
if attendee.order is None:
return "General Admission"
for line_item in attendee.order.line_items.all():
if line_item.ticket_type is not None:
return str(line_item.ticket_type.name)
return "General Admission"
def _get_qr_data(self, attendee: Attendee) -> str:
"""Build the QR code payload for an attendee.
Args:
attendee: The attendee to encode.
Returns:
A string like ``"pycon-us-2026:A1B2C3D4"``.
"""
conference_slug = str(attendee.conference.slug)
return f"{conference_slug}:{attendee.access_code}"
@staticmethod
def _register_pdf_font(font_spec: str, register_name: str) -> str | None:
"""Resolve and register a TrueType font for PDF rendering.
Args:
font_spec: Font filename or path to resolve.
register_name: Name to register the font under in reportlab.
Returns:
The registered font name, or ``None`` if registration failed.
"""
if not font_spec:
return None
path = _resolve_font_path(font_spec)
if not path:
return None
from reportlab.pdfbase import pdfmetrics # noqa: PLC0415
from reportlab.pdfbase.ttfonts import TTFont # noqa: PLC0415
try:
pdfmetrics.registerFont(TTFont(register_name, path))
except Exception: # noqa: BLE001
logger.warning("Failed to register font '%s' from '%s'", font_spec, path)
return None
return register_name
def _pdf_centered( # noqa: PLR0913
self, layout: _PDFLayout, text: str, font: str, max_size: int, min_size: int, y: float
) -> float:
"""Draw centered text on a PDF canvas, auto-shrinking to fit.
Args:
layout: PDF layout parameters including canvas and dimensions.
text: The text to draw.
font: The font name.
max_size: Starting font size.
min_size: Minimum font size.
y: Vertical position.
Returns:
The font size that was used.
"""
c = layout.canvas
max_w = layout.width - 2 * layout.margin
font_size = max_size
c.setFont(font, font_size) # type: ignore[attr-defined]
tw = c.stringWidth(text, font, font_size) # type: ignore[attr-defined]
while tw > max_w and font_size > min_size:
font_size -= 1
tw = c.stringWidth(text, font, font_size) # type: ignore[attr-defined]
c.setFont(font, font_size) # type: ignore[attr-defined]
c.drawString((layout.width - tw) / 2, y, text) # type: ignore[attr-defined]
return font_size
def _pdf_draw_name(self, layout: _PDFLayout, attendee: Attendee, content_top: float) -> float:
"""Draw the attendee name as the dominant badge element.
Splits multi-word names across lines for maximum readability.
Args:
layout: PDF layout parameters.
attendee: The attendee.
content_top: Starting y position.
Returns:
Updated content_top position.
"""
display_name = self._get_attendee_display_name(attendee)
name_parts = display_name.split()
mm = layout.mm_unit
font = layout.font_name
if len(name_parts) > 1:
first = name_parts[0]
last = " ".join(name_parts[1:])
font_size = 42.0
for line, y_offset in [(first, 0), (last, 1)]:
font_size = self._pdf_centered(layout, line, font, 42, 18, content_top - y_offset * (font_size + 6))
content_top -= 2 * (font_size + 6) + 4 * mm
else:
font_size = self._pdf_centered(layout, display_name, font, 42, 18, content_top)
content_top -= font_size + 8 * mm
return content_top
def _pdf_draw_background(self, layout: _PDFLayout, template: BadgeTemplate) -> None:
"""Draw background color and optional background image.
Args:
layout: PDF layout parameters.
template: Badge template with background settings.
"""
from reportlab.lib.utils import ImageReader # noqa: PLC0415
c = layout.canvas
c.setFillColorRGB(*_hex_to_reportlab(str(template.background_color))) # type: ignore[attr-defined]
c.rect(0, 0, layout.width, layout.height, fill=1, stroke=0) # type: ignore[attr-defined]
if template.background_image and template.background_image.name:
try:
bg_img = ImageReader(template.background_image.path)
c.drawImage( # type: ignore[attr-defined]
bg_img, 0, 0, width=layout.width, height=layout.height, preserveAspectRatio=True, anchor="c"
)
except FileNotFoundError, OSError:
pass
def _pdf_draw_header(self, layout: _PDFLayout, attendee: Attendee, template: BadgeTemplate) -> float:
"""Draw the accent header bar with logo and conference name.
Args:
layout: PDF layout parameters.
attendee: The attendee (for conference name).
template: Badge template with logo and color settings.
Returns:
The y position below the header.
"""
from reportlab.lib.utils import ImageReader # noqa: PLC0415
mm = layout.mm_unit
c = layout.canvas
header_h = 22 * mm
# Only draw header bar if no custom background image
if not (template.background_image and template.background_image.name):
c.setFillColorRGB(*layout.accent_rgb) # type: ignore[attr-defined]
c.rect(0, layout.height - header_h, layout.width, header_h, fill=1, stroke=0) # type: ignore[attr-defined]
# Logo — left side of header
if template.logo and template.logo.name:
try:
logo_img = ImageReader(template.logo.path)
logo_h = 14 * mm
iw, ih = logo_img.getSize()
logo_w = logo_h * (iw / ih)
logo_x = layout.margin
logo_y = layout.height - header_h + (header_h - logo_h) / 2
c.drawImage(logo_img, logo_x, logo_y, width=logo_w, height=logo_h) # type: ignore[attr-defined]
except FileNotFoundError, OSError:
pass
# Conference name — centered (or right of logo)
if template.show_conference_name:
has_bg_image = template.background_image and template.background_image.name
name_color = layout.text_rgb if has_bg_image else (1, 1, 1)
c.setFillColorRGB(*name_color) # type: ignore[attr-defined]
conf_name = str(attendee.conference.name)
conf_y = layout.height - header_h + (header_h - 18) / 2
self._pdf_centered(layout, conf_name, layout.font_name, 18, 10, conf_y)
return layout.height - header_h
def generate_badge_pdf(self, attendee: Attendee, template: BadgeTemplate) -> bytes:
"""Generate a conference-style portrait badge as PDF.
Fills the page with content: header with logo, huge centered name,
company/email, ticket type, and QR code. Supports custom background
images from a graphic designer.
Args:
attendee: The attendee to generate a badge for.
template: The badge template defining layout and colors.
Returns:
PDF file bytes.
"""
from reportlab.lib.units import mm # noqa: PLC0415
from reportlab.pdfgen import canvas # noqa: PLC0415
width = template.width_mm * mm
height = template.height_mm * mm
margin = 6 * mm
accent_rgb = _hex_to_reportlab(str(template.accent_color))
text_rgb = _hex_to_reportlab(str(template.text_color))
buf = io.BytesIO()
c = canvas.Canvas(buf, pagesize=(width, height))
# Resolve custom fonts
name_font = "Helvetica-Bold"
body_font = "Helvetica"
font_name_str = str(template.font_name) if template.font_name else ""
font_body_str = str(template.font_body) if template.font_body else ""
name_font = self._register_pdf_font(font_name_str, "CustomName") or name_font
body_font = self._register_pdf_font(font_body_str, "CustomBody") or body_font
layout = _PDFLayout(
canvas=c,
width=width,
height=height,
margin=margin,
mm_unit=mm,
accent_rgb=accent_rgb,
text_rgb=text_rgb,
font_name=name_font,
font_body=body_font,
)
self._pdf_draw_background(layout, template)
header_bottom = self._pdf_draw_header(layout, attendee, template)
ticket_label = self._get_ticket_type_label(attendee) if template.show_ticket_type else ""
banner_pos = str(template.ticket_banner_position)
# Draw banner at configured position (below_header draws it now, others defer to body)
if banner_pos == BadgeTemplate.BannerPosition.BELOW_HEADER:
header_bottom = self._pdf_draw_ticket_banner(layout, ticket_label, header_bottom)
ticket_label = "" # consumed
qr_zone_h = 30 * mm if template.show_qr_code else 8 * mm
self._pdf_draw_body(layout, attendee, template, ticket_label, banner_pos, header_bottom, margin + qr_zone_h)
if template.show_qr_code:
self._pdf_draw_qr(layout, attendee)
c.showPage()
c.save()
return buf.getvalue()
def _pdf_draw_ticket_banner(self, layout: _PDFLayout, ticket_label: str, header_bottom: float) -> float:
"""Draw a colored ticket-type banner below the header for special types.
Speaker, Sponsor, and other non-general ticket types get a prominent
banner. General Admission is shown inline with the body text instead.
Args:
layout: PDF layout parameters.
ticket_label: The ticket type label.
header_bottom: Y position of the bottom of the header.
Returns:
Updated header_bottom position.
"""
if ticket_label and ticket_label != "General Admission":
mm = layout.mm_unit
c = layout.canvas
banner_h = 10 * mm
c.setFillColorRGB(*layout.accent_rgb) # type: ignore[attr-defined]
c.rect(0, header_bottom - banner_h, layout.width, banner_h, fill=1, stroke=0) # type: ignore[attr-defined]
c.setFillColorRGB(1, 1, 1) # type: ignore[attr-defined]
banner_y = header_bottom - banner_h + 3 * mm
self._pdf_centered(layout, ticket_label.upper(), "Helvetica-Bold", 14, 10, banner_y)
return header_bottom - banner_h
return header_bottom
def _pdf_draw_body( # noqa: PLR0913, C901, PLR0912
self,
layout: _PDFLayout,
attendee: Attendee,
template: BadgeTemplate,
ticket_label: str,
banner_pos: str,
header_bottom: float,
content_floor: float,
) -> None:
"""Draw the body content (name, company, email) vertically centered.
Also draws the ticket type banner at the configured position
if it wasn't already drawn below the header.
Args:
layout: PDF layout parameters.
attendee: The attendee.
template: Badge template.
ticket_label: Ticket type label (empty if already drawn).
banner_pos: Banner position from template config.
header_bottom: Top of available content zone.
content_floor: Bottom of available content zone.
"""
c = layout.canvas
mm = layout.mm_unit
is_special = ticket_label and ticket_label != "General Admission"
# Estimate content height for vertical centering
content_h = 0.0
if is_special and banner_pos == BadgeTemplate.BannerPosition.ABOVE_NAME:
content_h += 14 * mm
if template.show_name:
content_h += 50
if is_special and banner_pos == BadgeTemplate.BannerPosition.BELOW_NAME:
content_h += 14 * mm
if template.show_company and self._get_company(attendee):
content_h += 24
if template.show_email:
content_h += 20
if not is_special and ticket_label:
content_h += 20
zone_top = header_bottom - 4 * mm
zone_h = zone_top - content_floor
y = zone_top - max(0, (zone_h - content_h) / 2)
if is_special and banner_pos == BadgeTemplate.BannerPosition.ABOVE_NAME:
y = self._pdf_draw_ticket_banner(layout, ticket_label, y + 10 * mm)
y -= 4 * mm
if template.show_name:
c.setFillColorRGB(*layout.text_rgb) # type: ignore[attr-defined]
y = self._pdf_draw_name(layout, attendee, y)
if is_special and banner_pos == BadgeTemplate.BannerPosition.BELOW_NAME:
y_before = y
y = self._pdf_draw_ticket_banner(layout, ticket_label, y + 10 * mm)
y = min(y, y_before) - 4 * mm
if template.show_company:
company = self._get_company(attendee)
if company:
c.setFillColorRGB(*layout.text_rgb) # type: ignore[attr-defined]
self._pdf_centered(layout, company, layout.font_body, 16, 10, y)
y -= 8 * mm
if template.show_email:
c.setFillColorRGB(*layout.text_rgb) # type: ignore[attr-defined]
self._pdf_centered(layout, str(attendee.user.email), layout.font_body, 13, 8, y)
y -= 7 * mm
if not is_special and ticket_label:
c.setFillColorRGB(*layout.accent_rgb) # type: ignore[attr-defined]
self._pdf_centered(layout, ticket_label, "Helvetica-Bold", 14, 10, y)
if is_special and banner_pos == BadgeTemplate.BannerPosition.BOTTOM:
self._pdf_draw_ticket_banner(layout, ticket_label, content_floor + 14 * mm)
def _pdf_draw_qr(self, layout: _PDFLayout, attendee: Attendee) -> None:
"""Draw QR code with white backing and access code in the bottom-right.
The white backing ensures QR readability on any background color
or custom background image.
Args:
layout: PDF layout parameters.
attendee: The attendee whose QR code to render.
"""
from reportlab.lib.utils import ImageReader # noqa: PLC0415
mm = layout.mm_unit
c = layout.canvas
qr_size = 20 * mm
pad = 2 * mm
qr_bytes = self.generate_qr_code(self._get_qr_data(attendee), size=200)
qr_x = layout.width - qr_size - layout.margin
qr_y = layout.margin + 4 * mm
# White backing with rounded corners for readability on any background
c.setFillColorRGB(1, 1, 1) # type: ignore[attr-defined]
c.setStrokeColorRGB(0.85, 0.85, 0.85) # type: ignore[attr-defined]
c.roundRect( # type: ignore[attr-defined]
qr_x - pad,
qr_y - pad - 3 * mm,
qr_size + 2 * pad,
qr_size + 2 * pad + 5 * mm,
radius=2 * mm,
fill=1,
stroke=1,
)
c.drawImage( # type: ignore[attr-defined]
ImageReader(io.BytesIO(qr_bytes)), qr_x, qr_y, width=qr_size, height=qr_size
)
c.setFillColorRGB(0, 0, 0) # type: ignore[attr-defined]
c.setFont("Courier", 7) # type: ignore[attr-defined]
code_text = str(attendee.access_code)
code_width = c.stringWidth(code_text, "Courier", 7) # type: ignore[attr-defined]
c.drawString(qr_x + (qr_size - code_width) / 2, qr_y - 3 * mm, code_text) # type: ignore[attr-defined]
def _load_png_fonts(self, px_per_mm: float) -> tuple[object, object, object, object]:
"""Load fonts for PNG badge rendering, falling back to defaults.
Args:
px_per_mm: Pixels per millimeter for font sizing.
Returns:
Tuple of (large, medium, small, mono) font objects.
"""
from PIL import ImageFont # noqa: PLC0415
try:
return (
ImageFont.truetype("Helvetica", int(14 * px_per_mm / 2.5)),
ImageFont.truetype("Helvetica", int(8 * px_per_mm / 2.5)),
ImageFont.truetype("Helvetica", int(7 * px_per_mm / 2.5)),
ImageFont.truetype("Courier", int(6 * px_per_mm / 2.5)),
)
except OSError:
default = ImageFont.load_default()
return default, default, default, default
def _draw_png_qr(
self,
img: object,
draw: object,
attendee: Attendee,
layout: _PNGLayout,
) -> None:
"""Draw QR code and access code on a PNG image.
Args:
img: The Pillow Image object.
draw: The Pillow ImageDraw object.
attendee: The attendee whose QR code to render.
layout: Layout parameters (dimensions, colors, fonts).
"""
from PIL import Image # noqa: PLC0415
qr_size = int(18 * layout.px_per_mm)
qr_bytes = self.generate_qr_code(self._get_qr_data(attendee), size=qr_size)
qr_img = Image.open(io.BytesIO(qr_bytes)).resize((qr_size, qr_size))
qr_x = layout.width - qr_size - layout.margin
qr_y = layout.height - qr_size - layout.margin - int(3 * layout.px_per_mm)
img.paste(qr_img, (qr_x, qr_y)) # type: ignore[union-attr]
code_text = str(attendee.access_code)
code_bbox = draw.textbbox((0, 0), code_text, font=layout.font_mono) # type: ignore[union-attr]
code_width = code_bbox[2] - code_bbox[0]
code_x = qr_x + (qr_size - code_width) // 2
code_y = layout.height - layout.margin
draw.text((code_x, code_y), code_text, fill=layout.text_color, font=layout.font_mono) # type: ignore[union-attr]
def generate_badge_png(self, attendee: Attendee, template: BadgeTemplate) -> bytes:
"""Generate a single badge as a PNG using Pillow.
Args:
attendee: The attendee to generate a badge for.
template: The badge template defining layout and colors.
Returns:
PNG image bytes.
"""
from PIL import Image, ImageDraw # noqa: PLC0415
dpi = 300
px_per_mm = dpi / 25.4
width = int(template.width_mm * px_per_mm)
height = int(template.height_mm * px_per_mm)
margin = int(4 * px_per_mm)
bar_height = int(6 * px_per_mm)
bg_color = _hex_to_rgb(str(template.background_color))
text_color = _hex_to_rgb(str(template.text_color))
accent_color = _hex_to_rgb(str(template.accent_color))
img = Image.new("RGB", (width, height), bg_color)
draw = ImageDraw.Draw(img)
draw.rectangle([0, 0, width, bar_height], fill=accent_color)
font_large, font_medium, font_small, font_mono = self._load_png_fonts(px_per_mm)
y_cursor = bar_height + int(2 * px_per_mm)
if template.show_conference_name:
draw.text((margin, y_cursor), str(attendee.conference.name), fill=accent_color, font=font_medium)
y_cursor += int(5 * px_per_mm)
if template.show_name:
name = self._get_attendee_display_name(attendee)
draw.text((margin, y_cursor), name, fill=text_color, font=font_large)
y_cursor += int(6 * px_per_mm)
if template.show_email:
draw.text((margin, y_cursor), str(attendee.user.email), fill=text_color, font=font_small)
y_cursor += int(4 * px_per_mm)
if template.show_company:
company = self._get_company(attendee)
if company:
draw.text((margin, y_cursor), company, fill=text_color, font=font_small)
y_cursor += int(4 * px_per_mm)
if template.show_ticket_type:
label = self._get_ticket_type_label(attendee)
draw.text((margin, y_cursor), label, fill=accent_color, font=font_medium)
if template.show_qr_code:
layout = _PNGLayout(
width=width,
height=height,
margin=margin,
px_per_mm=px_per_mm,
text_color=text_color,
font_mono=font_mono,
)
self._draw_png_qr(img, draw, attendee, layout)
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def generate_or_get_badge(
self,
attendee: Attendee,
template: BadgeTemplate | None = None,
badge_format: str = "pdf",
) -> Badge:
"""Generate a badge and save it, or return an existing one.
If a badge already exists for the given attendee, template, and format
combination, it is returned without regenerating. Otherwise a new badge
is generated and persisted.
Args:
attendee: The attendee to generate a badge for.
template: The badge template to use. If ``None``, the conference
default template is used.
badge_format: Output format — ``"pdf"`` or ``"png"``.
Returns:
The existing or newly created ``Badge`` instance.
Raises:
ValueError: If no template is provided and no default exists.
"""
valid_formats = {Badge.Format.PDF, Badge.Format.PNG}
if badge_format not in valid_formats:
msg = f"Unsupported badge format '{badge_format}'. Must be one of: {', '.join(sorted(valid_formats))}"
raise ValueError(msg)
if template is None:
template = BadgeTemplate.objects.filter(
conference=attendee.conference,
is_default=True,
).first()
if template is None:
msg = f"No default badge template found for conference '{attendee.conference.slug}'"
raise ValueError(msg)
existing = Badge.objects.filter(
attendee=attendee,
template=template,
format=badge_format,
).first()
if existing and existing.file:
return existing
if badge_format == Badge.Format.PNG:
content = self.generate_badge_png(attendee, template)
ext = "png"
else:
content = self.generate_badge_pdf(attendee, template)
ext = "pdf"
badge = existing or Badge(
attendee=attendee,
template=template,
format=badge_format,
)
filename = f"badge-{attendee.access_code}.{ext}"
badge.file.save(filename, ContentFile(content), save=False)
badge.generated_at = timezone.now()
badge.save()
return badge
def bulk_generate_badges(
self,
conference: Conference,
template: BadgeTemplate | None = None,
badge_format: str = "pdf",
ticket_type: TicketType | None = None,
) -> Iterator[Badge]:
"""Generate badges for all attendees of a conference.
Yields badges as they are generated, allowing progress tracking.
Optionally filters attendees by ticket type.
Args:
conference: The conference whose attendees need badges.
template: The badge template to use. If ``None``, the conference
default template is used.
badge_format: Output format — ``"pdf"`` or ``"png"``.
ticket_type: When provided, only generate badges for attendees
whose order contains this ticket type.
Yields:
``Badge`` instances as they are generated.
Raises:
ValueError: If no template is provided and no default exists.
"""
from django.db.models import Prefetch # noqa: PLC0415
from django_program.registration.attendee import Attendee # noqa: PLC0415
from django_program.registration.models import OrderLineItem # noqa: PLC0415
queryset = (
Attendee.objects.filter(conference=conference)
.select_related(
"user",
"conference",
"order",
)
.prefetch_related(
Prefetch(
"order__line_items",
queryset=OrderLineItem.objects.filter(ticket_type__isnull=False).select_related("ticket_type"),
),
)
)
if ticket_type is not None:
queryset = queryset.filter(
order__line_items__ticket_type=ticket_type,
).distinct()
for attendee in queryset:
yield self.generate_or_get_badge(attendee, template=template, badge_format=badge_format)
@dataclass
class _PNGLayout:
"""Intermediate layout parameters for PNG badge rendering."""
width: int
height: int
margin: int
px_per_mm: float
text_color: tuple[int, int, int]
font_mono: object