feat: attach a self-describing ticket card to the email (v1.6.1-aio.10) #43
8 changed files with 444 additions and 140 deletions
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
commit
2f8a602bbd
11
README.md
11
README.md
|
|
@ -62,10 +62,13 @@ Events includes a shareable ticket scanner, which can be used to register attend
|
||||||
- **Stripe session.** The buyer's email is passed as `customer_email`
|
- **Stripe session.** The buyer's email is passed as `customer_email`
|
||||||
(prefilled and locked on the hosted page); the line item is named after the
|
(prefilled and locked on the hosted page); the line item is named after the
|
||||||
event; `event_id`, `quantity` and `ticket_ids` ride along as metadata.
|
event; `event_id`, `quantity` and `ticket_ids` ride along as metadata.
|
||||||
- **Email.** Multipart text + HTML with the ticket QR embedded from
|
- **Email.** Multipart text + HTML (links, no images) with the **ticket card**
|
||||||
`GET /events/api/v1/qr/{ticket_id}` (PNG, branded with the instance QR logo).
|
attached — a self-describing PNG (site, event, when, where, QR with the
|
||||||
`POST /events/api/v1/tickets/{ticket_id}/resend-email` returns a
|
instance logo, name on ticket, ticket id) also served at
|
||||||
`TicketResendResult` with per-channel outcome.
|
`GET /events/api/v1/ticket-card/{ticket_id}`; the bare QR stays at
|
||||||
|
`GET /events/api/v1/qr/{ticket_id}`. Headers carry Date, Message-ID and a
|
||||||
|
From display name (site title). `POST /events/api/v1/tickets/{ticket_id}/resend-email`
|
||||||
|
returns a `TicketResendResult` with per-channel outcome.
|
||||||
|
|
||||||
## Powered by LNbits
|
## Powered by LNbits
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"id": "events",
|
"id": "events",
|
||||||
"version": "1.6.1-aio.9",
|
"version": "1.6.1-aio.10",
|
||||||
"name": "Events",
|
"name": "Events",
|
||||||
"repo": "https://git.atitlan.io/aiolabs/events",
|
"repo": "https://git.atitlan.io/aiolabs/events",
|
||||||
"short_description": "Sell and register event tickets",
|
"short_description": "Sell and register event tickets",
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,16 @@ Running log of fork features that are shaped so they could be offered to
|
||||||
`lnbits/events` (or `lnbits/lnbits`). Add a row whenever a change lands here
|
`lnbits/events` (or `lnbits/lnbits`). Add a row whenever a change lands here
|
||||||
in an upstream-compatible form; strike it when the PR merges upstream.
|
in an upstream-compatible form; strike it when the PR merges upstream.
|
||||||
|
|
||||||
| Feature | Where | Upstream target | Readiness |
|
| Feature | Where | Upstream target | Readiness |
|
||||||
| --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
| ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||||
| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR |
|
| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR |
|
||||||
| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above |
|
| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above |
|
||||||
| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch |
|
| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch |
|
||||||
| `extra.payment_methods` per event + `tickets_payment_methods` NIP-52 tag | `models.py`, `nostr_publisher.py` | lnbits/events (v2 PR #64 introduces the same field) | offer as review input on #64 |
|
| `extra.payment_methods` per event + `tickets_payment_methods` NIP-52 tag | `models.py`, `nostr_publisher.py` | lnbits/events (v2 PR #64 introduces the same field) | offer as review input on #64 |
|
||||||
| `asyncio.to_thread` around the smtplib send | `services.py` `_send_ticket_email_notification` | lnbits/events | trivial, standalone |
|
| `asyncio.to_thread` around the smtplib send | `services.py` `_send_ticket_email_notification` | lnbits/events | trivial, standalone |
|
||||||
| QR logo overlay in `make_qr_png` (instance `lnbits_qr_logo`) | `views_api.py` | lnbits/events | standalone |
|
| QR logo overlay in `make_qr_png` (instance `lnbits_qr_logo`) | `views_api.py` | lnbits/events | standalone |
|
||||||
| Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 |
|
| Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 |
|
||||||
| Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone |
|
| Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone |
|
||||||
| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 |
|
| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 |
|
||||||
| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 |
|
| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 |
|
||||||
|
| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `<img>` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) |
|
||||||
|
|
|
||||||
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()
|
||||||
92
services.py
92
services.py
|
|
@ -1,8 +1,10 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import re
|
||||||
import smtplib
|
import smtplib
|
||||||
from asyncio.tasks import create_task
|
from asyncio.tasks import create_task
|
||||||
|
from email.mime.image import MIMEImage
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.utils import formataddr, formatdate, make_msgid
|
from email.utils import formataddr, formatdate, make_msgid
|
||||||
|
|
@ -26,6 +28,13 @@ from .crud import (
|
||||||
)
|
)
|
||||||
from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult
|
from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult
|
||||||
from .nostr_hooks import publish_or_delete_nostr_event
|
from .nostr_hooks import publish_or_delete_nostr_event
|
||||||
|
from .qr import (
|
||||||
|
format_event_when,
|
||||||
|
image_png_bytes,
|
||||||
|
load_qr_logo,
|
||||||
|
render_ticket_card,
|
||||||
|
ticket_card_filename,
|
||||||
|
)
|
||||||
|
|
||||||
DEFAULT_NOSTR_RELAYS = [
|
DEFAULT_NOSTR_RELAYS = [
|
||||||
"wss://relay.damus.io",
|
"wss://relay.damus.io",
|
||||||
|
|
@ -118,17 +127,15 @@ def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str
|
||||||
def _ticket_details(ticket: Ticket, event: Event) -> str:
|
def _ticket_details(ticket: Ticket, event: Event) -> str:
|
||||||
"""Human-readable ticket facts for the email body. Also what keeps the
|
"""Human-readable ticket facts for the email body. Also what keeps the
|
||||||
mail from being an image with no words (SpamAssassin HTML_IMAGE_ONLY)."""
|
mail from being an image with no words (SpamAssassin HTML_IMAGE_ONLY)."""
|
||||||
when = event.event_start_date
|
lines = [f"Event: {event.name}", f"When: {format_event_when(event)}"]
|
||||||
if event.event_end_date and event.event_end_date != event.event_start_date:
|
|
||||||
when = f"{when} to {event.event_end_date}"
|
|
||||||
lines = [f"Event: {event.name}", f"When: {when}"]
|
|
||||||
if event.location:
|
if event.location:
|
||||||
lines.append(f"Where: {event.location}")
|
lines.append(f"Where: {event.location}")
|
||||||
if ticket.name:
|
if ticket.name:
|
||||||
lines.append(f"Name on ticket: {ticket.name}")
|
lines.append(f"Name on ticket: {ticket.name}")
|
||||||
lines.append(f"Ticket ID: {ticket.id}")
|
lines.append(f"Ticket ID: {ticket.id}")
|
||||||
lines.append(
|
lines.append(
|
||||||
"Show the QR code below (or open the link above on your phone) at the "
|
"Your ticket (with its QR code) is attached to this email — save it "
|
||||||
|
"or open the link above on your phone, and show the QR code at the "
|
||||||
"door to be scanned in."
|
"door to be scanned in."
|
||||||
)
|
)
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
@ -142,14 +149,18 @@ def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) ->
|
||||||
|
|
||||||
|
|
||||||
def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) -> str:
|
def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) -> str:
|
||||||
|
"""HTML twin of the text part. Deliberately no <img>: the card travels
|
||||||
|
as an attachment (renders inline in most clients, works offline, and
|
||||||
|
keeps SpamAssassin's HTML_IMAGE_ONLY rules quiet), and URLs become
|
||||||
|
links."""
|
||||||
text_message = _ticket_delivery_message(ticket, event, base_message)
|
text_message = _ticket_delivery_message(ticket, event, base_message)
|
||||||
html_message = f"<p>{escape(text_message).replace(chr(10), '<br />')}</p>"
|
html = escape(text_message)
|
||||||
image_url = escape(_ticket_image_url(ticket), quote=True)
|
html = re.sub(
|
||||||
return (
|
r"(https?://[^\s<]+)",
|
||||||
f"{html_message}"
|
lambda m: f'<a href="{m.group(1)}">{m.group(1)}</a>',
|
||||||
f'<p><img src="{image_url}" alt="Ticket QR code" '
|
html,
|
||||||
'style="max-width: 300px; height: auto;" /></p>'
|
|
||||||
)
|
)
|
||||||
|
return f"<p>{html.replace(chr(10), '<br />')}</p>"
|
||||||
|
|
||||||
|
|
||||||
def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]:
|
def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]:
|
||||||
|
|
@ -195,8 +206,20 @@ async def _deliver_ticket_notifications(
|
||||||
if result.email.attempted:
|
if result.email.attempted:
|
||||||
try:
|
try:
|
||||||
assert ticket.email
|
assert ticket.email
|
||||||
|
card = render_ticket_card(
|
||||||
|
ticket,
|
||||||
|
event,
|
||||||
|
logo=await load_qr_logo(),
|
||||||
|
site_title=settings.lnbits_site_title,
|
||||||
|
)
|
||||||
await _send_ticket_email_notification(
|
await _send_ticket_email_notification(
|
||||||
[ticket.email], text_message, subject, html_message
|
[ticket.email],
|
||||||
|
text_message,
|
||||||
|
subject,
|
||||||
|
html_message,
|
||||||
|
attachments=[
|
||||||
|
(ticket_card_filename(ticket, event), image_png_bytes(card))
|
||||||
|
],
|
||||||
)
|
)
|
||||||
ticket.extra.email_notification_sent = True
|
ticket.extra.email_notification_sent = True
|
||||||
result.email.sent = True
|
result.email.sent = True
|
||||||
|
|
@ -227,6 +250,7 @@ async def _send_ticket_email_notification(
|
||||||
message: str,
|
message: str,
|
||||||
subject: str,
|
subject: str,
|
||||||
html_message: str | None = None,
|
html_message: str | None = None,
|
||||||
|
attachments: list[tuple[str, bytes]] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Multipart (text + HTML) ticket email through the instance SMTP
|
"""Multipart (text + HTML) ticket email through the instance SMTP
|
||||||
settings. Core's `send_email_notification` is plain-text only, which is
|
settings. Core's `send_email_notification` is plain-text only, which is
|
||||||
|
|
@ -244,7 +268,9 @@ async def _send_ticket_email_notification(
|
||||||
if not is_valid_email_address(address):
|
if not is_valid_email_address(address):
|
||||||
raise ValueError(f"Invalid email address: {address}")
|
raise ValueError(f"Invalid email address: {address}")
|
||||||
|
|
||||||
msg = build_ticket_email(from_email, to_emails, subject, message, html_message)
|
msg = build_ticket_email(
|
||||||
|
from_email, to_emails, subject, message, html_message, attachments
|
||||||
|
)
|
||||||
username = settings.lnbits_email_notifications_username or from_email
|
username = settings.lnbits_email_notifications_username or from_email
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
_smtp_send,
|
_smtp_send,
|
||||||
|
|
@ -264,22 +290,34 @@ def build_ticket_email(
|
||||||
subject: str,
|
subject: str,
|
||||||
message: str,
|
message: str,
|
||||||
html_message: str | None = None,
|
html_message: str | None = None,
|
||||||
|
attachments: list[tuple[str, bytes]] | None = None,
|
||||||
) -> MIMEMultipart:
|
) -> MIMEMultipart:
|
||||||
"""Assemble the multipart ticket email with the headers receivers score
|
"""Assemble the ticket email: text + HTML alternatives, PNG attachments
|
||||||
on: a Date and a Message-ID (their absence is what SpamAssassin's
|
(the ticket card), and the headers receivers score on — a Date and a
|
||||||
MISSING_DATE / MISSING_MID flag, and what Gmail/Outlook read as
|
Message-ID (their absence is what SpamAssassin's MISSING_DATE /
|
||||||
machine-generated), and a display name on From so the sender is not a
|
MISSING_MID flag, and what Gmail/Outlook read as machine-generated) and
|
||||||
bare address."""
|
a display name on From so the sender is not a bare address."""
|
||||||
msg = MIMEMultipart("alternative")
|
body = MIMEMultipart("alternative")
|
||||||
|
body.attach(MIMEText(message, "plain"))
|
||||||
|
if html_message:
|
||||||
|
body.attach(MIMEText(html_message, "html"))
|
||||||
|
|
||||||
|
if attachments:
|
||||||
|
msg = MIMEMultipart("mixed")
|
||||||
|
msg.attach(body)
|
||||||
|
for filename, data in attachments:
|
||||||
|
part = MIMEImage(data, _subtype="png")
|
||||||
|
part.add_header("Content-Disposition", "attachment", filename=filename)
|
||||||
|
msg.attach(part)
|
||||||
|
else:
|
||||||
|
msg = body
|
||||||
|
|
||||||
sender_name = (settings.lnbits_site_title or "").strip() or "Tickets"
|
sender_name = (settings.lnbits_site_title or "").strip() or "Tickets"
|
||||||
msg["From"] = formataddr((sender_name, from_email))
|
msg["From"] = formataddr((sender_name, from_email))
|
||||||
msg["To"] = ", ".join(to_emails)
|
msg["To"] = ", ".join(to_emails)
|
||||||
msg["Subject"] = subject
|
msg["Subject"] = subject
|
||||||
msg["Date"] = formatdate(localtime=True)
|
msg["Date"] = formatdate(localtime=True)
|
||||||
msg["Message-ID"] = make_msgid(domain=from_email.rsplit("@", 1)[-1])
|
msg["Message-ID"] = make_msgid(domain=from_email.rsplit("@", 1)[-1])
|
||||||
msg.attach(MIMEText(message, "plain"))
|
|
||||||
if html_message:
|
|
||||||
msg.attach(MIMEText(html_message, "html"))
|
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -318,11 +356,13 @@ def _ticket_url(ticket: Ticket) -> str:
|
||||||
|
|
||||||
|
|
||||||
def _ticket_image_url(ticket: Ticket) -> str:
|
def _ticket_image_url(ticket: Ticket) -> str:
|
||||||
"""The QR PNG is served by THIS extension on the LNbits host, so it is
|
"""The ticket card PNG is served by THIS extension on the LNbits host, so
|
||||||
built from `lnbits_baseurl` even when `ticket_base_url` points at a
|
it is built from `lnbits_baseurl` even when `ticket_base_url` points at
|
||||||
separate web app (deviation from upstream, which assumes both are the
|
a separate web app (deviation from upstream, which assumes both are the
|
||||||
same host)."""
|
same host)."""
|
||||||
return f"{settings.lnbits_baseurl.rstrip('/')}/events/api/v1/qr/{ticket.id}"
|
return (
|
||||||
|
f"{settings.lnbits_baseurl.rstrip('/')}/events/api/v1/ticket-card/{ticket.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def refund_tickets(event_id: str):
|
async def refund_tickets(event_id: str):
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,25 @@ def test_email_carries_date_message_id_and_display_name(monkeypatch):
|
||||||
assert parts == ["text/plain", "text/html"]
|
assert parts == ["text/plain", "text/html"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_email_attaches_the_ticket_card(monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "lnbits_site_title", "Oyez!")
|
||||||
|
png = b"\x89PNG\r\n\x1a\n" + b"0" * 32
|
||||||
|
msg = build_ticket_email(
|
||||||
|
"tickets@example.org",
|
||||||
|
["ada@example.com"],
|
||||||
|
"Subj",
|
||||||
|
"text",
|
||||||
|
"<p>html</p>",
|
||||||
|
attachments=[("ticket-test-8auNuuaB.png", png)],
|
||||||
|
)
|
||||||
|
assert msg.get_content_type() == "multipart/mixed"
|
||||||
|
body, attachment = msg.get_payload()
|
||||||
|
assert body.get_content_type() == "multipart/alternative"
|
||||||
|
assert attachment.get_content_type() == "image/png"
|
||||||
|
assert attachment.get_filename() == "ticket-test-8auNuuaB.png"
|
||||||
|
assert attachment.get_payload(decode=True) == png
|
||||||
|
|
||||||
|
|
||||||
def test_payload_includes_event_details_and_qr(monkeypatch):
|
def test_payload_includes_event_details_and_qr(monkeypatch):
|
||||||
monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/")
|
monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/")
|
||||||
subject, text, html = _ticket_notification_payload(_ticket(), _event())
|
subject, text, html = _ticket_notification_payload(_ticket(), _event())
|
||||||
|
|
@ -65,5 +84,6 @@ def test_payload_includes_event_details_and_qr(monkeypatch):
|
||||||
"at the door",
|
"at the door",
|
||||||
):
|
):
|
||||||
assert needle in text
|
assert needle in text
|
||||||
assert "/events/api/v1/qr/tkt1" in text
|
assert "/events/api/v1/ticket-card/tkt1" in text
|
||||||
assert "<img" in html and "Ticket ID: tkt1" in html
|
assert "<img" not in html and "Ticket ID: tkt1" in html
|
||||||
|
assert '<a href="https://lnbits.example/events/api/v1/ticket-card/tkt1">' in html
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from io import BytesIO
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from ..views_api import make_qr_png
|
from ..qr import make_qr_png
|
||||||
|
|
||||||
|
|
||||||
def test_make_qr_png_renders_requested_size():
|
def test_make_qr_png_renders_requested_size():
|
||||||
|
|
@ -20,3 +20,45 @@ def test_make_qr_png_pastes_a_centred_logo():
|
||||||
out = BytesIO()
|
out = BytesIO()
|
||||||
img.save(out, format="PNG")
|
img.save(out, format="PNG")
|
||||||
assert out.getvalue().startswith(b"\x89PNG")
|
assert out.getvalue().startswith(b"\x89PNG")
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_ticket_card_is_self_describing():
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from ..models import Event, Ticket
|
||||||
|
from ..qr import format_event_when, render_ticket_card, ticket_card_filename
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
event = Event(
|
||||||
|
id="evt1",
|
||||||
|
wallet="w",
|
||||||
|
name="Château du Faune | Uru Ecstatic Dance",
|
||||||
|
info="",
|
||||||
|
closing_date="2027-02-19",
|
||||||
|
event_start_date="2027-02-19T16:00:00+01:00",
|
||||||
|
event_end_date="2027-02-19T20:00:00+01:00",
|
||||||
|
location="The Chateau",
|
||||||
|
amount_tickets=10,
|
||||||
|
price_per_ticket=15,
|
||||||
|
time=now,
|
||||||
|
)
|
||||||
|
ticket = Ticket(
|
||||||
|
id="8auNuuaBv7TFGj7BYoVnTN",
|
||||||
|
wallet="w",
|
||||||
|
event="evt1",
|
||||||
|
name="Guest Test",
|
||||||
|
email="g@example.com",
|
||||||
|
registered=False,
|
||||||
|
paid=True,
|
||||||
|
time=now,
|
||||||
|
reg_timestamp=now,
|
||||||
|
)
|
||||||
|
assert format_event_when(event) == "Fri 19 Feb 2027, 16:00 - 20:00"
|
||||||
|
assert (
|
||||||
|
ticket_card_filename(ticket, event)
|
||||||
|
== "ticket-ch-teau-du-faune-uru-ecstatic-dance-8auNuuaB.png"
|
||||||
|
)
|
||||||
|
card = render_ticket_card(ticket, event, site_title="Oyez!")
|
||||||
|
assert card.width == 800 and card.height > 800
|
||||||
|
# QR area is centred; its finder pattern is black
|
||||||
|
assert card.getpixel((400, card.height // 2)) in ((0, 0, 0), (255, 255, 255))
|
||||||
|
|
|
||||||
132
views_api.py
132
views_api.py
|
|
@ -2,12 +2,9 @@ import asyncio
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pyqrcode # type: ignore[import-untyped]
|
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
APIRouter,
|
APIRouter,
|
||||||
Depends,
|
Depends,
|
||||||
|
|
@ -37,8 +34,6 @@ from lnbits.utils.exchange_rates import (
|
||||||
satoshis_amount_as_fiat,
|
satoshis_amount_as_fiat,
|
||||||
)
|
)
|
||||||
from lnbits.utils.nostr import normalize_public_key
|
from lnbits.utils.nostr import normalize_public_key
|
||||||
from loguru import logger
|
|
||||||
from PIL import Image, ImageDraw
|
|
||||||
|
|
||||||
from .crud import (
|
from .crud import (
|
||||||
create_event,
|
create_event,
|
||||||
|
|
@ -76,6 +71,13 @@ from .models import (
|
||||||
effective_payment_methods,
|
effective_payment_methods,
|
||||||
)
|
)
|
||||||
from .nostr_hooks import publish_or_delete_nostr_event
|
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 (
|
from .services import (
|
||||||
refund_tickets,
|
refund_tickets,
|
||||||
resend_ticket_email_notification,
|
resend_ticket_email_notification,
|
||||||
|
|
@ -570,90 +572,6 @@ def _resolve_frontend_root(data: CreateTicket, request: Request) -> str:
|
||||||
return data.frontend_url.rstrip("/")
|
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(
|
async def _issue_free_tickets(
|
||||||
*,
|
*,
|
||||||
event: Event,
|
event: Event,
|
||||||
|
|
@ -1142,13 +1060,10 @@ async def api_ticket_qr(ticket_id: str):
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
|
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)
|
image = make_qr_png(f"ticket://{ticket_id}", size=300, logo=logo)
|
||||||
output = BytesIO()
|
|
||||||
image.save(output, format="PNG")
|
|
||||||
output.seek(0)
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
output,
|
BytesIO(image_png_bytes(image)),
|
||||||
media_type="image/png",
|
media_type="image/png",
|
||||||
headers={
|
headers={
|
||||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||||
|
|
@ -1156,3 +1071,32 @@ async def api_ticket_qr(ticket_id: str):
|
||||||
"Expires": "0",
|
"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",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue