events/views_api.py
Padreug 7b761a1aef
Some checks failed
lint.yml / fix: every ticket row gets a fresh short-hash id (no payment_hash reuse) (pull_request) Failing after 0s
lint.yml / fix: every ticket row gets a fresh short-hash id (no payment_hash reuse) (push) Failing after 0s
fix: every ticket row gets a fresh short-hash id (no payment_hash reuse)
Previous commit reused the LNbits invoice payment_hash as the
first row's id, so a 3-ticket purchase ended up with one 64-hex
id and two short-hash ids — inconsistent and noisy in My Tickets.

Switch every row to urlsafe_short_hash. The shared payment_hash
column is the join key for invoice lookups (poll endpoint, ws
notifier, on_invoice_paid); rows never need to BE the payment
hash, they only need to point at it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:57:00 +02:00

790 lines
27 KiB
Python

import asyncio
from datetime import datetime, timezone
from http import HTTPStatus
from typing import Any
from fastapi import (
APIRouter,
Depends,
HTTPException,
Query,
Request,
WebSocket,
WebSocketDisconnect,
)
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.helpers import urlsafe_short_hash
from lnbits.decorators import (
check_admin,
check_user_exists,
require_admin_key,
require_invoice_key,
)
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_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,
)
from .nostr_hooks import publish_or_delete_nostr_event
from .services import refund_tickets, resend_ticket_email_notification
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")
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[Event]:
"""All events across all wallets. LNbits admin only."""
return await get_all_events()
@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
@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}
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
if payment_method == "fiat" and not event.allow_fiat:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Fiat payments are 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"
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,
),
)
# 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).
ticket_ids: list[str] = []
sats_per_ticket = payment.sat // quantity if quantity else payment.sat
for _ in range(quantity):
row_id = urlsafe_short_hash()
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": str(request.base_url).rstrip("/"),
"sats_paid": sats_per_ticket,
},
)
ticket_ids.append(row_id)
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")
async def api_ticket_resend_email(
ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> Ticket:
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) -> Ticket:
ticket = await get_ticket(ticket_id)
if not ticket:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
)
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