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
440
views_api.py
440
views_api.py
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue