diff --git a/__init__.py b/__init__.py index 14c1590..ef37528 100644 --- a/__init__.py +++ b/__init__.py @@ -6,11 +6,12 @@ from .crud import db from .tasks import wait_for_paid_invoices from .views import events_generic_router -from .views_api import events_api_router +from .views_api import events_api_router, tickets_api_router events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"]) events_ext.include_router(events_generic_router) events_ext.include_router(events_api_router) +events_ext.include_router(tickets_api_router) events_static_files = [ { diff --git a/config.json b/config.json index 8a9a61c..11bd0b1 100644 --- a/config.json +++ b/config.json @@ -1,12 +1,12 @@ { "id": "events", - "version": "1.2.1", + "version": "1.3.0", "name": "Events", "repo": "https://github.com/lnbits/events", "short_description": "Sell and register event tickets", "description": "", "tile": "/events/static/image/events.png", - "min_lnbits_version": "1.3.0", + "min_lnbits_version": "1.4.1", "contributors": [ { "name": "talvasconcelos", @@ -14,7 +14,7 @@ "role": "Developer" }, { - "name": "DNI", + "name": "dni", "uri": "https://github.com/dni", "role": "Developer" }, diff --git a/models.py b/models.py index f82890e..14547d1 100644 --- a/models.py +++ b/models.py @@ -58,6 +58,17 @@ class Event(BaseModel): extra: EventExtra = Field(default_factory=EventExtra) +class PublicEvent(BaseModel): + id: str + name: str + info: str + closing_date: str + canceled: bool + event_start_date: str + event_end_date: str + banner: str | None + + class TicketExtra(BaseModel): applied_promo_code: str | None = None sats_paid: int | None = None @@ -83,3 +94,17 @@ class Ticket(BaseModel): time: datetime reg_timestamp: datetime extra: TicketExtra = Field(default_factory=TicketExtra) + + +class PublicTicket(BaseModel): + event: str + name: str + registered: bool + paid: bool + time: datetime + reg_timestamp: datetime + + +class TicketPaymentRequest(BaseModel): + payment_hash: str + payment_request: str diff --git a/static/js/display.js b/static/js/display.js index 6098e5a..a652ba5 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -1,8 +1,9 @@ -window.app = Vue.createApp({ - el: '#vue', - mixins: [windowMixin], +window.PageEventsDisplay = { + template: '#page-events-display', data() { return { + eventErrorLabel: '', + event: null, paymentReq: null, redirectUrl: null, formDialog: { @@ -23,15 +24,14 @@ window.app = Vue.createApp({ show: false, status: 'pending', paymentReq: null - } + }, + paymentDismissMsg: null, + paymentWebsocket: null } }, async created() { - this.info = event_info - this.info = this.info.substring(1, this.info.length - 1) - this.banner = event_banner - this.extra = event_extra - this.hasPromoCodes = has_promoCodes + this.eventId = this.$route.params.id + this.event = await this.getEvent() }, computed: { formatDescription() { @@ -39,6 +39,18 @@ window.app = Vue.createApp({ } }, methods: { + async getEvent() { + try { + const {data} = await LNbits.api.request( + 'GET', + `/events/api/v1/events/${this.eventId}` + ) + return data + } catch (error) { + this.eventErrorLabel = 'Event unavailable.' + LNbits.utils.notifyApiError(error) + } + }, resetForm(e) { e.preventDefault() this.formDialog.data.name = '' @@ -47,10 +59,14 @@ window.app = Vue.createApp({ }, closeReceiveDialog() { - const checker = this.receive.paymentChecker - dismissMsg() - clearInterval(paymentChecker) - setTimeout(() => {}, 10000) + if (this.paymentDismissMsg) { + this.paymentDismissMsg() + this.paymentDismissMsg = null + } + if (this.paymentWebsocket) { + this.paymentWebsocket.close() + this.paymentWebsocket = null + } }, nameValidation(val) { const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g @@ -63,68 +79,93 @@ window.app = Vue.createApp({ const regex = /^[\w\.-]+@[a-zA-Z\d\.-]+\.[a-zA-Z]{2,}$/ return regex.test(val) || 'Please enter valid email.' }, - Invoice() { - axios - .post(`/events/api/v1/tickets/${event_id}`, { - name: this.formDialog.data.name, - email: this.formDialog.data.email, - promo_code: this.formDialog.data.promo_code || null - }) - .then(response => { - this.paymentReq = response.data.payment_request - this.paymentCheck = response.data.payment_hash + paymentSuccess(paymentHash) { + if (this.paymentDismissMsg) { + this.paymentDismissMsg() + this.paymentDismissMsg = null + } + this.paymentReq = null + this.formDialog.data.name = '' + this.formDialog.data.email = '' + Quasar.Notify.create({ + type: 'positive', + message: 'Sent, thank you!', + icon: null + }) + this.receive = { + show: false, + status: 'complete', + paymentReq: null + } + this.ticketLink = { + show: true, + data: { + link: `/events/ticket/${paymentHash}` + } + } + setTimeout(() => { + window.location.href = `/events/ticket/${paymentHash}` + }, 5000) + }, + async createInvoice() { + try { + const {data} = await LNbits.api.request( + 'POST', + `/events/api/v1/tickets/${this.eventId}`, + null, + { + name: this.formDialog.data.name, + email: this.formDialog.data.email, + promo_code: this.formDialog.data.promo_code || null, + refund_address: this.formDialog.data.refund || null + } + ) + this.paymentReq = data.payment_request + this.paymentHash = data.payment_hash - dismissMsg = Quasar.Notify.create({ - timeout: 0, - message: 'Waiting for payment...' - }) + this.paymentDismissMsg = Quasar.Notify.create({ + timeout: 0, + message: 'Waiting for payment...' + }) + this.receive = { + show: true, + status: 'pending', + paymentReq: this.paymentReq + } + this.websocketListener(this.paymentHash) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + websocketListener(paymentHash) { + if (this.paymentWebsocket) { + this.paymentWebsocket.close() + } - this.receive = { - show: true, - status: 'pending', - paymentReq: this.paymentReq - } - paymentChecker = setInterval(() => { - axios - .post(`/events/api/v1/tickets/${event_id}/${this.paymentCheck}`, { - event: event_id, - event_name: event_name, - name: this.formDialog.data.name, - email: this.formDialog.data.email - }) - .then(res => { - if (res.data.paid) { - clearInterval(paymentChecker) - dismissMsg() - this.formDialog.data.name = '' - this.formDialog.data.email = '' + const url = new URL(window.location) + url.protocol = url.protocol === 'https:' ? 'wss' : 'ws' + url.pathname = `/events/api/v1/tickets/ws/${paymentHash}` + url.search = '' + url.hash = '' - Quasar.Notify.create({ - type: 'positive', - message: 'Sent, thank you!', - icon: null - }) - this.receive = { - show: false, - status: 'complete', - paymentReq: null - } + const ws = new WebSocket(url) + this.paymentWebsocket = ws - this.ticketLink = { - show: true, - data: { - link: `/events/ticket/${res.data.ticket_id}` - } - } - setTimeout(() => { - window.location.href = `/events/ticket/${res.data.ticket_id}` - }, 5000) - } - }) - .catch(LNbits.utils.notifyApiError) - }, 2000) - }) - .catch(LNbits.utils.notifyApiError) + ws.onmessage = event => { + const data = JSON.parse(event.data) + if (data.paid) { + this.paymentSuccess(paymentHash) + ws.close() + } + } + ws.onerror = error => { + console.error('WebSocket error:', error) + } + ws.onclose = () => { + if (this.paymentWebsocket === ws) { + this.paymentWebsocket = null + } + } } } -}) +} diff --git a/static/js/display.vue b/static/js/display.vue new file mode 100644 index 0000000..3f80180 --- /dev/null +++ b/static/js/display.vue @@ -0,0 +1,125 @@ + diff --git a/static/js/index.js b/static/js/index.js index d26133c..ca34383 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,13 +1,5 @@ -const mapEvents = function (obj) { - obj.date = LNbits.utils.formatTimestamp(obj.time) - obj.fsat = new Intl.NumberFormat(window.g.locale).format(obj.price_per_ticket) - obj.displayUrl = ['/events/', obj.id].join('') - return obj -} - -window.app = Vue.createApp({ - el: '#vue', - mixins: [windowMixin], +window.PageEvents = { + template: '#page-events', data() { return { events: [], @@ -105,6 +97,7 @@ window.app = Vue.createApp({ formDialog: { show: false, data: { + currency: 'sats', extra: { promo_codes: [] } @@ -118,18 +111,15 @@ window.app = Vue.createApp({ .request( 'GET', '/events/api/v1/tickets?all_wallets=true', - this.g.user.wallets[0].inkey + this.g.user.wallets[0].adminkey ) .then(response => { - this.tickets = response.data - .map(function (obj) { - return mapEvents(obj) - }) - .filter(e => e.paid) + this.tickets = response.data.filter(e => e.paid) }) }, deleteTicket(ticketId) { const tickets = _.findWhere(this.tickets, {id: ticketId}) + const wallet = _.findWhere(this.g.user.wallets, {id: tickets.wallet}) LNbits.utils .confirmDialog('Are you sure you want to delete this ticket') @@ -138,16 +128,14 @@ window.app = Vue.createApp({ .request( 'DELETE', '/events/api/v1/tickets/' + ticketId, - _.findWhere(this.g.user.wallets, {id: tickets.wallet}).inkey + wallet.adminkey ) .then(response => { this.tickets = _.reject(this.tickets, function (obj) { return obj.id == ticketId }) }) - .catch(function (error) { - LNbits.utils.notifyApiError(error) - }) + .catch(LNbits.utils.notifyApiError) }) }, exportticketsCSV() { @@ -161,9 +149,7 @@ window.app = Vue.createApp({ this.g.user.wallets[0].inkey ) .then(response => { - this.events = response.data.map(obj => { - return mapEvents(obj) - }) + this.events = response.data this.checkCanceledEvents() }) }, @@ -190,6 +176,7 @@ window.app = Vue.createApp({ this.formDialog.data = {...data} } else { this.formDialog.data = { + currency: 'sats', extra: { conditional: false, min_tickets: 1, @@ -212,7 +199,7 @@ window.app = Vue.createApp({ LNbits.api .request('POST', '/events/api/v1/events', wallet.adminkey, data) .then(response => { - this.events.push(mapEvents(response.data)) + this.events.push(response.data) this.resetEventDialog() }) .catch(LNbits.utils.notifyApiError) @@ -233,7 +220,7 @@ window.app = Vue.createApp({ this.events = _.reject(this.events, function (obj) { return obj.id == data.id }) - this.events.push(mapEvents(response.data)) + this.events.push(response.data) this.resetEventDialog() }) .catch(LNbits.utils.notifyApiError) @@ -255,7 +242,7 @@ window.app = Vue.createApp({ return obj.id == eventsId }) }) - .catch(LNbits.utils.notifyApiError(error)) + .catch(LNbits.utils.notifyApiError) }) }, exporteventsCSV() { @@ -279,9 +266,7 @@ window.app = Vue.createApp({ message: `Event ${ev.name} has been canceled and refunds have been issued.`, icon: null }) - this.events = this.events.map(e => - e.id === ev.id ? mapEvents(data) : e - ) + this.events = this.events.map(e => (e.id === ev.id ? data : e)) } }) } @@ -290,7 +275,11 @@ window.app = Vue.createApp({ if (this.g.user.wallets.length) { this.getTickets() this.getEvents() - this.currencies = await LNbits.api.getCurrencies() + if (this.g.allowedCurrencies && this.g.allowedCurrencies.length > 0) { + this.currencies = ['sats', ...this.g.allowedCurrencies] + } else { + this.currencies = ['sats', ...this.g.currencies] + } } } -}) +} diff --git a/static/js/index.vue b/static/js/index.vue new file mode 100644 index 0000000..174f0c1 --- /dev/null +++ b/static/js/index.vue @@ -0,0 +1,511 @@ + diff --git a/static/js/register.js b/static/js/register.js index a7ab92f..76ccbcb 100644 --- a/static/js/register.js +++ b/static/js/register.js @@ -1,28 +1,22 @@ -const mapEvents = function (obj) { - obj.date = Quasar.date.formatDate( - new Date(obj.time * 1000), - 'YYYY-MM-DD HH:mm' - ) - obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount) - obj.displayUrl = ['/events/', obj.id].join('') - return obj -} - -window.app = Vue.createApp({ - el: '#vue', - mixins: [windowMixin], +window.PageEventsRegister = { + template: '#page-events-register', data() { return { tickets: [], ticketsTable: { columns: [ - {name: 'id', align: 'left', label: 'ID', field: 'id'}, {name: 'name', align: 'left', label: 'Name', field: 'name'}, { name: 'registered', align: 'left', label: 'Registered', field: 'registered' + }, + { + name: 'paid', + align: 'left', + label: 'Paid', + field: 'paid' } ], pagination: { @@ -49,30 +43,26 @@ window.app = Vue.createApp({ this.sendCamera.show = false const value = res[0].rawValue.split('//')[1] LNbits.api - .request('GET', `/events/api/v1/register/ticket/${value}`) + .request('PUT', `/events/api/v1/tickets/register/${value}`) .then(() => { Quasar.Notify.create({ type: 'positive', message: 'Registered!' }) - setTimeout(() => { - window.location.reload() - }, 2000) }) .catch(LNbits.utils.notifyApiError) }, getEventTickets() { LNbits.api - .request('GET', `/events/api/v1/eventtickets/${event_id}`) + .request('GET', `/events/api/v1/events/${this.eventId}/tickets`) .then(response => { - this.tickets = response.data.map(obj => { - return mapEvents(obj) - }) + this.tickets = response.data }) .catch(LNbits.utils.notifyApiError) } }, created() { + this.eventId = this.$route.params.id this.getEventTickets() } -}) +} diff --git a/static/js/register.vue b/static/js/register.vue new file mode 100644 index 0000000..9b40537 --- /dev/null +++ b/static/js/register.vue @@ -0,0 +1,64 @@ + diff --git a/static/js/ticket.js b/static/js/ticket.js new file mode 100644 index 0000000..3085f47 --- /dev/null +++ b/static/js/ticket.js @@ -0,0 +1,26 @@ +window.PageEventsTicket = { + template: '#page-events-ticket', + data() { + return { + ticketId: null, + ticketName: null + } + }, + methods: { + printWindow() { + window.print() + } + }, + async created() { + this.ticketId = this.$route.params.id + try { + const {data} = await LNbits.api.request( + 'GET', + `/events/api/v1/tickets/${this.ticketId}` + ) + this.ticketName = data.ticket_name + } catch (error) { + LNbits.utils.notifyApiError(error) + } + } +} diff --git a/static/js/ticket.vue b/static/js/ticket.vue new file mode 100644 index 0000000..7fdecce --- /dev/null +++ b/static/js/ticket.vue @@ -0,0 +1,27 @@ + diff --git a/static/routes.json b/static/routes.json new file mode 100644 index 0000000..ae46a9a --- /dev/null +++ b/static/routes.json @@ -0,0 +1,26 @@ +[ + { + "path": "/events/", + "name": "PageEvents", + "template": "/events/static/js/index.vue", + "component": "/events/static/js/index.js" + }, + { + "path": "/events/:id", + "name": "PageEventsDisplay", + "template": "/events/static/js/display.vue", + "component": "/events/static/js/display.js" + }, + { + "path": "/events/ticket/:id", + "name": "PageEventsTicket", + "template": "/events/static/js/ticket.vue", + "component": "/events/static/js/ticket.js" + }, + { + "path": "/events/register/:id", + "name": "PageEventsRegister", + "template": "/events/static/js/register.vue", + "component": "/events/static/js/register.js" + } +] diff --git a/tasks.py b/tasks.py index f7300bb..651994e 100644 --- a/tasks.py +++ b/tasks.py @@ -5,8 +5,24 @@ from loguru import logger from .crud import get_ticket +from .models import Ticket from .services import set_ticket_paid +payment_listeners: dict[str, list[asyncio.Queue[Ticket]]] = {} + + +def register_payment_listener(payment_hash, queue: asyncio.Queue[Ticket]) -> None: + if payment_hash not in payment_listeners: + payment_listeners[payment_hash] = [] + payment_listeners[payment_hash].append(queue) + + +def deregister_payment_listener(payment_hash, queue: asyncio.Queue[Ticket]) -> None: + if payment_hash in payment_listeners: + payment_listeners[payment_hash].remove(queue) + if not payment_listeners[payment_hash]: + del payment_listeners[payment_hash] + async def wait_for_paid_invoices(): invoice_queue = asyncio.Queue() @@ -21,13 +37,12 @@ async def on_invoice_paid(payment: Payment) -> None: if not payment.extra or "events" != payment.extra.get("tag"): return - if not payment.extra.get("name") or not payment.extra.get("email"): - logger.warning(f"Ticket {payment.payment_hash} missing name or email.") - return - ticket = await get_ticket(payment.payment_hash) if not ticket: logger.warning(f"Ticket for payment {payment.payment_hash} not found.") return - await set_ticket_paid(ticket) + ticket = await set_ticket_paid(ticket) + if payment_listeners.get(payment.payment_hash): + for paid_ticket_queue in payment_listeners[payment.payment_hash]: + paid_ticket_queue.put_nowait(ticket) diff --git a/templates/events/_api_docs.html b/templates/events/_api_docs.html deleted file mode 100644 index dbf0131..0000000 --- a/templates/events/_api_docs.html +++ /dev/null @@ -1,25 +0,0 @@ - - - -
- Events: Sell and register ticket waves for an event -
-

