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:
commit
ae5affa44f
14 changed files with 2228 additions and 275 deletions
182
services.py
182
services.py
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue