Some checks failed
lint.yml / feat: attach a self-describing ticket card to the email (1.6.1-aio.10) (pull_request) Failing after 0s
The v1.6.1-aio.9 mail still scored 8.4/10 on mail-tester: the remaining
deduction was HTML_IMAGE_ONLY (1.8) — an HTML part whose only content of
note is a remote <img>. Remote images are also blocked by default in most
clients until the reader opts in, and a bare QR saved from that mail says
nothing about what it opens.
- New `qr.py` module (QR + logo helpers moved out of views_api) with
`render_ticket_card`: site title, event name, when/where, the branded
QR, name on ticket, ticket id and the door instruction, laid out with
the bundled DejaVu Sans; `format_event_when` gives "Fri 19 Feb 2027,
16:00 - 20:00"; filenames are `ticket-<event-slug>-<id8>.png`.
- `GET /events/api/v1/ticket-card/{ticket_id}` serves the same PNG
(anonymous, like the QR endpoint); the email's "Ticket image" link
now points there.
- The ticket email becomes multipart/mixed: text + HTML alternatives
(URLs as links, no <img>) plus the card as a PNG attachment, which
clients show inline at the end of the message and which works offline
at the door.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
254 lines
8.2 KiB
Python
254 lines
8.2 KiB
Python
"""QR + ticket-card rendering shared by the API and the mailer.
|
|
|
|
`make_qr_png` is upstream v1.6.8's helper (pyqrcode + Pillow) with the
|
|
instance QR logo pasted in the centre; `render_ticket_card` wraps it in a
|
|
self-describing card (event, when, where, name, ticket id) so the PNG a
|
|
buyer saves from the email still says what it is for.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from datetime import datetime
|
|
from io import BytesIO
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pyqrcode # type: ignore[import-untyped]
|
|
from lnbits.settings import settings
|
|
from loguru import logger
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
from .models import Event, Ticket
|
|
|
|
_qr_logo_cache: dict[str, Image.Image | None] = {}
|
|
|
|
|
|
async def load_qr_logo() -> Image.Image | None:
|
|
"""LNbits' "QR Code/Favicon Logo" setting, as a Pillow image (cached).
|
|
|
|
Local `/static/...` values resolve inside the LNbits package; absolute
|
|
URLs are fetched once. Any failure just yields a plain QR.
|
|
"""
|
|
source = (settings.lnbits_qr_logo or "").strip()
|
|
if not source:
|
|
return None
|
|
if source in _qr_logo_cache:
|
|
return _qr_logo_cache[source]
|
|
logo: Image.Image | None = None
|
|
try:
|
|
if source.startswith(("http://", "https://")):
|
|
async with httpx.AsyncClient(timeout=5) as client:
|
|
resp = await client.get(source)
|
|
resp.raise_for_status()
|
|
logo = Image.open(BytesIO(resp.content)).convert("RGBA")
|
|
else:
|
|
local = Path(settings.lnbits_path) / source.lstrip("/")
|
|
if local.is_file():
|
|
logo = Image.open(local).convert("RGBA")
|
|
except Exception as exc:
|
|
logger.warning(f"QR logo '{source}' unavailable: {exc}")
|
|
logo = None
|
|
_qr_logo_cache[source] = logo
|
|
return logo
|
|
|
|
|
|
def make_qr_png(
|
|
data: str,
|
|
size: int = 235,
|
|
border: int = 4,
|
|
logo: Image.Image | None = None,
|
|
) -> Image.Image:
|
|
"""Render `data` as a QR image. With `logo`, the code is built at
|
|
error-correction level H and the logo is pasted in the centre on a white
|
|
pad at ≤ 20 % of the width — the same look LNbits' client-side
|
|
`lnbits-qrcode` component produces."""
|
|
qr = pyqrcode.create(data, error="H" if logo is not None else "M")
|
|
matrix = qr.code
|
|
modules = len(matrix)
|
|
|
|
total_modules = modules + border * 2
|
|
box_size = max(1, size // total_modules)
|
|
img_size = total_modules * box_size
|
|
|
|
img = Image.new("RGBA", (img_size, img_size), "white")
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
for y, row in enumerate(matrix):
|
|
for x, cell in enumerate(row):
|
|
if cell:
|
|
x0 = (x + border) * box_size
|
|
y0 = (y + border) * box_size
|
|
draw.rectangle(
|
|
[x0, y0, x0 + box_size - 1, y0 + box_size - 1],
|
|
fill="black",
|
|
)
|
|
|
|
if img_size != size:
|
|
img = img.resize((size, size), Image.Resampling.NEAREST)
|
|
|
|
if logo is not None:
|
|
logo_size = max(8, int(size * 0.2))
|
|
pad = max(2, logo_size // 8)
|
|
scaled = logo.copy()
|
|
scaled.thumbnail((logo_size, logo_size), Image.Resampling.LANCZOS)
|
|
plate = Image.new(
|
|
"RGBA", (scaled.width + 2 * pad, scaled.height + 2 * pad), "white"
|
|
)
|
|
plate.paste(scaled, (pad, pad), scaled)
|
|
img.paste(
|
|
plate,
|
|
((size - plate.width) // 2, (size - plate.height) // 2),
|
|
plate,
|
|
)
|
|
|
|
return img
|
|
|
|
|
|
def _parse_iso(value: str | None) -> datetime | None:
|
|
if not value:
|
|
return None
|
|
try:
|
|
return datetime.fromisoformat(value)
|
|
except ValueError:
|
|
try:
|
|
return datetime.strptime(value[:10], "%Y-%m-%d")
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def format_event_when(event: Event) -> str:
|
|
"""'Fri 19 Feb 2027, 16:00 - 20:00' (same day) or a full range; the raw
|
|
strings when they do not parse."""
|
|
start = _parse_iso(event.event_start_date)
|
|
end = _parse_iso(event.event_end_date)
|
|
if not start:
|
|
return event.event_start_date or ""
|
|
has_time = "T" in (event.event_start_date or "")
|
|
day = start.strftime("%a %d %b %Y")
|
|
if not end or end == start:
|
|
return f"{day}, {start.strftime('%H:%M')}" if has_time else day
|
|
if end.date() == start.date():
|
|
if has_time:
|
|
return f"{day}, {start.strftime('%H:%M')} - {end.strftime('%H:%M')}"
|
|
return day
|
|
end_day = end.strftime("%a %d %b %Y")
|
|
if has_time:
|
|
return f"{day} {start.strftime('%H:%M')} - {end_day} {end.strftime('%H:%M')}"
|
|
return f"{day} - {end_day}"
|
|
|
|
|
|
def ticket_card_filename(ticket: Ticket, event: Event) -> str:
|
|
slug = re.sub(r"[^a-z0-9]+", "-", event.name.lower()).strip("-")[:40] or "event"
|
|
return f"ticket-{slug}-{ticket.id[:8]}.png"
|
|
|
|
|
|
_FONT_DIR = Path(__file__).resolve().parent / "static" / "fonts"
|
|
|
|
|
|
def _font(
|
|
size: int, bold: bool = False
|
|
) -> ImageFont.ImageFont | ImageFont.FreeTypeFont:
|
|
"""DejaVu Sans shipped with the extension (full Latin coverage — the
|
|
Pillow-bundled default lacks accented glyphs, so 'Château' would render
|
|
as tofu); Pillow's default is the fallback."""
|
|
path = _FONT_DIR / ("DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf")
|
|
try:
|
|
return ImageFont.truetype(str(path), size)
|
|
except OSError:
|
|
try:
|
|
return ImageFont.load_default(size=size)
|
|
except Exception: # very old Pillow: bitmap default only
|
|
return ImageFont.load_default()
|
|
|
|
|
|
def _wrap(draw: ImageDraw.ImageDraw, text: str, font, max_width: int) -> list[str]:
|
|
lines: list[str] = []
|
|
for paragraph in text.split("\n"):
|
|
words = paragraph.split()
|
|
line = ""
|
|
for word in words:
|
|
candidate = f"{line} {word}".strip()
|
|
if draw.textlength(candidate, font=font) <= max_width or not line:
|
|
line = candidate
|
|
else:
|
|
lines.append(line)
|
|
line = word
|
|
lines.append(line)
|
|
return lines
|
|
|
|
|
|
def render_ticket_card(
|
|
ticket: Ticket,
|
|
event: Event,
|
|
*,
|
|
logo: Image.Image | None = None,
|
|
site_title: str | None = None,
|
|
width: int = 800,
|
|
) -> Image.Image:
|
|
"""A self-describing ticket: header (site), event name, when/where, the
|
|
QR, then name on ticket + ticket id + door instruction."""
|
|
pad = 48
|
|
inner = width - 2 * pad
|
|
title_font = _font(40, bold=True)
|
|
body_font = _font(28)
|
|
small_font = _font(22)
|
|
mono_font = _font(24)
|
|
|
|
# Measure first: the card grows with the wrapped title.
|
|
probe = ImageDraw.Draw(Image.new("RGB", (width, 10), "white"))
|
|
title_lines = _wrap(probe, event.name, title_font, inner)
|
|
when = format_event_when(event)
|
|
meta_lines = [when] if when else []
|
|
if event.location:
|
|
meta_lines += _wrap(probe, event.location, body_font, inner)
|
|
qr_size = min(inner, 560)
|
|
detail_lines = []
|
|
if ticket.name:
|
|
detail_lines.append(f"Name: {ticket.name}")
|
|
detail_lines.append(f"Ticket {ticket.id}")
|
|
|
|
y = pad
|
|
y += 30 + 16 # site title line
|
|
y += len(title_lines) * 52 + 12
|
|
y += len(meta_lines) * 36 + 28
|
|
qr_y = y
|
|
y += qr_size + 28
|
|
y += len(detail_lines) * 36 + 12
|
|
y += 30 + pad # footer
|
|
height = y
|
|
|
|
img = Image.new("RGB", (width, height), "white")
|
|
draw = ImageDraw.Draw(img)
|
|
grey = (110, 110, 110)
|
|
black = (20, 20, 20)
|
|
|
|
y = pad
|
|
draw.text((pad, y), (site_title or "Ticket").upper(), fill=grey, font=small_font)
|
|
y += 30 + 16
|
|
for line in title_lines:
|
|
draw.text((pad, y), line, fill=black, font=title_font)
|
|
y += 52
|
|
y += 12
|
|
for line in meta_lines:
|
|
draw.text((pad, y), line, fill=black, font=body_font)
|
|
y += 36
|
|
y += 28
|
|
|
|
qr = make_qr_png(f"ticket://{ticket.id}", size=qr_size, logo=logo).convert("RGB")
|
|
img.paste(qr, ((width - qr_size) // 2, qr_y))
|
|
y = qr_y + qr_size + 28
|
|
|
|
for line in detail_lines:
|
|
font = mono_font if line.startswith("Ticket ") else body_font
|
|
draw.text((pad, y), line, fill=black, font=font)
|
|
y += 36
|
|
y += 12
|
|
draw.text((pad, y), "Show this QR code at the door.", fill=grey, font=small_font)
|
|
return img
|
|
|
|
|
|
def image_png_bytes(img: Image.Image) -> bytes:
|
|
out = BytesIO()
|
|
img.save(out, format="PNG")
|
|
return out.getvalue()
|