- Events allows you to make a wave of tickets for an event, each ticket is - in the form of a unique QRcode, which the user presents at registration. - Events comes with a shareable ticket scanner, which can be used to - register attendees.
- - Created by, - Ben Arc - -

-
-
- -
diff --git a/templates/events/display.html b/templates/events/display.html deleted file mode 100644 index 73d279d..0000000 --- a/templates/events/display.html +++ /dev/null @@ -1,116 +0,0 @@ -{% extends "public.html" %} {% block page %} -
-
- - - -

{{ event_name }}

-
-
-
-
-
- - -
Buy Ticket
- - - - - -
- Submit - Cancel -
-
-
-
- - -
- Link to your ticket! -

-

You'll be redirected in a few moments...

-
-
-
- - - - - -
- -
-
- Copy invoice - Close -
-
-
-
- -{% endblock %} {% block scripts %} - - -{% endblock %} diff --git a/templates/events/error.html b/templates/events/error.html deleted file mode 100644 index 3993db5..0000000 --- a/templates/events/error.html +++ /dev/null @@ -1,31 +0,0 @@ -{% extends "public.html" %} {% block page %} -
-
- - -
-

{{ event_name }} error

-
- - -
{{ event_error }}
-
-
-
-
-
-
-{% endblock %} {% block scripts %} - - - -{% endblock %} diff --git a/templates/events/index.html b/templates/events/index.html deleted file mode 100644 index 62752d1..0000000 --- a/templates/events/index.html +++ /dev/null @@ -1,464 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block page %} -
-
- - - New Event - - - - - -
-
-
Events
-
-
- Export to CSV -
-
- - - - -
-
- - - -
-
-
Tickets
-
-
- Export to CSV -
-
- - - - -
-
-
-
- - -
- {{SITE_TITLE}} Events extension -
-
- - - {% include "events/_api_docs.html" %} - -
-
- - - - -
-
- -
-
- - -
-
- - - -
-
Ticket closing date
-
- -
-
-
-
Event begins
-
- -
-
- -
-
Event ends
-
- -
-
-
-
- -
-
- -
-
- -
-
- -
-
Conditional Events
-
- Make this event conditional if - minimum tickets are sold. User will be asked to - provide a Lightning Address or LNURL pay for refunds. -
-
- -
-
- -
-
- -
Promo Codes
-
- Allow users to enter a promo code for discounts. -
- -
- - - - - - -
-
- Add Promo Code -
-
- -
- Update Event - Create Event - Cancel -
-
-
-
-
-{% endblock %} {% block scripts %} {{ window_vars(user) }} - - -{% endblock %} diff --git a/templates/events/register.html b/templates/events/register.html deleted file mode 100644 index 92589a3..0000000 --- a/templates/events/register.html +++ /dev/null @@ -1,84 +0,0 @@ -{% extends "public.html" %} {% block page %} - -
-
- - -
-

