Merge upstream v1.6.8 into the aio fork

Brings in ticket waves (per-wave price/currency/stock/fiat), the paginated
ticket endpoint, the organiser ticket-image template, and the SatsPay
on-chain surface. Refs #33.

Resolutions that were not mechanical, and why:

- set_ticket_paid debits the wave named on the ticket, keeping upstream's
  `> 0` guards; ours decremented unconditionally and could go negative.
  The purchase and free-ticket paths now stamp ticket_wave_id /
  ticket_wave_title, without which every sale would debit the primary wave.

- Pricing moved onto the selected wave (basket_totals takes it as a required
  argument, the promo-validate endpoint resolves the same wave through the
  shared _resolve_ticket_wave). event.price_per_ticket is a roll-up of the
  PRIMARY wave since sync_event_ticket_waves, so pricing off the event
  quoted and charged the first wave's price to buyers who picked a later
  one. Regression test added.

- Kept npub support in two places upstream removed it: the purchase
  endpoint's normalize_public_key path and the notification dispatcher.
  Upstream's replacement rejects with "Only NIP-05 Nostr identifiers are
  supported", which is false for this fork. The purchase-side rejection had
  merged in outside any conflict marker.

- _ticket_image_url existed on both sides as two unrelated features. Ours
  (always-attached rendered QR card) is now _ticket_card_url; upstream's
  (organiser template, opt-in per wave) keeps the name. Both are wired into
  the mail, and the /qr/{ticket_id} endpoint — also duplicated on both
  sides, on the same route — is merged into one handler rather than
  registered twice, where the second copy would have been unreachable.

- models._parse_date now accepts a full ISO datetime. Upstream's date-only
  strptime raised ValueError on any event whose closing_date carries a time,
  which create_event produces by defaulting it from event_end_date — it
  would have 500'd the purchase path, the public event gate and the promo
  preview. Reproduced before fixing.

- Dropped upstream's inline make_qr_png (we import a superset from .qr) and
  its duplicate paymentMethodOptions in display.js, which re-derived payment
  options from per-method booleans and offered an on-chain option the
  backend rejects; the submit gate now matches the template's condition.

- Restored imports the merge silently dropped with upstream's npub removal
  (normalize_public_key, normalize_private_key, DEFAULT_NOSTR_RELAYS).

Event create/update stays ours: upstream's combined endpoint would have
replaced the approval workflow and the explicit field allowlist that keeps
`status` out of the request body.

mypy error set is unchanged from HEAD; ruff, black and the 97 tests pass.
This commit is contained in:
Padreug 2026-09-28 22:09:18 +02:00
commit ae5affa44f
14 changed files with 2228 additions and 275 deletions

View file

@ -9,7 +9,9 @@ from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formataddr, formatdate, make_msgid
from html import escape
from typing import Any
import httpx
from lnbits.core.models.users import UserNotifications
from lnbits.core.services.nostr import send_nostr_dm
from lnbits.core.services.notifications import send_user_notification
@ -26,7 +28,13 @@ from .crud import (
update_event,
update_ticket,
)
from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult
from .models import (
Event,
NotificationDeliveryResult,
Ticket,
TicketResendResult,
ensure_ticket_waves,
)
from .nostr_hooks import publish_or_delete_nostr_event
from .promo import promo_usage
from .qr import (
@ -37,6 +45,59 @@ from .qr import (
ticket_card_filename,
)
async def fetch_watchonly_config(api_key: str) -> dict[str, Any]:
async with httpx.AsyncClient() as client:
resp = await client.get(
url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/config",
headers={"X-API-KEY": api_key},
)
resp.raise_for_status()
return resp.json()
async def fetch_watchonly_wallets(api_key: str, network: str) -> list[dict[str, Any]]:
async with httpx.AsyncClient() as client:
resp = await client.get(
url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/wallet",
headers={"X-API-KEY": api_key},
params={"network": network},
)
resp.raise_for_status()
return resp.json()
async def fetch_watchonly_wallet(api_key: str, wallet_id: str) -> dict[str, Any]:
async with httpx.AsyncClient() as client:
resp = await client.get(
url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/wallet/{wallet_id}",
headers={"X-API-KEY": api_key},
)
resp.raise_for_status()
return resp.json()
async def get_satspay_charge(api_key: str, charge_id: str) -> dict[str, Any]:
async with httpx.AsyncClient() as client:
resp = await client.get(
url=f"http://{settings.host}:{settings.port}/satspay/api/v1/charge/{charge_id}",
headers={"X-API-KEY": api_key},
)
resp.raise_for_status()
return resp.json()
async def create_satspay_charge(api_key: str, data: dict) -> dict[str, Any]:
async with httpx.AsyncClient() as client:
resp = await client.post(
url=f"http://{settings.host}:{settings.port}/satspay/api/v1/charge",
headers={"X-API-KEY": api_key},
json=data,
)
resp.raise_for_status()
return resp.json()
DEFAULT_NOSTR_RELAYS = [
"wss://relay.damus.io",
"wss://relay.primal.net",
@ -70,7 +131,25 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket:
event = await get_event(ticket.event)
assert event, "Couldn't get event from ticket being paid"
event.sold += 1
event.amount_tickets -= 1
# Debit the wave the buyer actually bought from. v1.6.8 moved
# inventory onto waves; the event-level counter is only the
# fallback for events that predate them, and is itself a derived
# roll-up (`sync_event_ticket_waves`). Upstream's `> 0` guards are
# kept — ours decremented unconditionally and could go negative.
ticket_waves = event.extra.ticket_waves or []
if ticket_waves:
selected_wave = next(
(
wave
for wave in ticket_waves
if wave.id == ticket.extra.ticket_wave_id
),
ticket_waves[0],
)
if selected_wave.amount_tickets > 0:
selected_wave.amount_tickets -= 1
elif event.amount_tickets > 0:
event.amount_tickets -= 1
# Flag inside this same write: the counters and "the relay does
# not know about them yet" land atomically, so a crash between
# here and the publish still leaves the drift discoverable.
@ -116,10 +195,17 @@ async def _send_ticket_notification(ticket: Ticket) -> None:
await _deliver_ticket_notifications(ticket, event)
async def resend_ticket_email_notification(ticket: Ticket) -> TicketResendResult:
async def resend_ticket_email_notification(
ticket: Ticket, base_url: str | None = None
) -> TicketResendResult:
"""Organizer-triggered re-delivery of the ticket email. Bypasses the
per-event `email_notifications` opt-in (the organizer asked explicitly)
but still needs the instance mailer and an address on the ticket."""
but still needs the instance mailer and an address on the ticket.
`base_url` is upstream v1.6.8's: it re-points the ticket link at the
caller's frontend, which matters for us specifically because our
`ticket_base_url` can differ from `lnbits_baseurl` (separate web app).
"""
event = await get_event(ticket.event)
if not event:
raise ValueError("Event does not exist.")
@ -127,7 +213,12 @@ async def resend_ticket_email_notification(ticket: Ticket) -> TicketResendResult
raise ValueError("Email notifications are not enabled.")
if not ticket.email:
raise ValueError("Ticket does not have an email address.")
if base_url:
ticket.extra.ticket_base_url = base_url.rstrip("/")
# email-only: this is the *email* resend endpoint. Upstream calls
# `_deliver_ticket_notifications(ticket, event)` unqualified, which in
# our fork would also fire a Nostr DM the organiser did not ask for.
return await _deliver_ticket_notifications(ticket, event, email=True, nostr=False)
@ -163,17 +254,35 @@ def _ticket_details(ticket: Ticket, event: Event) -> str:
def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) -> str:
return (
"""Text part of the ticket mail.
Carries up to two images, which are NOT the same thing (see the note on
`_ticket_card_url` vs `_ticket_image_url`): our rendered QR card, always
present, and the organiser's uploaded template, only when the buyer's
wave opted into it. They had the same label before the v1.6.8 merge
because only one of them existed; now that both can appear the labels
have to distinguish them.
"""
message = (
f"{base_message}\n\n{_ticket_details(ticket, event)}"
f"\n\nTicket image: {_ticket_image_url(ticket)}"
f"\n\nTicket card: {_ticket_card_url(ticket)}"
)
ticket_image_url = _ticket_image_url(ticket, event)
if ticket_image_url:
message = f"{message}\n\nTicket image: {ticket_image_url}"
return message
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."""
"""HTML twin of the text part.
Deliberately no <img> for our own ticket card: it travels as an
attachment (renders inline in most clients, works offline, and keeps
SpamAssassin's HTML_IMAGE_ONLY rules quiet), and its URL becomes a link.
The organiser's uploaded ticket image is a remote URL with no attachment
to fall back on, so that one does get upstream's <img> — the surrounding
ticket details keep the mail from being image-only either way.
"""
text_message = _ticket_delivery_message(ticket, event, base_message)
html = escape(text_message)
html = re.sub(
@ -181,7 +290,17 @@ def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str)
lambda m: f'<a href="{m.group(1)}">{m.group(1)}</a>',
html,
)
return f"<p>{html.replace(chr(10), '<br />')}</p>"
html_message = f"<p>{html.replace(chr(10), '<br />')}</p>"
ticket_image_url = _ticket_image_url(ticket, event)
if not ticket_image_url:
return html_message
return (
f"{html_message}"
f'<p><img src="{escape(ticket_image_url, quote=True)}" alt="Ticket image" '
'style="max-width: 200px; height: auto;" /></p>'
)
def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]:
@ -200,7 +319,16 @@ async def _deliver_ticket_notifications(
) -> TicketResendResult:
"""Send the ticket by every configured channel and report per-channel
outcome (upstream v1.6.8 shape). `email` / `nostr` override the event's
opt-ins when not None; the instance-level prerequisites always apply."""
opt-ins when not None; the instance-level prerequisites always apply.
Upstream v1.6.8 guards the nostr branch with a `_supports_nostr_delivery`
check that errors with "Only NIP-05 Nostr identifiers are supported."
That guard is NOT taken here: it encodes upstream's limitation, not ours.
`_send_nostr_ticket_notification` below dispatches NIP-05 identifiers to
core's notifier and bare npubs to `send_nostr_dm`, so adopting the guard
would silently refuse identifiers we deliver to today — and say so with
a message that would be false for this fork.
"""
subject, text_message, html_message = _ticket_notification_payload(ticket, event)
updated = False
@ -376,16 +504,36 @@ def _ticket_url(ticket: Ticket) -> str:
return f"{base_url}/events/ticket/{ticket.id}"
def _ticket_image_url(ticket: Ticket) -> str:
"""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)."""
def _ticket_card_url(ticket: Ticket) -> str:
"""Our rendered ticket-card PNG — always available, and 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/ticket-card/{ticket.id}"
)
def _ticket_image_url(ticket: Ticket, event: Event) -> str | None:
"""Upstream's organiser-uploaded ticket template, opted into per wave.
Distinct from `_ticket_card_url`: that is our own rendered card and is
always attached, this is a template the organiser uploads and enables
on a specific wave. They shared a name before the v1.6.8 merge, which
made them look like one feature.
"""
waves = ensure_ticket_waves(event)
wave = next(
(wave for wave in waves if wave.id == ticket.extra.ticket_wave_id),
waves[0],
)
if not wave.use_ticket_image:
return None
base_url = (ticket.extra.ticket_base_url or settings.lnbits_baseurl).rstrip("/")
return f"{base_url}/events/api/v1/qr/{ticket.id}"
async def refund_tickets(event_id: str):
"""
Refund tickets for an event that has not met the minimum ticket requirement.