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
1102 lines
38 KiB
Python
1102 lines
38 KiB
Python
import asyncio
|
|
from datetime import datetime, timezone
|
|
from http import HTTPStatus
|
|
from io import BytesIO
|
|
from typing import Any
|
|
from urllib.parse import urlsplit
|
|
|
|
from fastapi import (
|
|
APIRouter,
|
|
Depends,
|
|
HTTPException,
|
|
Query,
|
|
Request,
|
|
WebSocket,
|
|
WebSocketDisconnect,
|
|
)
|
|
from fastapi.responses import StreamingResponse
|
|
from lnbits.core.crud import get_user
|
|
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.decorators import (
|
|
check_admin,
|
|
check_user_exists,
|
|
require_admin_key,
|
|
require_invoice_key,
|
|
)
|
|
from lnbits.helpers import urlsafe_short_hash
|
|
from lnbits.settings import settings
|
|
from lnbits.utils.exchange_rates import (
|
|
fiat_amount_as_satoshis,
|
|
get_fiat_rate_satoshis,
|
|
satoshis_amount_as_fiat,
|
|
)
|
|
from lnbits.utils.nostr import normalize_public_key
|
|
|
|
from .crud import (
|
|
create_event,
|
|
create_ticket,
|
|
delete_event,
|
|
delete_event_tickets,
|
|
delete_ticket,
|
|
get_all_events,
|
|
get_event,
|
|
get_event_tickets,
|
|
get_events,
|
|
get_pending_events,
|
|
get_public_events,
|
|
get_settings,
|
|
get_ticket,
|
|
get_tickets,
|
|
get_tickets_by_event,
|
|
get_tickets_by_payment_hash,
|
|
get_tickets_by_user_id,
|
|
purge_unpaid_tickets,
|
|
update_event,
|
|
update_settings,
|
|
update_ticket,
|
|
)
|
|
from .models import (
|
|
CreateEvent,
|
|
CreateTicket,
|
|
Event,
|
|
EventsSettings,
|
|
PublicEvent,
|
|
PublicTicket,
|
|
Ticket,
|
|
TicketPaymentRequest,
|
|
TicketResendResult,
|
|
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,
|
|
send_ticket_notification_in_background,
|
|
set_ticket_paid,
|
|
)
|
|
from .tasks import deregister_payment_listener, 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")
|
|
|
|
|
|
def _is_fiat_currency(currency: str | None) -> bool:
|
|
return str(currency or "").lower() not in {"sat", "sats"}
|
|
|
|
|
|
# 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),
|
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
|
) -> list[Event]:
|
|
wallet_ids = [wallet.wallet.id]
|
|
if all_wallets:
|
|
user = await get_user(wallet.wallet.user)
|
|
wallet_ids = user.wallet_ids if user else []
|
|
return await get_events(wallet_ids)
|
|
|
|
|
|
@events_api_router.get("/public")
|
|
async def api_events_public() -> list[Event]:
|
|
"""Approved, non-canceled events for an anonymous public listing."""
|
|
return await get_public_events()
|
|
|
|
|
|
@events_api_router.get("/all")
|
|
async def api_events_all(
|
|
admin: Account = Depends(check_admin),
|
|
) -> list[dict]:
|
|
"""All events across all wallets, with each row's wallet owner
|
|
resolved to a user_id. LNbits admin only.
|
|
|
|
Returns dicts (not strict `Event` rows) so the response can carry
|
|
the synthetic `wallet_user_id` column the admin UI uses to attribute
|
|
each cross-tenant event to a user.
|
|
"""
|
|
events = await get_all_events()
|
|
enriched: list[dict] = []
|
|
for event in events:
|
|
wallet = await get_wallet(event.wallet)
|
|
row = event.dict()
|
|
row["wallet_user_id"] = wallet.user if wallet else None
|
|
enriched.append(row)
|
|
return enriched
|
|
|
|
|
|
@events_api_router.get("/pending")
|
|
async def api_events_pending(
|
|
admin: Account = Depends(check_admin),
|
|
) -> list[Event]:
|
|
"""Proposed events awaiting admin approval. LNbits admin only."""
|
|
return await get_pending_events()
|
|
|
|
|
|
@events_api_router.post("/republish-all")
|
|
async def api_republish_all(
|
|
admin: Account = Depends(check_admin),
|
|
) -> dict:
|
|
"""Force-republish every approved event to Nostr relays. Admin only.
|
|
|
|
Used by the catalog-bump migration that introduced the AIO ticket
|
|
tags: existing events on a deployed instance were published before
|
|
the publisher learned the new tag set, so they don't carry
|
|
tickets_available / tickets_sold / etc. until something triggers
|
|
a republish. This endpoint walks the approved list and re-emits
|
|
each calendar event so connected clients see the new metadata
|
|
without waiting for a per-event edit.
|
|
|
|
Errors are swallowed per-event (logged inside the publisher) so
|
|
one bad event doesn't block the rest. Returns a count summary.
|
|
"""
|
|
events = await get_all_events()
|
|
approved = [e for e in events if e.status == "approved" and not e.canceled]
|
|
for event in approved:
|
|
await publish_or_delete_nostr_event(event)
|
|
return {"republished": len(approved), "total": len(events)}
|
|
|
|
|
|
@events_api_router.post("/republish-mine")
|
|
async def api_republish_mine(
|
|
all_wallets: bool = Query(False),
|
|
key_info: WalletTypeInfo = Depends(require_admin_key),
|
|
) -> dict:
|
|
"""Force-republish the caller's own approved events to Nostr relays.
|
|
|
|
Same shape as /republish-all but scoped to events owned by the
|
|
authenticated wallet (or all wallets belonging to the wallet's
|
|
user when `?all_wallets=true`). Lets the organizer trigger the
|
|
same migration the admin uses, without needing instance-admin
|
|
rights — useful when the AIO publisher gains a new tag set and
|
|
an organizer wants their published events to carry it.
|
|
|
|
Only events with `status == "approved"` are republished; pending
|
|
and rejected rows aren't on relays in the first place, so a
|
|
republish for them would be a no-op (or worse, surface a
|
|
proposed-but-not-approved row to subscribers).
|
|
"""
|
|
wallet_ids: list[str] = [key_info.wallet.id]
|
|
if all_wallets:
|
|
user = await get_user(key_info.wallet.user)
|
|
wallet_ids = user.wallet_ids if user else []
|
|
|
|
events = await get_events(wallet_ids)
|
|
approved = [e for e in events if e.status == "approved" and not e.canceled]
|
|
for event in approved:
|
|
await publish_or_delete_nostr_event(event)
|
|
return {"republished": len(approved), "total": len(events)}
|
|
|
|
|
|
@events_api_router.get("/settings")
|
|
async def api_get_settings(
|
|
admin: Account = Depends(check_admin),
|
|
) -> EventsSettings:
|
|
return await get_settings()
|
|
|
|
|
|
@events_api_router.put("/settings")
|
|
async def api_update_settings(
|
|
data: EventsSettings,
|
|
admin: Account = Depends(check_admin),
|
|
) -> EventsSettings:
|
|
return await update_settings(data)
|
|
|
|
|
|
@events_api_router.get("/settings/public")
|
|
async def api_get_settings_public(
|
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
|
) -> dict:
|
|
"""Subset of EventsSettings safe to expose to any authenticated
|
|
caller. The webapp needs `auto_approve` to render accurate edit-flow
|
|
copy ("your edit will go back to pending" vs "edit stays approved")
|
|
without forcing every event-creator to also be an LNbits admin.
|
|
"""
|
|
settings = await get_settings()
|
|
return {"auto_approve": settings.auto_approve}
|
|
|
|
|
|
@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.
|
|
|
|
For approved events we run the upstream sold-out / closing-window /
|
|
conditional gates. For non-approved events (proposed / rejected) we
|
|
return the trimmed PublicEvent with status set so the SFC can render
|
|
the pending-approval banner without a separate request.
|
|
"""
|
|
event = await get_event(event_id)
|
|
if not event:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
|
)
|
|
|
|
if event.status != "approved":
|
|
# Proposed/rejected events are not yet ticketable; skip ticket gates.
|
|
return event
|
|
|
|
await purge_unpaid_tickets(event_id)
|
|
|
|
# closing_date is filled in by create_event (defaults to end_date or
|
|
# start_date) but the field is typed Optional, so guard for the typechecker.
|
|
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).
|
|
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
|
|
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:
|
|
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:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.GONE, detail="Ticket closing date has passed."
|
|
)
|
|
|
|
return event
|
|
|
|
|
|
@events_api_router.post("")
|
|
async def api_event_create(
|
|
data: CreateEvent,
|
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
|
) -> Event:
|
|
"""Create a new event.
|
|
|
|
Anyone with a wallet invoice key can submit. Non-LNbits-admins land in
|
|
`proposed` status unless `auto_approve` is enabled in extension settings.
|
|
"""
|
|
if not data.wallet:
|
|
data.wallet = wallet.wallet.id
|
|
|
|
from lnbits.settings import settings
|
|
|
|
ext_settings = await get_settings()
|
|
user_id = wallet.wallet.user
|
|
is_admin = user_id == settings.super_user or user_id in settings.lnbits_admin_users
|
|
if not is_admin and not ext_settings.auto_approve:
|
|
data.status = "proposed"
|
|
|
|
event = await create_event(data)
|
|
|
|
if event.status == "approved":
|
|
await publish_or_delete_nostr_event(event)
|
|
|
|
return event
|
|
|
|
|
|
@events_api_router.put("/{event_id}")
|
|
async def api_event_update(
|
|
event_id: str,
|
|
data: CreateEvent,
|
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
) -> Event:
|
|
"""Update an event. The owner can edit any mutable field; the status
|
|
is derived (admin / `auto_approve` ⇒ approved, otherwise proposed)
|
|
and is NEVER taken from the request body — that would let owners
|
|
self-approve.
|
|
|
|
Nostr is reconciled against the status transition:
|
|
approved → approved : re-publish the replaceable NIP-52 event
|
|
proposed → approved : fresh publish
|
|
approved → proposed : NIP-09 delete so the public feed drops it
|
|
until the edit is re-approved
|
|
proposed → proposed : no-op
|
|
"""
|
|
event = await get_event(event_id)
|
|
if not event:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
|
)
|
|
if event.wallet != wallet.wallet.id:
|
|
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.")
|
|
|
|
from lnbits.settings import settings
|
|
|
|
ext_settings = await get_settings()
|
|
user_id = wallet.wallet.user
|
|
is_admin = user_id == settings.super_user or user_id in settings.lnbits_admin_users
|
|
|
|
previous_status = event.status
|
|
|
|
# Same defaulting as create_event: optional end/closing dates fall
|
|
# back to start_date when omitted, so an edit that doesn't restate
|
|
# them doesn't wipe them.
|
|
if not data.event_end_date:
|
|
data.event_end_date = data.event_start_date
|
|
if not data.closing_date:
|
|
data.closing_date = data.event_end_date
|
|
|
|
# Explicit field list — never copy `status` from the request body.
|
|
# Includes upstream v1.6.1 fields (allow_fiat, fiat_currency) so an
|
|
# owner editing a fiat-enabled event keeps the fiat config.
|
|
for field in (
|
|
"name",
|
|
"info",
|
|
"closing_date",
|
|
"event_start_date",
|
|
"event_end_date",
|
|
"currency",
|
|
"allow_fiat",
|
|
"fiat_currency",
|
|
"amount_tickets",
|
|
"price_per_ticket",
|
|
"banner",
|
|
"location",
|
|
"categories",
|
|
"extra",
|
|
):
|
|
setattr(event, field, getattr(data, field))
|
|
|
|
event.status = "approved" if (is_admin or ext_settings.auto_approve) else "proposed"
|
|
|
|
event = await update_event(event)
|
|
|
|
if event.status == "approved":
|
|
await publish_or_delete_nostr_event(event)
|
|
elif previous_status == "approved":
|
|
# Take it down from the public feed while it waits for re-approval.
|
|
await publish_or_delete_nostr_event(event, delete=True)
|
|
|
|
return event
|
|
|
|
|
|
@events_api_router.put("/{event_id}/cancel")
|
|
async def api_event_cancel(
|
|
event_id: str,
|
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
) -> Event:
|
|
event = await get_event(event_id)
|
|
if not event:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
|
)
|
|
if event.wallet != wallet.wallet.id:
|
|
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.")
|
|
event.canceled = True
|
|
event = await update_event(event)
|
|
await refund_tickets(event.id)
|
|
|
|
if event.nostr_event_id:
|
|
await publish_or_delete_nostr_event(event, delete=True)
|
|
|
|
return event
|
|
|
|
|
|
@events_api_router.delete("/{event_id}")
|
|
async def api_form_delete(
|
|
event_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
|
) -> None:
|
|
event = await get_event(event_id)
|
|
if not event:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
|
)
|
|
if event.wallet != wallet.wallet.id:
|
|
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.")
|
|
|
|
if event.nostr_event_id:
|
|
await publish_or_delete_nostr_event(event, delete=True)
|
|
|
|
await delete_event(event_id)
|
|
await delete_event_tickets(event_id)
|
|
|
|
|
|
@events_api_router.put("/{event_id}/approve")
|
|
async def api_event_approve(
|
|
event_id: str,
|
|
admin: Account = Depends(check_admin),
|
|
) -> Event:
|
|
event = await get_event(event_id)
|
|
if not event:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
|
)
|
|
if event.status != "proposed":
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST,
|
|
detail=f"Event is already {event.status}.",
|
|
)
|
|
event.status = "approved"
|
|
event = await update_event(event)
|
|
await publish_or_delete_nostr_event(event)
|
|
return event
|
|
|
|
|
|
@events_api_router.put("/{event_id}/reject")
|
|
async def api_event_reject(
|
|
event_id: str,
|
|
admin: Account = Depends(check_admin),
|
|
) -> Event:
|
|
event = await get_event(event_id)
|
|
if not event:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
|
)
|
|
if event.status != "proposed":
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST,
|
|
detail=f"Event is already {event.status}.",
|
|
)
|
|
event.status = "rejected"
|
|
return await update_event(event)
|
|
|
|
|
|
@events_api_router.get(
|
|
"/{event_id}/tickets",
|
|
response_model=list[PublicTicket],
|
|
)
|
|
async def api_event_tickets(event_id: str) -> list[Ticket]:
|
|
return await get_event_tickets(event_id)
|
|
|
|
|
|
@tickets_api_router.get("")
|
|
async def api_tickets(
|
|
all_wallets: bool = Query(False),
|
|
key_info: WalletTypeInfo = Depends(require_admin_key),
|
|
) -> list[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 []
|
|
|
|
return await get_tickets(wallet_ids)
|
|
|
|
|
|
@tickets_api_router.get("/user/{user_id}")
|
|
async def api_tickets_by_user(
|
|
user_id: str,
|
|
user: User = Depends(check_user_exists),
|
|
) -> list[Ticket]:
|
|
"""All tickets for the authenticated user.
|
|
|
|
The `user_id` path param must match the token-bound user so a
|
|
Bearer-authenticated session can only enumerate its own tickets.
|
|
Returns full `Ticket` rows (not `PublicTicket`) since the owner
|
|
needs the payment_hash to render the QR + the `extra` envelope
|
|
to surface payment/refund state in My Tickets.
|
|
"""
|
|
if user_id != user.id:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.FORBIDDEN,
|
|
detail="Can only fetch your own tickets.",
|
|
)
|
|
return await get_tickets_by_user_id(user_id)
|
|
|
|
|
|
@tickets_api_router.get("/{ticket_id}", response_model=PublicTicket)
|
|
async def api_get_ticket(ticket_id: str) -> Ticket:
|
|
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."
|
|
)
|
|
return ticket
|
|
|
|
|
|
def _origin(url: str | None) -> str | None:
|
|
if not url:
|
|
return None
|
|
parts = urlsplit(url.strip())
|
|
if not parts.scheme or not parts.netloc:
|
|
return None
|
|
return f"{parts.scheme.lower()}://{parts.netloc.lower()}"
|
|
|
|
|
|
def _allowed_frontend_origins() -> set[str]:
|
|
"""Origins a buyer-side client may name in `CreateTicket.frontend_url`.
|
|
|
|
The CORS allow-list is literally "which web apps may talk to this
|
|
LNbits", so it is the natural allow-list for "which web apps may be
|
|
linked from a ticket email". The LNbits host itself and the configured
|
|
custom frontend are always fine.
|
|
"""
|
|
origins: set[str] = set()
|
|
for candidate in [
|
|
*getattr(settings, "lnbits_cors_allowed_origins", []),
|
|
settings.lnbits_baseurl,
|
|
getattr(settings, "lnbits_custom_frontend_url", None),
|
|
]:
|
|
origin = _origin(candidate)
|
|
if origin:
|
|
origins.add(origin)
|
|
return origins
|
|
|
|
|
|
def _resolve_frontend_root(data: CreateTicket, request: Request) -> str:
|
|
"""Root under which `/events/{event_id}` and `/events/ticket/{ticket_id}`
|
|
resolve for the buyer — the calling app when it says so, else the LNbits
|
|
host (the extension's own Quasar pages)."""
|
|
if not data.frontend_url:
|
|
return str(request.base_url).rstrip("/")
|
|
origin = _origin(data.frontend_url)
|
|
if not origin or origin not in _allowed_frontend_origins():
|
|
# Fail loud rather than silently falling back: a wrong root means
|
|
# the buyer is returned to (and emailed a link into) the wrong app.
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST,
|
|
detail="frontend_url origin is not allowed.",
|
|
)
|
|
return data.frontend_url.rstrip("/")
|
|
|
|
|
|
async def _issue_free_tickets(
|
|
*,
|
|
event: Event,
|
|
quantity: int,
|
|
name: str | None,
|
|
email: str | None,
|
|
user_id: str | None,
|
|
promo_code: str | None,
|
|
nostr_identifier: str | None,
|
|
frontend_root: str,
|
|
) -> TicketPaymentRequest:
|
|
"""Issue `quantity` free tickets without minting an invoice.
|
|
|
|
Each row is created then run through `set_ticket_paid` — the exact path
|
|
`on_invoice_paid` drives for a settled payment: it flips `paid`, bumps
|
|
the sold / available counters under the per-event lock, and republishes
|
|
the NIP-52 calendar event so connected clients see the new counts.
|
|
Notifications fire the same way. No invoice exists, so `sats_paid` is 0
|
|
and these tickets are naturally skipped by `refund_tickets`.
|
|
|
|
All rows in the batch share one synthetic `payment_hash` — the join key
|
|
the poll / WebSocket / My-Tickets lookups use — mirroring how the paid
|
|
multi-ticket path shares the real invoice hash.
|
|
"""
|
|
payment_hash = urlsafe_short_hash()
|
|
ticket_ids: list[str] = []
|
|
for _ in range(quantity):
|
|
row_id = urlsafe_short_hash()
|
|
ticket = await create_ticket(
|
|
payment_hash=payment_hash,
|
|
wallet=event.wallet,
|
|
event=event.id,
|
|
name=name,
|
|
email=email,
|
|
user_id=user_id,
|
|
ticket_id=row_id,
|
|
extra={
|
|
"applied_promo_code": promo_code,
|
|
"nostr_identifier": nostr_identifier,
|
|
"ticket_base_url": frontend_root,
|
|
"sats_paid": 0,
|
|
},
|
|
)
|
|
await set_ticket_paid(ticket)
|
|
send_ticket_notification_in_background(ticket)
|
|
ticket_ids.append(row_id)
|
|
|
|
return TicketPaymentRequest(
|
|
payment_hash=payment_hash,
|
|
payment_request=None,
|
|
is_fiat=False,
|
|
paid=True,
|
|
ticket_ids=ticket_ids,
|
|
)
|
|
|
|
|
|
@tickets_api_router.post("/{event_id}")
|
|
async def api_ticket_create(
|
|
event_id: str, data: CreateTicket, request: Request
|
|
) -> TicketPaymentRequest:
|
|
event = await get_event(event_id)
|
|
if not event:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
|
)
|
|
if event.status != "approved":
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.GONE,
|
|
detail="Event is not yet open for tickets.",
|
|
)
|
|
if event.canceled:
|
|
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.")
|
|
quantity = data.quantity
|
|
if event.amount_tickets > 0:
|
|
if event.sold >= event.amount_tickets:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.GONE, detail="Event is sold out."
|
|
)
|
|
remaining = event.amount_tickets - event.sold
|
|
if quantity > remaining:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST,
|
|
detail=f"Only {remaining} ticket(s) remaining for this event.",
|
|
)
|
|
|
|
name = data.name
|
|
email = data.email
|
|
user_id = data.user_id
|
|
promo_code = data.promo_code.upper() if data.promo_code else None
|
|
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"}:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST,
|
|
detail="Unsupported payment method.",
|
|
)
|
|
if nostr_identifier and "@" not in nostr_identifier:
|
|
try:
|
|
nostr_identifier = normalize_public_key(nostr_identifier)
|
|
except Exception as exc:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST,
|
|
detail="Invalid Nostr identifier.",
|
|
) from exc
|
|
unit_price = event.price_per_ticket
|
|
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
|
|
frontend_root = _resolve_frontend_root(data, request)
|
|
|
|
if promo_code:
|
|
# check if promo_code exists in event.extra.promo_codes
|
|
if promo_code not in [pc.code for pc in event.extra.promo_codes]:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code."
|
|
)
|
|
# get the promocode
|
|
promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code)
|
|
extra["promo_code"] = promo.code
|
|
unit_price = event.price_per_ticket * (1 - promo.discount_percent / 100)
|
|
# Scale by quantity AFTER the promo applies. One invoice, N tickets.
|
|
price = unit_price * quantity
|
|
|
|
# Free tickets (final charge 0 — a free event or a 100%-off promo).
|
|
# Short-circuit before any invoice / fiat-provider logic: no Lightning
|
|
# invoice can settle for 0, so we issue the rows and mark them paid
|
|
# directly. payment_method is irrelevant here (nothing is charged).
|
|
if price <= 0:
|
|
return await _issue_free_tickets(
|
|
event=event,
|
|
quantity=quantity,
|
|
name=name,
|
|
email=email,
|
|
user_id=user_id,
|
|
promo_code=promo_code,
|
|
nostr_identifier=nostr_identifier,
|
|
frontend_root=frontend_root,
|
|
)
|
|
|
|
# Organizer-controlled rails (extra.payment_methods; legacy events fall
|
|
# back to Lightning + fiat-if-allow_fiat). Checked after the free path
|
|
# because a free claim charges nothing on any rail.
|
|
if payment_method not in effective_payment_methods(event):
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST,
|
|
detail="Payment method not enabled for this event.",
|
|
)
|
|
|
|
if _is_fiat_currency(event.currency):
|
|
extra["fiat"] = True
|
|
extra["currency"] = event.currency
|
|
extra["fiatAmount"] = price
|
|
extra["rate"] = await get_fiat_rate_satoshis(event.currency)
|
|
|
|
if payment_method != "fiat":
|
|
price = await fiat_amount_as_satoshis(price, event.currency)
|
|
|
|
invoice_unit = event.currency
|
|
fiat_amount = price
|
|
fiat_provider = None
|
|
if payment_method == "fiat":
|
|
if _is_fiat_currency(event.currency):
|
|
invoice_unit = event.currency
|
|
else:
|
|
invoice_unit = event.fiat_currency
|
|
fiat_amount = await satoshis_amount_as_fiat(price, invoice_unit)
|
|
extra["fiat"] = True
|
|
extra["currency"] = invoice_unit
|
|
extra["fiatAmount"] = fiat_amount
|
|
extra["rate"] = await get_fiat_rate_satoshis(invoice_unit)
|
|
wallet = await get_wallet(event.wallet)
|
|
if not wallet:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND,
|
|
detail="Event wallet does not exist.",
|
|
)
|
|
providers = settings.get_fiat_providers_for_user(wallet.user)
|
|
fiat_provider = data.fiat_provider or (providers[0] if providers else None)
|
|
if not fiat_provider:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST,
|
|
detail="No fiat payment provider configured for this event.",
|
|
)
|
|
else:
|
|
invoice_unit = "sat"
|
|
|
|
# Each row gets a fresh urlsafe_short_hash id so single- and
|
|
# multi-ticket purchases stay shape-consistent — every scannable
|
|
# ticket id is a short hash, never the long bolt11 payment_hash.
|
|
# The shared `payment_hash` column is the join key for invoice
|
|
# lookup (poll endpoint, ws notifier, set_ticket_paid loop). Ids are
|
|
# minted BEFORE the invoice so the fiat success URL can carry them
|
|
# (the payment_hash only exists after `create_payment_request`).
|
|
ticket_ids: list[str] = [urlsafe_short_hash() for _ in range(quantity)]
|
|
|
|
if payment_method == "fiat":
|
|
# Parameterise the provider's hosted checkout (consumed by
|
|
# lnbits/fiat/stripe.py `StripeCheckoutOptions`): bring the buyer
|
|
# back to the app they came from, lock the email they gave us, and
|
|
# label the line item with the event rather than the raw memo.
|
|
ticket_label = "ticket" if quantity == 1 else "tickets"
|
|
extra["checkout"] = {
|
|
"success_url": (
|
|
f"{frontend_root}/events/{event.id}"
|
|
f"?checkout=success&tickets={','.join(ticket_ids)}"
|
|
),
|
|
"cancel_url": f"{frontend_root}/events/{event.id}?checkout=cancelled",
|
|
"customer_email": email,
|
|
"line_item_name": f"{event.name} — {quantity} {ticket_label}",
|
|
"metadata": {
|
|
"event_id": event.id,
|
|
"quantity": str(quantity),
|
|
"ticket_ids": ",".join(ticket_ids),
|
|
},
|
|
}
|
|
|
|
payment = await create_payment_request(
|
|
wallet_id=event.wallet,
|
|
invoice_data=CreateInvoice(
|
|
out=False,
|
|
amount=fiat_amount if payment_method == "fiat" else price,
|
|
unit=invoice_unit,
|
|
fiat_provider=fiat_provider,
|
|
memo=f"{event_id}",
|
|
extra=extra,
|
|
),
|
|
)
|
|
sats_per_ticket = payment.sat // quantity if quantity else payment.sat
|
|
for row_id in ticket_ids:
|
|
await create_ticket(
|
|
payment_hash=payment.payment_hash,
|
|
wallet=event.wallet,
|
|
event=event.id,
|
|
name=name,
|
|
email=email,
|
|
user_id=user_id,
|
|
ticket_id=row_id,
|
|
extra={
|
|
"applied_promo_code": promo_code,
|
|
"refund_address": refund_address,
|
|
"nostr_identifier": nostr_identifier,
|
|
"ticket_base_url": frontend_root,
|
|
"sats_paid": sats_per_ticket,
|
|
},
|
|
)
|
|
|
|
return TicketPaymentRequest(
|
|
payment_hash=payment.payment_hash,
|
|
payment_request=getattr(payment, "bolt11", None),
|
|
fiat_payment_request=getattr(payment, "extra", {}).get("fiat_payment_request"),
|
|
fiat_provider=getattr(payment, "fiat_provider", None) or fiat_provider,
|
|
is_fiat=bool(getattr(payment, "fiat_provider", None) or fiat_provider),
|
|
ticket_ids=ticket_ids,
|
|
)
|
|
|
|
|
|
@tickets_api_router.post("/{event_id}/{payment_hash}")
|
|
async def api_ticket_payment_status(event_id: str, payment_hash: str) -> dict:
|
|
"""Poll-style payment confirmation for a pending ticket purchase.
|
|
|
|
The webapp polls this every 2s after presenting the invoice until
|
|
`paid: true` comes back, then advances to the success state. The
|
|
companion WebSocket at `/tickets/ws/{payment_hash}` is more
|
|
efficient for pushes — this endpoint is the fallback.
|
|
|
|
Returns `{paid, ticket_ids: [...]}` so multi-ticket buyers get
|
|
every scannable id back in one response (one for single-ticket
|
|
purchases). A missing / cross-event purchase returns
|
|
`paid: false` rather than 404 so the poll doesn't have to
|
|
special-case the not-yet-created race.
|
|
"""
|
|
tickets = await get_tickets_by_payment_hash(payment_hash)
|
|
relevant = [t for t in tickets if t.event == event_id]
|
|
if not relevant:
|
|
return {"paid": False}
|
|
return {
|
|
"paid": all(t.paid for t in relevant),
|
|
"ticket_id": relevant[0].id, # back-compat with single-ticket clients
|
|
"ticket_ids": [t.id for t in relevant],
|
|
}
|
|
|
|
|
|
@tickets_api_router.websocket("/ws/{payment_hash}")
|
|
async def websocket_endpoint(payment_hash: str, websocket: WebSocket) -> None:
|
|
await websocket.accept()
|
|
queue: asyncio.Queue[Ticket] = asyncio.Queue()
|
|
register_payment_listener(payment_hash, queue)
|
|
disconnect_task: asyncio.Task | None = None
|
|
payment_task: asyncio.Task | None = None
|
|
|
|
try:
|
|
ticket = await get_ticket(payment_hash)
|
|
if ticket and ticket.paid:
|
|
await websocket.send_json({"paid": True})
|
|
return
|
|
|
|
while True:
|
|
disconnect_task = asyncio.create_task(websocket.receive_text())
|
|
payment_task = asyncio.create_task(queue.get())
|
|
done, pending = await asyncio.wait(
|
|
{disconnect_task, payment_task}, return_when=asyncio.FIRST_COMPLETED
|
|
)
|
|
|
|
for task in pending:
|
|
task.cancel()
|
|
|
|
if disconnect_task in done:
|
|
try:
|
|
disconnect_task.result()
|
|
except WebSocketDisconnect:
|
|
pass
|
|
break
|
|
|
|
ticket = payment_task.result()
|
|
await websocket.send_json({"paid": ticket.paid})
|
|
if ticket.paid:
|
|
break
|
|
finally:
|
|
for pending_task in (disconnect_task, payment_task):
|
|
if pending_task and not pending_task.done():
|
|
pending_task.cancel()
|
|
deregister_payment_listener(payment_hash, queue)
|
|
|
|
|
|
@tickets_api_router.delete("/{ticket_id}")
|
|
async def api_ticket_delete(
|
|
ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
|
) -> None:
|
|
ticket = await get_ticket(ticket_id)
|
|
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.")
|
|
|
|
await delete_ticket(ticket_id)
|
|
|
|
|
|
@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)
|
|
) -> TicketResendResult:
|
|
ticket = await get_ticket(ticket_id)
|
|
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 not ticket.paid:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.FORBIDDEN,
|
|
detail="Only paid tickets can be resent by email.",
|
|
)
|
|
|
|
try:
|
|
return await resend_ticket_email_notification(ticket)
|
|
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}")
|
|
async def api_event_register_ticket(
|
|
ticket_id: str,
|
|
key_info: WalletTypeInfo = Depends(require_admin_key),
|
|
) -> Ticket:
|
|
"""Mark a ticket as registered at the door.
|
|
|
|
Auth: wallet admin_key. Caller must own the event the ticket
|
|
belongs to — we check `event.wallet` against the user's full
|
|
wallet set so an organizer with multiple wallets can scan
|
|
regardless of which wallet's key they're using.
|
|
|
|
Until v1.6.1-aio.3 this endpoint had no auth, which meant any
|
|
caller who knew a ticket id could register it. The
|
|
Nostr-transport flow at `events_ticket_register` is now the
|
|
preferred call site for the webapp; this HTTP path stays for
|
|
the legacy LNbits Quasar register page which already sends
|
|
the wallet admin_key through `LNbits.api.request`.
|
|
"""
|
|
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."
|
|
)
|
|
|
|
user = await get_user(key_info.wallet.user)
|
|
owned_wallet_ids = user.wallet_ids if user else [key_info.wallet.id]
|
|
if event.wallet not in owned_wallet_ids:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.FORBIDDEN,
|
|
detail="You do not own this event.",
|
|
)
|
|
|
|
if not ticket.paid:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.FORBIDDEN, detail="Ticket not paid for."
|
|
)
|
|
|
|
if ticket.registered is True:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.FORBIDDEN, detail="Ticket already registered"
|
|
)
|
|
|
|
ticket.registered = True
|
|
ticket.reg_timestamp = datetime.now(timezone.utc)
|
|
ticket = await update_ticket(ticket)
|
|
return ticket
|
|
|
|
|
|
@tickets_api_router.get("/event/{event_id}/stats")
|
|
async def api_event_ticket_stats(
|
|
event_id: str,
|
|
key_info: WalletTypeInfo = Depends(require_admin_key),
|
|
) -> dict:
|
|
"""Door-scanner roster + counts for one event, organizer-only.
|
|
|
|
Mirrors the `events_list_event_tickets` nostr-transport RPC for
|
|
callers that don't hold a raw user prvkey (the webapp post-#9, in
|
|
particular). Auth: wallet admin_key + the event's wallet must be
|
|
in the caller's wallet set.
|
|
"""
|
|
event = await get_event(event_id)
|
|
if not event:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
|
)
|
|
|
|
user = await get_user(key_info.wallet.user)
|
|
owned_wallet_ids = user.wallet_ids if user else [key_info.wallet.id]
|
|
if event.wallet not in owned_wallet_ids:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.FORBIDDEN,
|
|
detail="You do not own this event.",
|
|
)
|
|
|
|
tickets = await get_tickets_by_event(event_id)
|
|
paid_tickets = [t for t in tickets if t.paid]
|
|
registered_count = sum(1 for t in paid_tickets if t.registered)
|
|
|
|
return {
|
|
"event_id": event_id,
|
|
"sold": len(paid_tickets),
|
|
"registered": registered_count,
|
|
"remaining": len(paid_tickets) - registered_count,
|
|
"tickets": [
|
|
{
|
|
"id": t.id,
|
|
"name": t.name,
|
|
"registered": t.registered,
|
|
"registered_at": (
|
|
t.reg_timestamp.isoformat() if t.reg_timestamp else None
|
|
),
|
|
}
|
|
for t in paid_tickets
|
|
],
|
|
}
|
|
|
|
|
|
@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."""
|
|
ticket = await get_ticket(ticket_id)
|
|
if not ticket:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Ticket 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",
|
|
},
|
|
)
|
|
|
|
|
|
@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",
|
|
},
|
|
)
|