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
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:
commit
f11e84d967
11 changed files with 631 additions and 140 deletions
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`
|
||||
(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.
|
||||
- **Email.** Multipart text + HTML with the ticket QR embedded from
|
||||
`GET /events/api/v1/qr/{ticket_id}` (PNG, branded with the instance QR logo).
|
||||
`POST /events/api/v1/tickets/{ticket_id}/resend-email` returns a
|
||||
`TicketResendResult` with per-channel outcome.
|
||||
- **Email.** Multipart text + HTML (links, no images) with the **ticket card**
|
||||
attached — a self-describing PNG (site, event, when, where, QR with the
|
||||
instance logo, name on ticket, ticket id) also served at
|
||||
`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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"id": "events",
|
||||
"version": "1.6.1-aio.9",
|
||||
"version": "1.6.1-aio.10",
|
||||
"name": "Events",
|
||||
"repo": "https://git.atitlan.io/aiolabs/events",
|
||||
"short_description": "Sell and register event tickets",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ Running log of fork features that are shaped so they could be offered to
|
|||
in an upstream-compatible form; strike it when the PR merges upstream.
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
|
@ -16,3 +16,4 @@ in an upstream-compatible form; strike it when the PR merges upstream.
|
|||
| 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 |
|
||||
| `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
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import smtplib
|
||||
from asyncio.tasks import create_task
|
||||
from email.mime.image import MIMEImage
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import formataddr, formatdate, make_msgid
|
||||
|
|
@ -26,6 +28,13 @@ from .crud import (
|
|||
)
|
||||
from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult
|
||||
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 = [
|
||||
"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:
|
||||
"""Human-readable ticket facts for the email body. Also what keeps the
|
||||
mail from being an image with no words (SpamAssassin HTML_IMAGE_ONLY)."""
|
||||
when = event.event_start_date
|
||||
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}"]
|
||||
lines = [f"Event: {event.name}", f"When: {format_event_when(event)}"]
|
||||
if event.location:
|
||||
lines.append(f"Where: {event.location}")
|
||||
if ticket.name:
|
||||
lines.append(f"Name on ticket: {ticket.name}")
|
||||
lines.append(f"Ticket ID: {ticket.id}")
|
||||
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."
|
||||
)
|
||||
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:
|
||||
"""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)
|
||||
html_message = f"<p>{escape(text_message).replace(chr(10), '<br />')}</p>"
|
||||
image_url = escape(_ticket_image_url(ticket), quote=True)
|
||||
return (
|
||||
f"{html_message}"
|
||||
f'<p><img src="{image_url}" alt="Ticket QR code" '
|
||||
'style="max-width: 300px; height: auto;" /></p>'
|
||||
html = escape(text_message)
|
||||
html = re.sub(
|
||||
r"(https?://[^\s<]+)",
|
||||
lambda m: f'<a href="{m.group(1)}">{m.group(1)}</a>',
|
||||
html,
|
||||
)
|
||||
return f"<p>{html.replace(chr(10), '<br />')}</p>"
|
||||
|
||||
|
||||
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:
|
||||
try:
|
||||
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(
|
||||
[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
|
||||
result.email.sent = True
|
||||
|
|
@ -227,6 +250,7 @@ async def _send_ticket_email_notification(
|
|||
message: str,
|
||||
subject: str,
|
||||
html_message: str | None = None,
|
||||
attachments: list[tuple[str, bytes]] | None = None,
|
||||
) -> None:
|
||||
"""Multipart (text + HTML) ticket email through the instance SMTP
|
||||
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):
|
||||
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
|
||||
await asyncio.to_thread(
|
||||
_smtp_send,
|
||||
|
|
@ -264,22 +290,34 @@ def build_ticket_email(
|
|||
subject: str,
|
||||
message: str,
|
||||
html_message: str | None = None,
|
||||
attachments: list[tuple[str, bytes]] | None = None,
|
||||
) -> MIMEMultipart:
|
||||
"""Assemble the multipart ticket email with the headers receivers score
|
||||
on: a Date and a Message-ID (their absence is what SpamAssassin's
|
||||
MISSING_DATE / MISSING_MID flag, and what Gmail/Outlook read as
|
||||
machine-generated), and a display name on From so the sender is not a
|
||||
bare address."""
|
||||
msg = MIMEMultipart("alternative")
|
||||
"""Assemble the ticket email: text + HTML alternatives, PNG attachments
|
||||
(the ticket card), and the headers receivers score on — a Date and a
|
||||
Message-ID (their absence is what SpamAssassin's MISSING_DATE /
|
||||
MISSING_MID flag, and what Gmail/Outlook read as machine-generated) and
|
||||
a display name on From so the sender is not a bare address."""
|
||||
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"
|
||||
msg["From"] = formataddr((sender_name, from_email))
|
||||
msg["To"] = ", ".join(to_emails)
|
||||
msg["Subject"] = subject
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -318,11 +356,13 @@ def _ticket_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
|
||||
built from `lnbits_baseurl` even when `ticket_base_url` points at a
|
||||
separate web app (deviation from upstream, which assumes both are the
|
||||
"""The ticket card PNG is served by THIS extension on the LNbits host, so
|
||||
it is built from `lnbits_baseurl` even when `ticket_base_url` points at
|
||||
a separate web app (deviation from upstream, which assumes both are the
|
||||
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):
|
||||
|
|
|
|||
BIN
static/fonts/DejaVuSans-Bold.ttf
Normal file
BIN
static/fonts/DejaVuSans-Bold.ttf
Normal file
Binary file not shown.
BIN
static/fonts/DejaVuSans.ttf
Normal file
BIN
static/fonts/DejaVuSans.ttf
Normal file
Binary file not shown.
187
static/fonts/LICENSE-DejaVu.txt
Normal file
187
static/fonts/LICENSE-DejaVu.txt
Normal 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$
|
||||
|
|
@ -53,6 +53,25 @@ def test_email_carries_date_message_id_and_display_name(monkeypatch):
|
|||
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):
|
||||
monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/")
|
||||
subject, text, html = _ticket_notification_payload(_ticket(), _event())
|
||||
|
|
@ -65,5 +84,6 @@ def test_payload_includes_event_details_and_qr(monkeypatch):
|
|||
"at the door",
|
||||
):
|
||||
assert needle in text
|
||||
assert "/events/api/v1/qr/tkt1" in text
|
||||
assert "<img" in html and "Ticket ID: tkt1" in html
|
||||
assert "/events/api/v1/ticket-card/tkt1" in text
|
||||
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 ..views_api import make_qr_png
|
||||
from ..qr import make_qr_png
|
||||
|
||||
|
||||
def test_make_qr_png_renders_requested_size():
|
||||
|
|
@ -20,3 +20,45 @@ def test_make_qr_png_pastes_a_centred_logo():
|
|||
out = BytesIO()
|
||||
img.save(out, format="PNG")
|
||||
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 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",
|
||||
},
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue