feat: make events dynamic (#43)

---------

Co-authored-by: dni <office@dnilabs.com>
This commit is contained in:
Tiago Vasconcelos 2026-05-04 16:01:53 +01:00 committed by GitHub
commit 9e477ac959
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1164 additions and 1143 deletions

View file

@ -1,8 +1,17 @@
import asyncio
from datetime import datetime, timezone
from http import HTTPStatus
from typing import Any
from fastapi import APIRouter, Depends, Query
from lnbits.core.crud import get_standalone_payment, get_user
from fastapi import (
APIRouter,
Depends,
HTTPException,
Query,
WebSocket,
WebSocketDisconnect,
)
from lnbits.core.crud import get_user
from lnbits.core.models import WalletTypeInfo
from lnbits.core.services import create_invoice
from lnbits.decorators import (
@ -13,7 +22,6 @@ from lnbits.utils.exchange_rates import (
fiat_amount_as_satoshis,
get_fiat_rate_satoshis,
)
from starlette.exceptions import HTTPException
from .crud import (
create_event,
@ -26,36 +34,79 @@ from .crud import (
get_events,
get_ticket,
get_tickets,
purge_unpaid_tickets,
update_event,
update_ticket,
)
from .models import CreateEvent, CreateTicket, Ticket
from .services import refund_tickets, set_ticket_paid
from .models import (
CreateEvent,
CreateTicket,
Event,
PublicEvent,
PublicTicket,
Ticket,
TicketPaymentRequest,
)
from .services import refund_tickets
from .tasks import deregister_payment_listener, register_payment_listener
events_api_router = APIRouter()
events_api_router = APIRouter(prefix="/api/v1/events")
tickets_api_router = APIRouter(prefix="/api/v1/tickets")
@events_api_router.get("/api/v1/events")
@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 [event.dict() for event in await get_events(wallet_ids)]
return await get_events(wallet_ids)
@events_api_router.post("/api/v1/events")
@events_api_router.put("/api/v1/events/{event_id}")
@events_api_router.get("/{event_id}", response_model=PublicEvent)
async def api_get_event(event_id: str) -> Event:
event = await get_event(event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
await purge_unpaid_tickets(event_id)
is_window_open = datetime.now(timezone.utc) < datetime.strptime(
event.closing_date, "%Y-%m-%d"
).replace(tzinfo=timezone.utc)
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("")
@events_api_router.put("/{event_id}")
async def api_event_create(
data: CreateEvent,
wallet: WalletTypeInfo = Depends(require_admin_key),
event_id: str | None = None,
):
) -> Event:
if event_id:
event = await get_event(event_id)
if not event:
@ -73,14 +124,14 @@ async def api_event_create(
else:
event = await create_event(data)
return event.dict()
return event
@events_api_router.put("/api/v1/events/{event_id}/cancel")
@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(
@ -93,13 +144,13 @@ async def api_event_cancel(
event = await update_event(event)
await refund_tickets(event.id)
return event.dict()
return event
@events_api_router.delete("/api/v1/events/{event_id}")
@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(
@ -111,47 +162,65 @@ async def api_form_delete(
await delete_event(event_id)
await delete_event_tickets(event_id)
return "", HTTPStatus.NO_CONTENT
#########Tickets##########
@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)
@events_api_router.get("/api/v1/tickets")
@tickets_api_router.get("")
async def api_tickets(
all_wallets: bool = Query(False),
wallet: WalletTypeInfo = Depends(require_invoice_key),
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> list[Ticket]:
wallet_ids = [wallet.wallet.id]
wallet_ids = [key_info.wallet.id]
if all_wallets:
user = await get_user(wallet.wallet.user)
user = await get_user(key_info.wallet.user)
wallet_ids = user.wallet_ids if user else []
return await get_tickets(wallet_ids)
@events_api_router.post("/api/v1/tickets/{event_id}")
async def api_ticket_create(event_id: str, data: CreateTicket):
name = data.name
email = data.email
promo_code = data.promo_code.upper() if data.promo_code else None
refund_address = data.refund_address
return await api_ticket_make_ticket(
event_id, name, email, promo_code, refund_address
)
@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
@events_api_router.get("/api/v1/tickets/{event_id}/{name}/{email}")
async def api_ticket_make_ticket(event_id, name, email, promo_code, refund_address):
@tickets_api_router.post("/{event_id}")
async def api_ticket_create(event_id: str, data: CreateTicket) -> TicketPaymentRequest:
event = await get_event(event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
if event.canceled:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.")
if event.amount_tickets > 0 and event.sold >= event.amount_tickets:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
name = data.name
email = data.email
promo_code = data.promo_code.upper() if data.promo_code else None
refund_address = data.refund_address
price = event.price_per_ticket
extra = {"tag": "events", "name": name, "email": email}
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
if promo_code:
# check if promo_code exists in event.extra.promo_codes
@ -172,84 +241,76 @@ async def api_ticket_make_ticket(event_id, name, email, promo_code, refund_addre
price = await fiat_amount_as_satoshis(price, event.currency)
try:
payment = await create_invoice(
wallet_id=event.wallet,
amount=price,
memo=f"{event_id}",
extra=extra,
)
await create_ticket(
payment_hash=payment.payment_hash,
wallet=event.wallet,
event=event.id,
name=name,
email=email,
extra={
"applied_promo_code": promo_code,
"refund_address": refund_address,
"sats_paid": int(price),
},
)
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
) from exc
return {"payment_hash": payment.payment_hash, "payment_request": payment.bolt11}
@events_api_router.post("/api/v1/tickets/{event_id}/{payment_hash}")
async def api_ticket_send_ticket(event_id, payment_hash):
event = await get_event(event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Event could not be fetched.",
)
ticket = await get_ticket(payment_hash)
if not ticket:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Ticket could not be fetched.",
)
payment = await get_standalone_payment(payment_hash, incoming=True)
assert payment
if ticket.extra.applied_promo_code:
promo = next(
(
pc
for pc in event.extra.promo_codes
if pc.code == ticket.extra.applied_promo_code
),
None,
)
if promo:
event.price_per_ticket *= 1 - promo.discount_percent / 100
price = (
event.price_per_ticket * 1000
if event.currency == "sats"
else await fiat_amount_as_satoshis(event.price_per_ticket, event.currency)
* 1000
payment = await create_invoice(
wallet_id=event.wallet,
amount=price,
memo=f"{event_id}",
extra=extra,
)
await create_ticket(
payment_hash=payment.payment_hash,
wallet=event.wallet,
event=event.id,
name=name,
email=email,
extra={
"applied_promo_code": promo_code,
"refund_address": refund_address,
"sats_paid": int(price),
},
)
# check if price is equal to payment.amount
lower_bound = price * 0.99 # 1% decrease
if not payment.pending and abs(payment.amount) >= lower_bound: # allow 1% error
ticket.extra.sats_paid = int(payment.amount / 1000)
await set_ticket_paid(ticket)
return {"paid": True, "ticket_id": ticket.id}
return {"paid": False}
return TicketPaymentRequest(
payment_hash=payment.payment_hash, payment_request=payment.bolt11
)
@events_api_router.delete("/api/v1/tickets/{ticket_id}")
@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_invoice_key)
):
ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> None:
ticket = await get_ticket(ticket_id)
if not ticket:
raise HTTPException(
@ -262,14 +323,8 @@ async def api_ticket_delete(
await delete_ticket(ticket_id)
@events_api_router.get("/api/v1/eventtickets/{event_id}")
async def api_event_tickets(event_id: str) -> list[Ticket]:
return await get_event_tickets(event_id)
# TODO: PUT, updates db! @tal
@events_api_router.get("/api/v1/register/ticket/{ticket_id}")
async def api_event_register_ticket(ticket_id) -> list[Ticket]:
@tickets_api_router.put("/register/{ticket_id}", response_model=PublicTicket)
async def api_event_register_ticket(ticket_id) -> Ticket:
ticket = await get_ticket(ticket_id)
if not ticket:
@ -289,5 +344,5 @@ async def api_event_register_ticket(ticket_id) -> list[Ticket]:
ticket.registered = True
ticket.reg_timestamp = datetime.now(timezone.utc)
await update_ticket(ticket)
return await get_event_tickets(ticket.event)
ticket = await update_ticket(ticket)
return ticket