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

@ -2,6 +2,7 @@ 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
@ -16,17 +17,20 @@ from fastapi import (
)
from fastapi.responses import StreamingResponse
from lnbits.core.crud import get_user
from lnbits.core.crud.assets import get_public_asset
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models import Account, User, WalletTypeInfo
from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services import create_payment_request
from lnbits.db import Filters, Page
from lnbits.decorators import (
check_admin,
check_user_exists,
parse_filters,
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import urlsafe_short_hash
from lnbits.helpers import generate_filter_params_openapi, urlsafe_short_hash
from lnbits.settings import settings
from lnbits.utils.exchange_rates import (
fiat_amount_as_satoshis,
@ -34,6 +38,8 @@ 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
from .crud import (
create_event,
@ -53,6 +59,7 @@ from .crud import (
get_tickets_by_event,
get_tickets_by_payment_hash,
get_tickets_by_user_id,
get_tickets_paginated,
purge_unpaid_tickets,
update_event,
update_settings,
@ -68,9 +75,13 @@ from .models import (
PublicEvent,
PublicTicket,
Ticket,
TicketFilters,
TicketPaymentRequest,
TicketResendResult,
TicketWave,
effective_payment_methods,
ensure_ticket_waves,
get_active_ticket_waves,
)
from .nostr_hooks import publish_or_delete_nostr_event
from .promo import (
@ -88,19 +99,57 @@ from .qr import (
ticket_card_filename,
)
from .services import (
create_satspay_charge,
event_promo_usage,
fetch_watchonly_config,
fetch_watchonly_wallets,
get_satspay_charge,
hydrate_promo_usage,
refund_tickets,
resend_ticket_email_notification,
send_ticket_notification_in_background,
set_ticket_paid,
)
from .tasks import deregister_payment_listener, register_payment_listener
from .tasks import (
deregister_payment_listener,
payment_listeners,
register_payment_listener,
)
events_api_router = APIRouter(prefix="/api/v1/events")
tickets_api_router = APIRouter(prefix="/api/v1/tickets")
qr_api_router = APIRouter(prefix="/api/v1")
promo_api_router = APIRouter(prefix="/api/v1/promo")
tickets_filters = parse_filters(TicketFilters)
async def _get_watchonly_status(wallet) -> dict[str, Any]:
try:
config = await fetch_watchonly_config(wallet.inkey)
network = config.get("network")
if not network:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Watchonly extension returned an invalid network.",
)
wallets = await fetch_watchonly_wallets(wallet.inkey, network)
except HTTPException:
raise
except Exception as exc:
return {
"available": False,
"message": f"Watchonly extension is not reachable: {exc!s}",
"network": None,
"wallets": [],
"mempool_endpoint": None,
}
return {
"available": True,
"message": None,
"network": network,
"wallets": wallets,
"mempool_endpoint": config.get("mempool_endpoint"),
}
def _is_fiat_currency(currency: str | None) -> bool:
@ -109,8 +158,6 @@ def _is_fiat_currency(currency: str | None) -> bool:
# Literal-prefix routes (/public, /all, /pending, /settings) MUST be declared
# before any "/{event_id}" route or FastAPI matches them as a path parameter.
@events_api_router.get("")
async def api_events(
all_wallets: bool = Query(False),
@ -247,6 +294,13 @@ async def api_get_settings_public(
return {"auto_approve": settings.auto_approve}
@events_api_router.get("/onchain/status")
async def api_onchain_status(
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> dict[str, Any]:
return await _get_watchonly_status(wallet.wallet)
@events_api_router.get("/{event_id}", response_model=PublicEvent)
async def api_get_event(event_id: str) -> Event:
"""Public event detail used by display.vue.
@ -273,28 +327,44 @@ async def api_get_event(event_id: str) -> Event:
closing_date = event.closing_date or event.event_end_date or event.event_start_date
# Accept either YYYY-MM-DD or full ISO 8601 datetime (event_end_date
# may carry a time component since v1.3.0-aio.3 / our start-end-time
# feature).
# feature). Upstream v1.6.8 parses closing_date with a bare
# strptime("%Y-%m-%d") here; that raises ValueError on any event of ours
# whose closing date carries a time, which is most of them.
try:
closing_dt = datetime.fromisoformat(closing_date)
except ValueError:
closing_dt = datetime.strptime(closing_date[:10], "%Y-%m-%d")
if closing_dt.tzinfo is None:
closing_dt = closing_dt.replace(tzinfo=timezone.utc)
is_window_open = datetime.now(timezone.utc) < closing_dt
now = datetime.now(timezone.utc)
today = now.date()
active_waves = get_active_ticket_waves(event, today)
# `is_sales_closed` is upstream's name for `not is_window_open`; the
# comparison stays ours (instant-precise) rather than upstream's
# whole-day `today > closing_date.date()`. Upstream's form keeps sales
# open for the whole of the closing day, which for our datetime-bearing
# closing dates would extend every existing event's sales window.
is_sales_closed = now >= closing_dt
is_min_tickets_met = (
event.sold >= event.extra.min_tickets if event.extra.conditional else True
)
if event.amount_tickets < 1:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
if event.extra.conditional and not is_min_tickets_met and not is_window_open:
if event.extra.conditional and not is_min_tickets_met and is_sales_closed:
event.canceled = True
await update_event(event)
await refund_tickets(event_id)
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.")
if not is_window_open:
if not active_waves:
raise HTTPException(
status_code=HTTPStatus.GONE, detail="Ticket closing date has passed."
status_code=HTTPStatus.GONE,
detail=(
"Ticket closing date has passed."
if is_sales_closed
else "No ticket wave is currently open."
),
)
return event
@ -536,6 +606,31 @@ async def api_tickets_by_user(
return await get_tickets_by_user_id(user_id)
@tickets_api_router.get(
"/paginated",
summary="Get paginated list of tickets",
openapi_extra=generate_filter_params_openapi(TicketFilters),
response_model=Page[Ticket],
)
async def api_tickets_paginated(
all_wallets: bool = Query(False),
filters: Filters = Depends(tickets_filters),
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> Page[Ticket]:
wallet_ids = [key_info.wallet.id]
if all_wallets:
user = await get_user(key_info.wallet.user)
wallet_ids = user.wallet_ids if user else []
if not filters.sortby:
filters.sortby = "time"
if not filters.direction:
filters.direction = "desc"
return await get_tickets_paginated(wallet_ids, filters)
@tickets_api_router.get("/{ticket_id}", response_model=PublicTicket)
async def api_get_ticket(ticket_id: str) -> Ticket:
ticket = await get_ticket(ticket_id)
@ -597,6 +692,36 @@ def _resolve_frontend_root(data: CreateTicket, request: Request) -> str:
return data.frontend_url.rstrip("/")
def _resolve_ticket_wave(event: Event, ticket_wave_id: str | None) -> TicketWave:
"""The wave a purchase or a price preview is priced against.
Shared by the purchase and promo-validate endpoints so the two cannot
drift: whatever wave the preview quoted is the wave the invoice charges.
Selection is upstream v1.6.8's — an explicit id must be an OPEN wave, a
single open wave is implied, and an ambiguous choice is an error rather
than a silent pick.
"""
active_waves = get_active_ticket_waves(event)
if not active_waves:
raise HTTPException(
status_code=HTTPStatus.GONE, detail="No ticket wave is currently open."
)
if ticket_wave_id:
wave = next((wave for wave in active_waves if wave.id == ticket_wave_id), None)
if not wave:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Invalid ticket wave selected.",
)
return wave
if len(active_waves) == 1:
return active_waves[0]
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Please select a ticket wave.",
)
async def _issue_free_tickets(
*,
event: Event,
@ -607,6 +732,7 @@ async def _issue_free_tickets(
promo_code: str | None,
nostr_identifier: str | None,
frontend_root: str,
selected_wave: TicketWave,
) -> TicketPaymentRequest:
"""Issue `quantity` free tickets without minting an invoice.
@ -635,6 +761,11 @@ async def _issue_free_tickets(
ticket_id=row_id,
extra={
"applied_promo_code": promo_code,
# Free tickets consume wave stock exactly like paid ones —
# `set_ticket_paid` runs on this path too — so they must name
# their wave or the decrement lands on the primary wave.
"ticket_wave_id": selected_wave.id,
"ticket_wave_title": selected_wave.title,
"nostr_identifier": nostr_identifier,
"ticket_base_url": frontend_root,
"sats_paid": 0,
@ -690,11 +821,16 @@ async def api_ticket_create(
refund_address = data.refund_address
nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None
payment_method = (data.payment_method or "lightning").lower()
if payment_method not in {"lightning", "fiat"}:
if payment_method not in {"lightning", "fiat", "onchain"}:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Unsupported payment method.",
)
# npub or NIP-05, both normalised to a hex pubkey. v1.6.8 replaced this
# with a hard "Only NIP-05 Nostr identifiers are supported." rejection —
# true for upstream, false here: `_send_nostr_ticket_notification` sends
# bare pubkeys via `send_nostr_dm`. That rejection merged in outside any
# conflict marker, so it would have silently dropped npub checkout.
if nostr_identifier and "@" not in nostr_identifier:
try:
nostr_identifier = normalize_public_key(nostr_identifier)
@ -703,6 +839,8 @@ async def api_ticket_create(
status_code=HTTPStatus.BAD_REQUEST,
detail="Invalid Nostr identifier.",
) from exc
selected_wave = _resolve_ticket_wave(event, data.ticket_wave_id)
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
frontend_root = _resolve_frontend_root(data, request)
@ -733,9 +871,18 @@ async def api_ticket_create(
),
)
extra["promo_code"] = promo.code
price = basket_totals(event, [promo.code], quantity, usage).total
# Priced off `selected_wave`, not `event`: since v1.6.8 the price and
# currency live on the wave, and the event-level fields are a roll-up
# of the PRIMARY wave (`sync_event_ticket_waves`). Pricing off the
# event charges every buyer the first wave's price whichever wave they
# actually picked. Upstream prices one ticket; `basket_totals` keeps
# our quantity + promo arithmetic, so the "Apply" preview and the
# invoice still agree.
price = basket_totals(event, [promo.code], quantity, usage, selected_wave).total
else:
price = round_amount(event.price_per_ticket * quantity, event.currency)
price = round_amount(
selected_wave.price_per_ticket * quantity, selected_wave.currency
)
# Free tickets (final charge 0 — a free event or a 100%-off promo).
# Short-circuit before any invoice / fiat-provider logic: no Lightning
@ -751,6 +898,16 @@ async def api_ticket_create(
promo_code=promo_code,
nostr_identifier=nostr_identifier,
frontend_root=frontend_root,
selected_wave=selected_wave,
)
# Fiat is a per-wave opt-in since v1.6.8. `effective_payment_methods`
# below reads `event.allow_fiat`, which is only the PRIMARY wave's, so
# this check is what actually protects a non-fiat wave.
if payment_method == "fiat" and not selected_wave.allow_fiat:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Fiat payments are not enabled for this ticket wave.",
)
# Organizer-controlled rails (extra.payment_methods; legacy events fall
@ -762,23 +919,25 @@ async def api_ticket_create(
detail="Payment method not enabled for this event.",
)
if _is_fiat_currency(event.currency):
if _is_fiat_currency(selected_wave.currency):
extra["fiat"] = True
extra["currency"] = event.currency
extra["currency"] = selected_wave.currency
extra["fiatAmount"] = price
extra["rate"] = await get_fiat_rate_satoshis(event.currency)
extra["rate"] = await get_fiat_rate_satoshis(selected_wave.currency)
if payment_method != "fiat":
price = await fiat_amount_as_satoshis(price, event.currency)
price = await fiat_amount_as_satoshis(price, selected_wave.currency)
invoice_unit = event.currency
invoice_unit = selected_wave.currency
fiat_amount = price
fiat_provider = None
onchain_amount_sat = None
if payment_method == "fiat":
if _is_fiat_currency(event.currency):
invoice_unit = event.currency
if _is_fiat_currency(selected_wave.currency):
invoice_unit = selected_wave.currency
else:
invoice_unit = event.fiat_currency
invoice_unit = selected_wave.fiat_currency
fiat_amount = await satoshis_amount_as_fiat(price, invoice_unit)
extra["fiat"] = True
extra["currency"] = invoice_unit
@ -797,6 +956,80 @@ async def api_ticket_create(
status_code=HTTPStatus.BAD_REQUEST,
detail="No fiat payment provider configured for this event.",
)
elif payment_method == "onchain":
onchain_amount_sat = int(price)
wallet_record = await get_wallet(event.wallet)
if not wallet_record:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Event wallet does not exist.",
)
if not event.extra.onchain_enabled:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Onchain payments are not enabled for this event.",
)
if not event.extra.onchain_wallet_id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="No onchain wallet configured for this event.",
)
ticket_id = urlsafe_short_hash()
base_url = str(request.base_url).rstrip("/")
# Use internal address for webhook — SatsPay calls it server-side and
# cannot reach the public domain from within the container.
internal_base = f"http://{settings.host}:{settings.port}"
webhook_url = (
f"{internal_base}/events/api/v1/tickets/{ticket_id}/satspay-webhook"
)
complete_url = f"{base_url}/events/ticket/{ticket_id}"
try:
charge = await create_satspay_charge(
api_key=wallet_record.inkey,
data={
"amount": onchain_amount_sat,
"description": f"Ticket for {event.name}",
"name": name,
"onchainwallet": event.extra.onchain_wallet_id,
"zeroconf": event.extra.onchain_zeroconf,
"fasttrack": event.extra.onchain_fasttrack,
"webhook": webhook_url,
"completelink": complete_url,
"completelinktext": "View your ticket",
"time": 1440,
},
)
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Failed to create SatsPay charge: {exc}",
) from exc
await create_ticket(
payment_hash=ticket_id,
wallet=event.wallet,
event=event.id,
name=name,
email=email,
extra={
"applied_promo_code": promo_code,
"ticket_wave_id": selected_wave.id,
"ticket_wave_title": selected_wave.title,
"refund_address": refund_address,
"nostr_identifier": nostr_identifier,
"ticket_base_url": base_url,
"sats_paid": onchain_amount_sat,
"onchain": True,
"satspay_charge_id": charge["id"],
},
)
return TicketPaymentRequest(
payment_hash=ticket_id,
onchain_amount_sat=onchain_amount_sat,
satspay_charge_url=f"/satspay/{charge['id']}",
)
else:
invoice_unit = "sat"
@ -854,6 +1087,12 @@ async def api_ticket_create(
ticket_id=row_id,
extra={
"applied_promo_code": promo_code,
# Which wave this ticket came from. `set_ticket_paid` debits
# the wave named here and falls back to waves[0] when it is
# absent, so omitting it would take every sale off the
# primary wave no matter what the buyer bought.
"ticket_wave_id": selected_wave.id,
"ticket_wave_title": selected_wave.title,
"refund_address": refund_address,
"nostr_identifier": nostr_identifier,
"ticket_base_url": frontend_root,
@ -955,9 +1194,78 @@ async def api_ticket_delete(
await delete_ticket(ticket_id)
@tickets_api_router.post("/{ticket_id}/satspay-webhook")
async def api_ticket_satspay_webhook(ticket_id: str) -> None:
ticket = await get_ticket(ticket_id)
if not ticket:
logger.warning(f"SatsPay webhook: ticket {ticket_id} does not exist.")
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
)
if ticket.paid:
logger.warning(f"SatsPay webhook: ticket {ticket_id} already paid.")
return
if not ticket.extra.satspay_charge_id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Not a SatsPay ticket."
)
wallet = await get_wallet(ticket.wallet)
if not wallet:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Ticket wallet does not exist."
)
try:
charge = await get_satspay_charge(wallet.inkey, ticket.extra.satspay_charge_id)
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=f"Could not verify charge: {exc}"
) from exc
if not charge.get("paid"):
logger.warning(
f"SatsPay webhook for ticket {ticket_id}: charge"
f" {ticket.extra.satspay_charge_id} not paid."
)
return
ticket = await set_ticket_paid(ticket)
send_ticket_notification_in_background(ticket)
for queue in payment_listeners.get(ticket_id, []):
queue.put_nowait(ticket)
@tickets_api_router.put("/{payment_hash}/onchain-confirm")
async def api_ticket_onchain_confirm(
payment_hash: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Ticket:
ticket = await get_ticket(payment_hash)
if not ticket:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
)
if ticket.wallet != wallet.wallet.id:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your ticket.")
if ticket.paid:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Ticket already paid."
)
if not ticket.extra.onchain:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Ticket is not an onchain payment.",
)
ticket = await set_ticket_paid(ticket)
send_ticket_notification_in_background(ticket)
for queue in payment_listeners.get(payment_hash, []):
queue.put_nowait(ticket)
return ticket
@tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult)
async def api_ticket_resend_email(
ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
ticket_id: str,
request: Request,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> TicketResendResult:
ticket = await get_ticket(ticket_id)
if not ticket:
@ -975,16 +1283,13 @@ async def api_ticket_resend_email(
)
try:
return await resend_ticket_email_notification(ticket)
return await resend_ticket_email_notification(
ticket, str(request.base_url).rstrip("/")
)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Failed to resend ticket email.",
) from exc
@tickets_api_router.put("/register/{ticket_id}")
@ -1095,28 +1400,76 @@ async def api_event_ticket_stats(
@qr_api_router.get("/qr/{ticket_id}", response_class=StreamingResponse)
async def api_ticket_qr(ticket_id: str):
"""PNG of the ticket's scan payload (`ticket://<id>`), branded with the
instance QR logo. Anonymous by design — it is what the ticket email
embeds — and the id is the same bearer token the ticket page exposes.
Port of upstream v1.6.8 without ticket-image compositing."""
"""PNG of the ticket's scan payload (`ticket://<id>`).
Two shapes behind one route. Before the v1.6.8 merge this endpoint
existed on both sides — ours was "upstream's, without ticket-image
compositing", theirs added the compositing but dropped the logo. They
are the same endpoint, so they are merged rather than registered twice
(FastAPI serves whichever route is declared first, so the second copy
would have been silently unreachable):
- wave opted into `use_ticket_image`: upstream's composite — the QR
pasted onto the organiser's uploaded template, or the bundled
default. QR size and paste coordinates are upstream's and are tied
to each other; no logo, because the template carries the branding.
- otherwise: our standalone QR at 300px with the instance logo.
Anonymous by design — it is what the ticket email links to — and the id
is the same bearer token the ticket page exposes.
"""
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()
image = make_qr_png(f"ticket://{ticket_id}", size=300, logo=logo)
return StreamingResponse(
BytesIO(image_png_bytes(image)),
media_type="image/png",
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
},
headers = {
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
}
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:
logo = await load_qr_logo()
image = make_qr_png(f"ticket://{ticket_id}", size=300, logo=logo)
return StreamingResponse(
BytesIO(image_png_bytes(image)),
media_type="image/png",
headers=headers,
)
background_bytes = None
if wave.ticket_image_id:
asset = await get_public_asset(wave.ticket_image_id)
if asset:
background_bytes = asset.data
if background_bytes:
ticket_image = Image.open(BytesIO(background_bytes)).convert("RGBA")
else:
default_template = (
Path(__file__).resolve().parent / "static" / "image" / "ticket.jpg"
)
ticket_image = Image.open(default_template).convert("RGBA")
ticket_image.paste(make_qr_png(f"ticket://{ticket_id}", size=157), (122, 505))
output = BytesIO()
ticket_image.save(output, format="PNG")
output.seek(0)
return StreamingResponse(output, media_type="image/png", headers=headers)
@qr_api_router.get("/ticket-card/{ticket_id}", response_class=StreamingResponse)
async def api_ticket_card(ticket_id: str):
@ -1162,4 +1515,7 @@ async def api_validate_promo_codes(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
usage = await event_promo_usage(event_id) if event.extra.promo_codes else {}
return basket_totals(event, data.codes, data.quantity, usage)
# Same resolver the purchase endpoint uses, so the quote and the charge
# are priced against the same wave.
wave = _resolve_ticket_wave(event, data.ticket_wave_id)
return basket_totals(event, data.codes, data.quantity, usage, wave)