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
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:
parent
defe6d5b00
commit
2f8a602bbd
8 changed files with 444 additions and 140 deletions
254
qr.py
Normal file
254
qr.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue