Some checks failed
lint.yml / refactor: consolidate create and propose endpoints into single POST /events (pull_request) Failing after 0s
Remove separate /events/propose endpoint. POST /events now uses
invoice key (any user) and determines approval status based on:
- LNbits admin → auto-approved
- auto_approve setting → auto-approved
- Otherwise → proposed (requires admin approval)
Separate PUT /events/{id} for updates (admin key, event owner).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
520 lines
16 KiB
Python
520 lines
16 KiB
Python
from datetime import datetime, timezone
|
|
from http import HTTPStatus
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
from lnbits.core.crud import get_standalone_payment, get_user
|
|
from lnbits.core.models import Account, WalletTypeInfo
|
|
from lnbits.core.services import create_invoice
|
|
from lnbits.decorators import (
|
|
check_admin,
|
|
require_admin_key,
|
|
require_invoice_key,
|
|
)
|
|
from lnbits.utils.exchange_rates import (
|
|
fiat_amount_as_satoshis,
|
|
get_fiat_rate_satoshis,
|
|
)
|
|
from starlette.exceptions import HTTPException
|
|
|
|
from .crud import (
|
|
create_event,
|
|
create_ticket,
|
|
delete_event,
|
|
delete_event_tickets,
|
|
delete_ticket,
|
|
get_event,
|
|
get_event_tickets,
|
|
get_events,
|
|
get_pending_events,
|
|
get_public_events,
|
|
get_settings,
|
|
get_ticket,
|
|
get_tickets,
|
|
get_tickets_by_user_id,
|
|
# TODO: consider exposing purge_unpaid_tickets via an admin endpoint
|
|
update_event,
|
|
update_settings,
|
|
update_ticket,
|
|
)
|
|
from .models import CreateEvent, CreateTicket, EventsSettings, Ticket
|
|
from .nostr_publisher import publish_event_to_nostr
|
|
from .services import refund_tickets, set_ticket_paid
|
|
|
|
events_api_router = APIRouter()
|
|
|
|
|
|
async def _publish_or_delete_nostr_event(event, delete=False):
|
|
"""Publish (or delete) a NIP-52 calendar event using the creator's keypair."""
|
|
try:
|
|
from lnbits.core.crud.wallets import get_wallet
|
|
from lnbits.core.crud.users import get_account
|
|
|
|
from . import nostr_client
|
|
|
|
wallet_obj = await get_wallet(event.wallet)
|
|
if not wallet_obj:
|
|
return
|
|
account = await get_account(wallet_obj.user)
|
|
if not account or not account.pubkey or not account.prvkey:
|
|
return
|
|
|
|
nostr_event = await publish_event_to_nostr(
|
|
nostr_client, event, account.pubkey, account.prvkey, delete=delete
|
|
)
|
|
if nostr_event and not delete:
|
|
event.nostr_event_id = nostr_event.id
|
|
event.nostr_event_created_at = nostr_event.created_at
|
|
await update_event(event)
|
|
except Exception as e:
|
|
logger.warning(f"[EVENTS] Nostr publish failed: {e}")
|
|
|
|
|
|
@events_api_router.get("/api/v1/events")
|
|
async def api_events(
|
|
all_wallets: bool = Query(False),
|
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
|
):
|
|
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)]
|
|
|
|
|
|
@events_api_router.get("/api/v1/events/public")
|
|
async def api_events_public():
|
|
"""
|
|
Retrieve approved, non-canceled events for public display.
|
|
No authentication required.
|
|
"""
|
|
events = await get_public_events()
|
|
return [event.dict() for event in events]
|
|
|
|
|
|
@events_api_router.get("/api/v1/events/all")
|
|
async def api_events_all(
|
|
admin: Account = Depends(check_admin),
|
|
):
|
|
"""Get all events across all wallets. LNbits admin only."""
|
|
from .crud import get_all_events
|
|
|
|
events = await get_all_events()
|
|
return [event.dict() for event in events]
|
|
|
|
|
|
@events_api_router.post("/api/v1/events")
|
|
async def api_event_create(
|
|
data: CreateEvent,
|
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
|
):
|
|
"""
|
|
Create a new event. Any authenticated user can create events.
|
|
Admin-created events are auto-approved. Non-admin events require
|
|
approval 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)
|
|
|
|
# Publish to Nostr if approved
|
|
if event.status == "approved":
|
|
await _publish_or_delete_nostr_event(event)
|
|
|
|
return event.dict()
|
|
|
|
|
|
@events_api_router.put("/api/v1/events/{event_id}")
|
|
async def api_event_update(
|
|
event_id: str,
|
|
data: CreateEvent,
|
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
):
|
|
"""Update an existing event. Requires admin key (event owner)."""
|
|
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."
|
|
)
|
|
for k, v in data.dict().items():
|
|
setattr(event, k, v)
|
|
event = await update_event(event)
|
|
|
|
# Republish to Nostr if event is approved (kind 31922 is replaceable)
|
|
if event.status == "approved" and event.nostr_event_id:
|
|
await _publish_or_delete_nostr_event(event)
|
|
|
|
return event.dict()
|
|
|
|
|
|
@events_api_router.put("/api/v1/events/{event_id}/cancel")
|
|
async def api_event_cancel(
|
|
event_id: str,
|
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
):
|
|
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)
|
|
|
|
# Delete NIP-52 event from Nostr if it was published
|
|
if event.nostr_event_id:
|
|
await _publish_or_delete_nostr_event(event, delete=True)
|
|
|
|
return event.dict()
|
|
|
|
|
|
@events_api_router.delete("/api/v1/events/{event_id}")
|
|
async def api_form_delete(
|
|
event_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
|
):
|
|
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.")
|
|
|
|
# Delete NIP-52 event from Nostr if it was published
|
|
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)
|
|
return "", HTTPStatus.NO_CONTENT
|
|
|
|
|
|
#########Event Approval##########
|
|
|
|
|
|
@events_api_router.get("/api/v1/events/pending")
|
|
async def api_events_pending(
|
|
admin: Account = Depends(check_admin),
|
|
):
|
|
"""Get all proposed events awaiting approval. LNbits admin only."""
|
|
events = await get_pending_events()
|
|
return [event.dict() for event in events]
|
|
|
|
|
|
@events_api_router.put("/api/v1/events/{event_id}/approve")
|
|
async def api_event_approve(
|
|
event_id: str,
|
|
admin: Account = Depends(check_admin),
|
|
):
|
|
"""Approve a proposed event. LNbits admin only."""
|
|
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)
|
|
|
|
# Publish NIP-52 calendar event to Nostr
|
|
await _publish_or_delete_nostr_event(event)
|
|
|
|
return event.dict()
|
|
|
|
|
|
@events_api_router.put("/api/v1/events/{event_id}/reject")
|
|
async def api_event_reject(
|
|
event_id: str,
|
|
admin: Account = Depends(check_admin),
|
|
):
|
|
"""Reject a proposed event. LNbits admin only."""
|
|
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"
|
|
event = await update_event(event)
|
|
return event.dict()
|
|
|
|
|
|
#########Settings##########
|
|
|
|
|
|
@events_api_router.get("/api/v1/settings")
|
|
async def api_get_settings(
|
|
admin: Account = Depends(check_admin),
|
|
) -> EventsSettings:
|
|
"""Get extension settings. LNbits admin only."""
|
|
return await get_settings()
|
|
|
|
|
|
@events_api_router.put("/api/v1/settings")
|
|
async def api_update_settings(
|
|
data: EventsSettings,
|
|
admin: Account = Depends(check_admin),
|
|
) -> EventsSettings:
|
|
"""Update extension settings. LNbits admin only."""
|
|
return await update_settings(data)
|
|
|
|
|
|
#########Tickets##########
|
|
|
|
|
|
@events_api_router.get("/api/v1/tickets")
|
|
async def api_tickets(
|
|
all_wallets: bool = Query(False),
|
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
|
) -> list[Ticket]:
|
|
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_tickets(wallet_ids)
|
|
|
|
|
|
@events_api_router.get("/api/v1/tickets/user/{user_id}")
|
|
async def api_tickets_by_user_id(user_id: str) -> list[Ticket]:
|
|
"""Get all tickets for a specific user by their user_id"""
|
|
return await get_tickets_by_user_id(user_id)
|
|
|
|
|
|
@events_api_router.post("/api/v1/tickets/{event_id}")
|
|
async def api_ticket_create(event_id: str, data: CreateTicket):
|
|
if data.user_id:
|
|
return await api_ticket_make_ticket_with_user_id(event_id, data.user_id)
|
|
else:
|
|
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, data.name, data.email, promo_code, refund_address
|
|
)
|
|
|
|
|
|
async def api_ticket_make_ticket_with_user_id(event_id: str, user_id: str):
|
|
event = await get_event(event_id)
|
|
if not event:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
|
)
|
|
|
|
price = event.price_per_ticket
|
|
extra = {"tag": "events", "user_id": user_id}
|
|
|
|
if event.currency != "sats":
|
|
price = await fiat_amount_as_satoshis(event.price_per_ticket, event.currency)
|
|
|
|
extra["fiat"] = True
|
|
extra["currency"] = event.currency
|
|
extra["fiatAmount"] = event.price_per_ticket
|
|
extra["rate"] = await get_fiat_rate_satoshis(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,
|
|
user_id=user_id,
|
|
)
|
|
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.get("/api/v1/tickets/{event_id}/user/{user_id}")
|
|
async def api_ticket_make_ticket_user_id(event_id: str, user_id: str):
|
|
return await api_ticket_make_ticket_with_user_id(event_id, user_id)
|
|
|
|
|
|
@events_api_router.get("/api/v1/tickets/{event_id}/{name}/{email}")
|
|
async def api_ticket_make_ticket(
|
|
event_id, name, email, promo_code=None, refund_address=None
|
|
):
|
|
event = await get_event(event_id)
|
|
if not event:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
|
)
|
|
|
|
price = event.price_per_ticket
|
|
extra = {"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
|
|
price = event.price_per_ticket * (1 - promo.discount_percent / 100)
|
|
|
|
if event.currency != "sats":
|
|
extra["fiat"] = True
|
|
extra["currency"] = event.currency
|
|
extra["fiatAmount"] = price
|
|
extra["rate"] = await get_fiat_rate_satoshis(event.currency)
|
|
|
|
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
|
|
)
|
|
|
|
# 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}
|
|
|
|
|
|
@events_api_router.delete("/api/v1/tickets/{ticket_id}")
|
|
async def api_ticket_delete(
|
|
ticket_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
|
|
):
|
|
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)
|
|
|
|
|
|
@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]:
|
|
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)
|
|
await update_ticket(ticket)
|
|
return await get_event_tickets(ticket.event)
|