{{ event_name }} Registration

-
- -
- - Scan ticket -
-
-
- - - - - - - - - -
- - - -
- -
-
- Cancel -
-
-
-
-{% endblock %} {% block scripts %} - - -{% endblock %} diff --git a/templates/events/ticket.html b/templates/events/ticket.html deleted file mode 100644 index bcf7e82..0000000 --- a/templates/events/ticket.html +++ /dev/null @@ -1,39 +0,0 @@ -{% extends "public.html" %} {% block page %} -
-
- - -
-

{{ ticket_name }} Ticket

-
-
- Bookmark, print or screenshot this page,
- and present it for registration! -
-
- -
- - Print -
-
-
-
-
-{% endblock %} {% block scripts %} - -{% endblock %} diff --git a/views.py b/views.py index 0680dcc..4a3f142 100644 --- a/views.py +++ b/views.py @@ -1,139 +1,24 @@ -from datetime import date, datetime -from http import HTTPStatus - -from fastapi import APIRouter, Depends, Request -from lnbits.core.models import User -from lnbits.decorators import check_user_exists -from lnbits.helpers import template_renderer -from starlette.exceptions import HTTPException -from starlette.responses import HTMLResponse - -from .crud import get_event, get_ticket, purge_unpaid_tickets, update_event -from .services import refund_tickets +from fastapi import APIRouter, Depends +from lnbits.core.views.generic import index, index_public +from lnbits.decorators import check_account_id_exists events_generic_router = APIRouter() +events_generic_router.add_api_route( + "/", + methods=["GET"], + endpoint=index, + dependencies=[Depends(check_account_id_exists)], +) -def events_renderer(): - return template_renderer(["events/templates"]) - - -@events_generic_router.get("/", response_class=HTMLResponse) -async def index(request: Request, user: User = Depends(check_user_exists)): - return events_renderer().TemplateResponse( - "events/index.html", {"request": request, "user": user.json()} - ) - - -@events_generic_router.get("/{event_id}", response_class=HTMLResponse) -async def display(request: Request, event_id): - 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 = ( - date.today() < datetime.strptime(event.closing_date, "%Y-%m-%d").date() - ) - is_min_tickets_met = ( - event.sold >= event.extra.min_tickets if event.extra.conditional else True - ) - - if event.amount_tickets < 1: - return events_renderer().TemplateResponse( - "events/error.html", - { - "request": request, - "event_name": event.name, - "event_error": "Sorry, tickets are 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) - - return events_renderer().TemplateResponse( - "events/error.html", - { - "request": request, - "event_name": event.name, - "event_error": "Sorry, event was cancelled.", - }, - ) - if not is_window_open: - return events_renderer().TemplateResponse( - "events/error.html", - { - "request": request, - "event_name": event.name, - "event_error": "Sorry, ticket closing date has passed :(", - }, - ) - - if len(event.extra.promo_codes) > 0: - has_promo_codes = True - else: - has_promo_codes = False - - event.extra.promo_codes = [] - return events_renderer().TemplateResponse( - "events/display.html", - { - "request": request, - "event_id": event_id, - "event_name": event.name, - "event_info": event.info, - "event_price": event.price_per_ticket, - "event_banner": event.banner, - "event_extra": event.extra.json(), - "has_promo_codes": has_promo_codes, - }, - ) - - -@events_generic_router.get("/ticket/{ticket_id}", response_class=HTMLResponse) -async def ticket(request: Request, ticket_id): - 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 events_renderer().TemplateResponse( - "events/ticket.html", - { - "request": request, - "ticket_id": ticket_id, - "ticket_name": event.name, - "ticket_info": event.info, - }, - ) - +events_generic_router.add_api_route( + "/{event_id}", methods=["GET"], endpoint=index_public +) -@events_generic_router.get("/register/{event_id}", response_class=HTMLResponse) -async def register(request: Request, event_id): - event = await get_event(event_id) - if not event: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." - ) +events_generic_router.add_api_route( + "/ticket/{ticket_id}", methods=["GET"], endpoint=index_public +) - return events_renderer().TemplateResponse( - "events/register.html", - { - "request": request, - "event_id": event_id, - "event_name": event.name, - "wallet_id": event.wallet, - }, - ) +events_generic_router.add_api_route( + "/register/{event_id}", methods=["GET"], endpoint=index_public +) diff --git a/views_api.py b/views_api.py index 58c9bca..d9789ec 100644 --- a/views_api.py +++ b/views_api.py @@ -1,8 +1,17 @@ +import asyncio 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 typing import Any + +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 @@ fiat_amount_as_satoshis, get_fiat_rate_satoshis, ) -from starlette.exceptions import HTTPException from .crud import ( create_event, @@ -26,36 +34,79 @@ 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.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("/api/v1/events") -@events_api_router.put("/api/v1/events/{event_id}") +@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} + 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), + }, + ) + return TicketPaymentRequest( + 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 - ) +@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 + ) - # check if price is equal to payment.amount - lower_bound = price * 0.99 # 1% decrease + for task in pending: + task.cancel() - 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} + if disconnect_task in done: + try: + disconnect_task.result() + except WebSocketDisconnect: + pass + break - return {"paid": False} + 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) -@events_api_router.delete("/api/v1/tickets/{ticket_id}") +@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