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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Buy Ticket
+
+
+
+
+
+
+ Submit
+ Clear
+
+
+
+
+
+
+
+
Link to your ticket!
+
+
You'll be redirected in a few moments...
+
+
+
+
+
+
+
+
+
+
+
+
+ Copy invoice
+ Close
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ New Event
+
+
+
+
+
+
+
+
Events
+
+
+ Export to CSV
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Promo codes
+
+
+ No promo codes for this event.
+
+
+
+
+
+
+
+
+
+ Discount:
+ %
+
+
+ Status:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Tickets
+
+
+ Export to CSV
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Events extension
+
+
+
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Ticket closing date
+
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+ Registration
+
+
+
+
+ Scan ticket
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+
+
+
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 @@
+
+
+
+
+
+
+ Ticket
+
+
+ Bookmark, print or screenshot this page,
+ and present it for registration!
+
+
+
+
+
+ Print
+
+
+
+
+
+
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
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Promo codes
-
-
- No promo codes for this event.
-
-
-
-
-
-
-
-
-
- Discount: %
-
-
- Status:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Tickets
-
-
- Export to CSV
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{SITE_TITLE}} Events extension
-
-
-
-
- {% include "events/_api_docs.html" %}
-
-
-
-
-
-
-
-
-
-
-
-
-
Ticket closing date
-
-
-
-
-
-
-
-
-
-
-
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