feat: attach a self-describing ticket card to the email (1.6.1-aio.10)
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
This commit is contained in:
Padreug 2026-09-08 16:40:25 +02:00
commit 2f8a602bbd
8 changed files with 444 additions and 140 deletions

254
qr.py Normal file
View file

@ -0,0 +1,254 @@
"""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()