Merge pull request 'feat: attach a self-describing ticket card to the email (v1.6.1-aio.10)' (#43) from feat/ticket-card-attachment into main
Some checks failed
lint.yml / Merge pull request 'feat: attach a self-describing ticket card to the email (v1.6.1-aio.10)' (#43) from feat/ticket-card-attachment into main (push) Failing after 0s

Reviewed-on: #43
This commit is contained in:
padreug 2026-09-08 14:53:05 +00:00
commit f11e84d967
11 changed files with 631 additions and 140 deletions

View file

@ -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

View file

@ -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",

View file

@ -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
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()

View file

@ -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):

Binary file not shown.

BIN
static/fonts/DejaVuSans.ttf Normal file

Binary file not shown.

View file

@ -0,0 +1,187 @@
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below)
Bitstream Vera Fonts Copyright
------------------------------
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is
a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of the fonts accompanying this license ("Fonts") and associated
documentation files (the "Font Software"), to reproduce and distribute the
Font Software, including without limitation the rights to use, copy, merge,
publish, distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to the
following conditions:
The above copyright and trademark notices and this permission notice shall
be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular
the designs of glyphs or characters in the Fonts may be modified and
additional glyphs or characters may be added to the Fonts, only if the fonts
are renamed to names not containing either the words "Bitstream" or the word
"Vera".
This License becomes null and void to the extent applicable to Fonts or Font
Software that has been modified and is distributed under the "Bitstream
Vera" names.
The Font Software may be sold as part of a larger software package but no
copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome
Foundation, and Bitstream Inc., shall not be used in advertising or
otherwise to promote the sale, use or other dealings in this Font Software
without prior written authorization from the Gnome Foundation or Bitstream
Inc., respectively. For further information, contact: fonts at gnome dot
org.
Arev Fonts Copyright
------------------------------
Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.
Permission is hereby granted, free of charge, to any person obtaining
a copy of the fonts accompanying this license ("Fonts") and
associated documentation files (the "Font Software"), to reproduce
and distribute the modifications to the Bitstream Vera Font Software,
including without limitation the rights to use, copy, merge, publish,
distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to
the following conditions:
The above copyright and trademark notices and this permission notice
shall be included in all copies of one or more of the Font Software
typefaces.
The Font Software may be modified, altered, or added to, and in
particular the designs of glyphs or characters in the Fonts may be
modified and additional glyphs or characters may be added to the
Fonts, only if the fonts are renamed to names not containing either
the words "Tavmjong Bah" or the word "Arev".
This License becomes null and void to the extent applicable to Fonts
or Font Software that has been modified and is distributed under the
"Tavmjong Bah Arev" names.
The Font Software may be sold as part of a larger software package but
no copy of one or more of the Font Software typefaces may be sold by
itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL
TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the name of Tavmjong Bah shall not
be used in advertising or otherwise to promote the sale, use or other
dealings in this Font Software without prior written authorization
from Tavmjong Bah. For further information, contact: tavmjong @ free
. fr.
TeX Gyre DJV Math
-----------------
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
Math extensions done by B. Jackowski, P. Strzelczyk and P. Pianowski
(on behalf of TeX users groups) are in public domain.
Letters imported from Euler Fraktur from AMSfonts are (c) American
Mathematical Society (see below).
Bitstream Vera Fonts Copyright
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera
is a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of the fonts accompanying this license (“Fonts”) and associated
documentation
files (the “Font Software”), to reproduce and distribute the Font Software,
including without limitation the rights to use, copy, merge, publish,
distribute,
and/or sell copies of the Font Software, and to permit persons to whom
the Font Software is furnished to do so, subject to the following
conditions:
The above copyright and trademark notices and this permission notice
shall be
included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular
the designs of glyphs or characters in the Fonts may be modified and
additional
glyphs or characters may be added to the Fonts, only if the fonts are
renamed
to names not containing either the words “Bitstream” or the word “Vera”.
This License becomes null and void to the extent applicable to Fonts or
Font Software
that has been modified and is distributed under the “Bitstream Vera”
names.
The Font Software may be sold as part of a larger software package but
no copy
of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
FOUNDATION
BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL,
SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN
ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR
INABILITY TO USE
THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the names of GNOME, the GNOME
Foundation,
and Bitstream Inc., shall not be used in advertising or otherwise to promote
the sale, use or other dealings in this Font Software without prior written
authorization from the GNOME Foundation or Bitstream Inc., respectively.
For further information, contact: fonts at gnome dot org.
AMSFonts (v. 2.2) copyright
The PostScript Type 1 implementation of the AMSFonts produced by and
previously distributed by Blue Sky Research and Y&Y, Inc. are now freely
available for general use. This has been accomplished through the
cooperation
of a consortium of scientific publishers with Blue Sky Research and Y&Y.
Members of this consortium include:
Elsevier Science IBM Corporation Society for Industrial and Applied
Mathematics (SIAM) Springer-Verlag American Mathematical Society (AMS)
In order to assure the authenticity of these fonts, copyright will be
held by
the American Mathematical Society. This is not meant to restrict in any way
the legitimate use of the fonts, such as (but not limited to) electronic
distribution of documents containing these fonts, inclusion of these fonts
into other public domain or commercial font collections or computer
applications, use of the outline data to create derivative fonts and/or
faces, etc. However, the AMS does require that the AMS copyright notice be
removed from any derivative versions of the fonts which have been altered in
any way. In addition, to ensure the fidelity of TeX documents using Computer
Modern fonts, Professor Donald Knuth, creator of the Computer Modern faces,
has requested that any alterations which yield different font metrics be
given a different name.
$Id$

View file

@ -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

View file

@ -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))

View file

@ -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",
},
)