From 2f8a602bbdfd62a059622495b50c5379fff3df7c Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 8 Sep 2026 16:40:25 +0200 Subject: [PATCH] feat: attach a self-describing ticket card to the email (1.6.1-aio.10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 . 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--.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 ) 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 Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo --- README.md | 11 +- config.json | 2 +- docs/upstream-candidates.md | 25 ++-- qr.py | 254 ++++++++++++++++++++++++++++++++++++ services.py | 92 +++++++++---- tests/test_ticket_email.py | 24 +++- tests/test_ticket_qr.py | 44 ++++++- views_api.py | 132 ++++++------------- 8 files changed, 444 insertions(+), 140 deletions(-) create mode 100644 qr.py diff --git a/README.md b/README.md index 43ab768..cd02134 100644 --- a/README.md +++ b/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 diff --git a/config.json b/config.json index 46be592..68e908a 100644 --- a/config.json +++ b/config.json @@ -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", diff --git a/docs/upstream-candidates.md b/docs/upstream-candidates.md index 539fbef..93e6c2f 100644 --- a/docs/upstream-candidates.md +++ b/docs/upstream-candidates.md @@ -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 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 | -| `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 | -| 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 | -| 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 | +| 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 | +| `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 | +| 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 | +| 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 `` (`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) | diff --git a/qr.py b/qr.py new file mode 100644 index 0000000..79a693f --- /dev/null +++ b/qr.py @@ -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() diff --git a/services.py b/services.py index 8476798..9e6d1ce 100644 --- a/services.py +++ b/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 : 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"

{escape(text_message).replace(chr(10), '
')}

" - image_url = escape(_ticket_image_url(ticket), quote=True) - return ( - f"{html_message}" - f'

Ticket QR code

' + html = escape(text_message) + html = re.sub( + r"(https?://[^\s<]+)", + lambda m: f'{m.group(1)}', + html, ) + return f"

{html.replace(chr(10), '
')}

" 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): diff --git a/tests/test_ticket_email.py b/tests/test_ticket_email.py index f33d670..f9b6c96 100644 --- a/tests/test_ticket_email.py +++ b/tests/test_ticket_email.py @@ -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", + "

html

", + 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 "' in html diff --git a/tests/test_ticket_qr.py b/tests/test_ticket_qr.py index 376ea1b..c65cb03 100644 --- a/tests/test_ticket_qr.py +++ b/tests/test_ticket_qr.py @@ -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)) diff --git a/views_api.py b/views_api.py index 8ae7b86..ac0b0ba 100644 --- a/views_api.py +++ b/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", + }, + )