feat: attach a self-describing ticket card to the email (1.6.1-aio.10)
Some checks failed
lint.yml / feat: attach a self-describing ticket card to the email (1.6.1-aio.10) (pull_request) Failing after 0s
Some checks failed
lint.yml / feat: attach a self-describing ticket card to the email (1.6.1-aio.10) (pull_request) Failing after 0s
The v1.6.1-aio.9 mail still scored 8.4/10 on mail-tester: the remaining
deduction was HTML_IMAGE_ONLY (1.8) — an HTML part whose only content of
note is a remote <img>. Remote images are also blocked by default in most
clients until the reader opts in, and a bare QR saved from that mail says
nothing about what it opens.
- New `qr.py` module (QR + logo helpers moved out of views_api) with
`render_ticket_card`: site title, event name, when/where, the branded
QR, name on ticket, ticket id and the door instruction, laid out with
the bundled DejaVu Sans; `format_event_when` gives "Fri 19 Feb 2027,
16:00 - 20:00"; filenames are `ticket-<event-slug>-<id8>.png`.
- `GET /events/api/v1/ticket-card/{ticket_id}` serves the same PNG
(anonymous, like the QR endpoint); the email's "Ticket image" link
now points there.
- The ticket email becomes multipart/mixed: text + HTML alternatives
(URLs as links, no <img>) plus the card as a PNG attachment, which
clients show inline at the end of the message and which works offline
at the door.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
This commit is contained in:
parent
defe6d5b00
commit
2f8a602bbd
8 changed files with 444 additions and 140 deletions
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