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

View file

@ -2,12 +2,9 @@ import asyncio
from datetime import datetime, timezone
from http import HTTPStatus
from io import BytesIO
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
import httpx
import pyqrcode # type: ignore[import-untyped]
from fastapi import (
APIRouter,
Depends,
@ -37,8 +34,6 @@ from lnbits.utils.exchange_rates import (
satoshis_amount_as_fiat,
)
from lnbits.utils.nostr import normalize_public_key
from loguru import logger
from PIL import Image, ImageDraw
from .crud import (
create_event,
@ -76,6 +71,13 @@ from .models import (
effective_payment_methods,
)
from .nostr_hooks import publish_or_delete_nostr_event
from .qr import (
image_png_bytes,
load_qr_logo,
make_qr_png,
render_ticket_card,
ticket_card_filename,
)
from .services import (
refund_tickets,
resend_ticket_email_notification,
@ -570,90 +572,6 @@ def _resolve_frontend_root(data: CreateTicket, request: Request) -> str:
return data.frontend_url.rstrip("/")
_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 (upstream v1.6.8 shape). 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
async def _issue_free_tickets(
*,
event: Event,
@ -1142,13 +1060,10 @@ async def api_ticket_qr(ticket_id: str):
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
)
logo = await _load_qr_logo()
logo = await load_qr_logo()
image = make_qr_png(f"ticket://{ticket_id}", size=300, logo=logo)
output = BytesIO()
image.save(output, format="PNG")
output.seek(0)
return StreamingResponse(
output,
BytesIO(image_png_bytes(image)),
media_type="image/png",
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
@ -1156,3 +1071,32 @@ async def api_ticket_qr(ticket_id: str):
"Expires": "0",
},
)
@qr_api_router.get("/ticket-card/{ticket_id}", response_class=StreamingResponse)
async def api_ticket_card(ticket_id: str):
"""Self-describing ticket PNG (event, when, where, QR, name, id) — the
same image the ticket email attaches. Anonymous like the QR endpoint."""
ticket = await get_ticket(ticket_id)
if not ticket:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
)
event = await get_event(ticket.event)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
logo = await load_qr_logo()
card = render_ticket_card(
ticket, event, logo=logo, site_title=settings.lnbits_site_title
)
filename = ticket_card_filename(ticket, event)
return StreamingResponse(
BytesIO(image_png_bytes(card)),
media_type="image/png",
headers={
"Content-Disposition": f'inline; filename="{filename}"',
"Cache-Control": "no-cache, no-store, must-revalidate",
},
)