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

@ -6,11 +6,12 @@ from loguru import logger
from .crud import db from .crud import db
from .tasks import wait_for_paid_invoices from .tasks import wait_for_paid_invoices
from .views import events_generic_router 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: APIRouter = APIRouter(prefix="/events", tags=["Events"])
events_ext.include_router(events_generic_router) events_ext.include_router(events_generic_router)
events_ext.include_router(events_api_router) events_ext.include_router(events_api_router)
events_ext.include_router(tickets_api_router)
events_static_files = [ events_static_files = [
{ {

View file

@ -1,12 +1,12 @@
{ {
"id": "events", "id": "events",
"version": "1.2.1", "version": "1.3.0",
"name": "Events", "name": "Events",
"repo": "https://github.com/lnbits/events", "repo": "https://github.com/lnbits/events",
"short_description": "Sell and register event tickets", "short_description": "Sell and register event tickets",
"description": "", "description": "",
"tile": "/events/static/image/events.png", "tile": "/events/static/image/events.png",
"min_lnbits_version": "1.3.0", "min_lnbits_version": "1.4.1",
"contributors": [ "contributors": [
{ {
"name": "talvasconcelos", "name": "talvasconcelos",
@ -14,7 +14,7 @@
"role": "Developer" "role": "Developer"
}, },
{ {
"name": "DNI", "name": "dni",
"uri": "https://github.com/dni", "uri": "https://github.com/dni",
"role": "Developer" "role": "Developer"
}, },

View file

@ -58,6 +58,17 @@ class Event(BaseModel):
extra: EventExtra = Field(default_factory=EventExtra) 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): class TicketExtra(BaseModel):
applied_promo_code: str | None = None applied_promo_code: str | None = None
sats_paid: int | None = None sats_paid: int | None = None
@ -83,3 +94,17 @@ class Ticket(BaseModel):
time: datetime time: datetime
reg_timestamp: datetime reg_timestamp: datetime
extra: TicketExtra = Field(default_factory=TicketExtra) 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

View file

@ -1,8 +1,9 @@
window.app = Vue.createApp({ window.PageEventsDisplay = {
el: '#vue', template: '#page-events-display',
mixins: [windowMixin],
data() { data() {
return { return {
eventErrorLabel: '',
event: null,
paymentReq: null, paymentReq: null,
redirectUrl: null, redirectUrl: null,
formDialog: { formDialog: {
@ -23,15 +24,14 @@ window.app = Vue.createApp({
show: false, show: false,
status: 'pending', status: 'pending',
paymentReq: null paymentReq: null
} },
paymentDismissMsg: null,
paymentWebsocket: null
} }
}, },
async created() { async created() {
this.info = event_info this.eventId = this.$route.params.id
this.info = this.info.substring(1, this.info.length - 1) this.event = await this.getEvent()
this.banner = event_banner
this.extra = event_extra
this.hasPromoCodes = has_promoCodes
}, },
computed: { computed: {
formatDescription() { formatDescription() {
@ -39,6 +39,18 @@ window.app = Vue.createApp({
} }
}, },
methods: { 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) { resetForm(e) {
e.preventDefault() e.preventDefault()
this.formDialog.data.name = '' this.formDialog.data.name = ''
@ -47,10 +59,14 @@ window.app = Vue.createApp({
}, },
closeReceiveDialog() { closeReceiveDialog() {
const checker = this.receive.paymentChecker if (this.paymentDismissMsg) {
dismissMsg() this.paymentDismissMsg()
clearInterval(paymentChecker) this.paymentDismissMsg = null
setTimeout(() => {}, 10000) }
if (this.paymentWebsocket) {
this.paymentWebsocket.close()
this.paymentWebsocket = null
}
}, },
nameValidation(val) { nameValidation(val) {
const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g
@ -63,42 +79,14 @@ window.app = Vue.createApp({
const regex = /^[\w\.-]+@[a-zA-Z\d\.-]+\.[a-zA-Z]{2,}$/ const regex = /^[\w\.-]+@[a-zA-Z\d\.-]+\.[a-zA-Z]{2,}$/
return regex.test(val) || 'Please enter valid email.' return regex.test(val) || 'Please enter valid email.'
}, },
Invoice() { paymentSuccess(paymentHash) {
axios if (this.paymentDismissMsg) {
.post(`/events/api/v1/tickets/${event_id}`, { this.paymentDismissMsg()
name: this.formDialog.data.name, this.paymentDismissMsg = null
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
dismissMsg = Quasar.Notify.create({
timeout: 0,
message: 'Waiting for payment...'
})
this.receive = {
show: true,
status: 'pending',
paymentReq: this.paymentReq
} }
paymentChecker = setInterval(() => { this.paymentReq = null
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.name = ''
this.formDialog.data.email = '' this.formDialog.data.email = ''
Quasar.Notify.create({ Quasar.Notify.create({
type: 'positive', type: 'positive',
message: 'Sent, thank you!', message: 'Sent, thank you!',
@ -109,22 +97,75 @@ window.app = Vue.createApp({
status: 'complete', status: 'complete',
paymentReq: null paymentReq: null
} }
this.ticketLink = { this.ticketLink = {
show: true, show: true,
data: { data: {
link: `/events/ticket/${res.data.ticket_id}` link: `/events/ticket/${paymentHash}`
} }
} }
setTimeout(() => { setTimeout(() => {
window.location.href = `/events/ticket/${res.data.ticket_id}` window.location.href = `/events/ticket/${paymentHash}`
}, 5000) }, 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
this.paymentDismissMsg = Quasar.Notify.create({
timeout: 0,
message: 'Waiting for payment...'
}) })
.catch(LNbits.utils.notifyApiError) this.receive = {
}, 2000) show: true,
}) status: 'pending',
.catch(LNbits.utils.notifyApiError) paymentReq: this.paymentReq
}
this.websocketListener(this.paymentHash)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
websocketListener(paymentHash) {
if (this.paymentWebsocket) {
this.paymentWebsocket.close()
}
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 = ''
const ws = new WebSocket(url)
this.paymentWebsocket = ws
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
}
}
}
} }
} }
})

125
static/js/display.vue Normal file
View file

@ -0,0 +1,125 @@
<template id="page-events-display">
<div v-if="event" class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card>
<q-img
v-if="event.banner"
:src="event.banner"
transition="slide-up"
></q-img>
<q-card-section class="q-pa-none">
<h3 class="q-my-none q-pa-lg" v-text="event.name"></h3>
<div v-html="event.info" class="q-pa-lg"></div>
</q-card-section>
</q-card>
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<h5 class="q-mt-none">Buy Ticket</h5>
<q-form @submit="createInvoice()" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialog.data.name"
label="Your name "
:rules="[val => nameValidation(val)]"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.email"
type="email"
label="Your email "
:rules="[
val => !!val || '* Required',
val => emailValidation(val)
]"
lazy-rules
></q-input>
<q-input
v-if="this.extra?.conditional"
filled
dense
v-model.trim="formDialog.data.refund"
label="Refund lnadress or LNURL "
:rules="[val => !!val || '* Required']"
lazy-rules
:hint="`If minimum tickets (${this.extra?.min_tickets}) are not met, refund will be sent.`"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.promo_code"
label="(optional) Promo Code "
></q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="
formDialog.data.name == '' ||
formDialog.data.email == '' ||
Boolean(paymentReq)
"
type="submit"
>Submit</q-btn
>
<q-btn @click="resetForm" flat color="grey" class="q-ml-auto"
>Clear</q-btn
>
</div>
</q-form>
</q-card-section>
</q-card>
<q-card v-show="ticketLink.show" class="q-pa-lg">
<div class="text-center q-mb-lg">
<q-btn
unelevated
size="xl"
:href="ticketLink.data.link"
target="_blank"
color="primary"
type="a"
>Link to your ticket!</q-btn
>
<br /><br />
<p>You'll be redirected in a few moments...</p>
</div>
</q-card>
</div>
<q-dialog v-model="receive.show" position="top" @hide="closeReceiveDialog">
<q-card
v-if="!receive.paymentReq"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
</q-card>
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<lnbits-qrcode
:href="'lightning:' + receive.paymentReq"
:value="'LIGHTNING:' + receive.paymentReq.toUpperCase()"
></lnbits-qrcode>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="utils.copyText(receive.paymentReq)"
>Copy invoice</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div>
<div v-else class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<h3 class="q-my-none q-pa-lg" v-text="eventErrorLabel"></h3>
</q-card-section>
</q-card>
</div>
</div>
</template>

View file

@ -1,13 +1,5 @@
const mapEvents = function (obj) { window.PageEvents = {
obj.date = LNbits.utils.formatTimestamp(obj.time) template: '#page-events',
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],
data() { data() {
return { return {
events: [], events: [],
@ -105,6 +97,7 @@ window.app = Vue.createApp({
formDialog: { formDialog: {
show: false, show: false,
data: { data: {
currency: 'sats',
extra: { extra: {
promo_codes: [] promo_codes: []
} }
@ -118,18 +111,15 @@ window.app = Vue.createApp({
.request( .request(
'GET', 'GET',
'/events/api/v1/tickets?all_wallets=true', '/events/api/v1/tickets?all_wallets=true',
this.g.user.wallets[0].inkey this.g.user.wallets[0].adminkey
) )
.then(response => { .then(response => {
this.tickets = response.data this.tickets = response.data.filter(e => e.paid)
.map(function (obj) {
return mapEvents(obj)
})
.filter(e => e.paid)
}) })
}, },
deleteTicket(ticketId) { deleteTicket(ticketId) {
const tickets = _.findWhere(this.tickets, {id: ticketId}) const tickets = _.findWhere(this.tickets, {id: ticketId})
const wallet = _.findWhere(this.g.user.wallets, {id: tickets.wallet})
LNbits.utils LNbits.utils
.confirmDialog('Are you sure you want to delete this ticket') .confirmDialog('Are you sure you want to delete this ticket')
@ -138,16 +128,14 @@ window.app = Vue.createApp({
.request( .request(
'DELETE', 'DELETE',
'/events/api/v1/tickets/' + ticketId, '/events/api/v1/tickets/' + ticketId,
_.findWhere(this.g.user.wallets, {id: tickets.wallet}).inkey wallet.adminkey
) )
.then(response => { .then(response => {
this.tickets = _.reject(this.tickets, function (obj) { this.tickets = _.reject(this.tickets, function (obj) {
return obj.id == ticketId return obj.id == ticketId
}) })
}) })
.catch(function (error) { .catch(LNbits.utils.notifyApiError)
LNbits.utils.notifyApiError(error)
})
}) })
}, },
exportticketsCSV() { exportticketsCSV() {
@ -161,9 +149,7 @@ window.app = Vue.createApp({
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
.then(response => { .then(response => {
this.events = response.data.map(obj => { this.events = response.data
return mapEvents(obj)
})
this.checkCanceledEvents() this.checkCanceledEvents()
}) })
}, },
@ -190,6 +176,7 @@ window.app = Vue.createApp({
this.formDialog.data = {...data} this.formDialog.data = {...data}
} else { } else {
this.formDialog.data = { this.formDialog.data = {
currency: 'sats',
extra: { extra: {
conditional: false, conditional: false,
min_tickets: 1, min_tickets: 1,
@ -212,7 +199,7 @@ window.app = Vue.createApp({
LNbits.api LNbits.api
.request('POST', '/events/api/v1/events', wallet.adminkey, data) .request('POST', '/events/api/v1/events', wallet.adminkey, data)
.then(response => { .then(response => {
this.events.push(mapEvents(response.data)) this.events.push(response.data)
this.resetEventDialog() this.resetEventDialog()
}) })
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
@ -233,7 +220,7 @@ window.app = Vue.createApp({
this.events = _.reject(this.events, function (obj) { this.events = _.reject(this.events, function (obj) {
return obj.id == data.id return obj.id == data.id
}) })
this.events.push(mapEvents(response.data)) this.events.push(response.data)
this.resetEventDialog() this.resetEventDialog()
}) })
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
@ -255,7 +242,7 @@ window.app = Vue.createApp({
return obj.id == eventsId return obj.id == eventsId
}) })
}) })
.catch(LNbits.utils.notifyApiError(error)) .catch(LNbits.utils.notifyApiError)
}) })
}, },
exporteventsCSV() { exporteventsCSV() {
@ -279,9 +266,7 @@ window.app = Vue.createApp({
message: `Event ${ev.name} has been canceled and refunds have been issued.`, message: `Event ${ev.name} has been canceled and refunds have been issued.`,
icon: null icon: null
}) })
this.events = this.events.map(e => this.events = this.events.map(e => (e.id === ev.id ? data : e))
e.id === ev.id ? mapEvents(data) : e
)
} }
}) })
} }
@ -290,7 +275,11 @@ window.app = Vue.createApp({
if (this.g.user.wallets.length) { if (this.g.user.wallets.length) {
this.getTickets() this.getTickets()
this.getEvents() 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]
}
}
} }
} }
})

511
static/js/index.vue Normal file
View file

@ -0,0 +1,511 @@
<template id="page-events">
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="openEventDialog"
>New Event</q-btn
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Events</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exporteventsCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:rows="events"
row-key="id"
:columns="eventsTable.columns"
v-model:pagination="eventsTable.pagination"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th auto-width></q-th>
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
size="sm"
color="accent"
round
dense
@click="props.expand = !props.expand"
:icon="props.expand ? 'expand_less' : 'expand_more'"
/>
</q-td>
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="link"
:color="$q.dark.isActive ? 'grey-7' : 'grey-5'"
type="a"
:href="'/events/' + props.row.id"
target="_blank"
></q-btn>
<q-btn
unelevated
dense
size="xs"
icon="how_to_reg"
:color="$q.dark.isActive ? 'grey-7' : 'grey-5'"
type="a"
:href="'/events/register/' + props.row.id"
target="_blank"
class="q-ml-xs"
></q-btn>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="updateformDialog(props.row.id)"
icon="edit"
color="light-blue"
></q-btn>
<q-btn
flat
dense
size="xs"
@click="deleteEvent(props.row.id)"
icon="cancel"
color="pink"
class="q-ml-xs"
></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.value"></span>
</q-td>
</q-tr>
<q-tr v-show="props.expand" :props="props">
<q-td colspan="100%">
<div class="q-pa-md">
<div class="text-subtitle1 q-mb-md">Promo codes</div>
<div class="column">
<div
v-if="props.row.extra.promo_codes.length == 0"
class="text-caption"
>
No promo codes for this event.
</div>
<div
v-for="(code, index) in props.row.extra.promo_codes"
:key="index"
class="row items-center q-col-gutter-sm q-mb-sm"
>
<div class="col-auto">
<q-chip
square
size="md"
clickable
@click="utils.copyText(code.code.toUpperCase())"
>
<q-avatar
icon="bookmark"
:color="code.active ? 'green' : 'grey'"
text-color="white"
></q-avatar>
<span v-text="code.code.toUpperCase()"></span>
</q-chip>
</div>
<div class="col-auto">
Discount:
<span v-text="code.discount_percent"></span>%
</div>
<div class="col-auto">
Status:
<span
:class="code.active ? 'text-green' : 'text-grey'"
v-text="code.active ? 'Active' : 'Inactive'"
></span>
</div>
</div>
</div>
</div>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Tickets</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportticketsCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:rows="tickets"
row-key="id"
:columns="ticketsTable.columns"
v-model:pagination="ticketsTable.pagination"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="local_activity"
:color="$q.dark.isActive ? 'grey-7' : 'grey-5'"
type="a"
:href="'/events/ticket/' + props.row.id"
target="_blank"
></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.value"></span>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteTicket(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 ellipsis q-my-none">
<span v-text="SITE_TITLE"></span>
Events extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="Info"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<h5 class="text-subtitle1 q-my-none">
Events: Sell and register ticket waves for an event
</h5>
<p>
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.<br />
<small>
Created by,
<a class="text-secondary" href="https://github.com/benarc"
>Ben Arc</a
>
</small>
</p>
</q-card-section>
</q-card>
<q-btn
flat
label="Swagger API"
type="a"
href="../docs#/events"
></q-btn>
</q-expansion-item>
</q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendEventData" class="q-gutter-md">
<div class="row">
<div class="col">
<q-input
filled
dense
v-model.trim="formDialog.data.name"
type="name"
label="Title of event "
></q-input>
</div>
<div class="col q-pl-sm">
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select>
</div>
</div>
<q-input
filled
dense
v-model.trim="formDialog.data.info"
type="textarea"
label="Info about the event"
hint="Markdown supported"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.banner"
type="url"
label="Image URL"
hint="Optional banner image to display on the event page"
></q-input>
<div class="row q-mt-lg">
<div class="col-4">Ticket closing date</div>
<div class="col-8">
<q-input
filled
dense
v-model.trim="formDialog.data.closing_date"
type="date"
></q-input>
</div>
</div>
<div class="row">
<div class="col-4">Event begins</div>
<div class="col-8">
<q-input
filled
dense
v-model.trim="formDialog.data.event_start_date"
type="date"
></q-input>
</div>
</div>
<div class="row">
<div class="col-4">Event ends</div>
<div class="col-8">
<q-input
filled
dense
v-model.trim="formDialog.data.event_end_date"
type="date"
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="col">
<q-select
filled
dense
v-model="formDialog.data.currency"
type="text"
label="Unit"
:options="currencies"
></q-select>
</div>
<div class="col">
<q-input
filled
dense
v-model.number="formDialog.data.amount_tickets"
type="number"
label="Amount of tickets "
></q-input>
</div>
<div class="col">
<q-input
filled
dense
v-model.number="formDialog.data.price_per_ticket"
type="number"
:label="'Price (' + formDialog.data.currency + ') *'"
:step="formDialog.data.currency != 'sats' ? '0.01' : '1'"
:mask="formDialog.data.currency != 'sats' ? '#.##' : '#'"
fill-mask="0"
reverse-fill-mask
:disable="formDialog.data.currency == null"
></q-input>
</div>
</div>
<q-expansion-item
group="advanced"
icon="settings"
label="Advanced options"
>
<div class="row q-mt-lg">
<div class="text-subtitle1 q-mb-md">Conditional Events</div>
<div class="text-caption">
Make this event conditional if
<strong>minimum tickets</strong> are sold. User will be asked to
provide a Lightning Address or LNURL pay for refunds.
</div>
<div class="col-8">
<q-toggle
v-model="formDialog.data.extra.conditional"
label="Conditional Event"
left-label
></q-toggle>
</div>
<div class="col-4">
<q-input
filled
dense
v-model.number="formDialog.data.extra.min_tickets"
type="number"
label="Minimum Tickets"
:disable="!formDialog.data.extra.conditional"
></q-input>
</div>
</div>
<q-separator class="q-my-md"></q-separator>
<div class="text-subtitle1 q-mb-md">Promo Codes</div>
<div class="text-caption">
Allow users to enter a promo code for discounts.
</div>
<div
v-for="(code, index) in formDialog.data.extra.promo_codes"
:key="index"
class="row q-col-gutter-sm q-mt-md"
>
<q-input
class="col-8"
filled
dense
v-model.trim="formDialog.data.extra.promo_codes[index].code"
type="text"
label="Promo Code"
>
<template v-slot:before>
<q-checkbox
left-label
v-model="formDialog.data.extra.promo_codes[index].active"
checked-icon="radio_button_checked"
unchecked-icon="radio_button_unchecked"
></q-checkbox>
<q-tooltip>
<span
v-text="
formDialog.data.extra.promo_codes[index].active
? 'Active'
: 'Inactive'
"
></span>
</q-tooltip>
</template>
</q-input>
<q-input
class="col-4"
filled
dense
v-model.number="
formDialog.data.extra.promo_codes[index].discount_percent
"
type="number"
label="Discount (%)"
min="0"
max="100"
>
<template v-slot:after>
<q-btn
round
dense
flat
icon="delete"
@click="formDialog.data.extra.promo_codes.splice(index, 1)"
></q-btn>
</template>
</q-input>
</div>
<div class="col-12 q-mt-md">
<q-btn
@click="
formDialog.data.extra.promo_codes.push({
code: '',
discount_percent: 0,
active: true
})
"
>Add Promo Code</q-btn
>
</div>
</q-expansion-item>
<div class="row q-mt-lg">
<q-btn
v-if="formDialog.data.id"
unelevated
color="primary"
type="submit"
>Update Event</q-btn
>
<q-btn
v-else
unelevated
color="primary"
:disable="
formDialog.data.wallet == null ||
formDialog.data.name == null ||
formDialog.data.info == null ||
formDialog.data.closing_date == null ||
formDialog.data.event_start_date == null ||
formDialog.data.event_end_date == null ||
formDialog.data.amount_tickets == null ||
formDialog.data.price_per_ticket == null
"
type="submit"
>Create Event</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
</template>

View file

@ -1,28 +1,22 @@
const mapEvents = function (obj) { window.PageEventsRegister = {
obj.date = Quasar.date.formatDate( template: '#page-events-register',
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],
data() { data() {
return { return {
tickets: [], tickets: [],
ticketsTable: { ticketsTable: {
columns: [ columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'name', align: 'left', label: 'Name', field: 'name'}, {name: 'name', align: 'left', label: 'Name', field: 'name'},
{ {
name: 'registered', name: 'registered',
align: 'left', align: 'left',
label: 'Registered', label: 'Registered',
field: 'registered' field: 'registered'
},
{
name: 'paid',
align: 'left',
label: 'Paid',
field: 'paid'
} }
], ],
pagination: { pagination: {
@ -49,30 +43,26 @@ window.app = Vue.createApp({
this.sendCamera.show = false this.sendCamera.show = false
const value = res[0].rawValue.split('//')[1] const value = res[0].rawValue.split('//')[1]
LNbits.api LNbits.api
.request('GET', `/events/api/v1/register/ticket/${value}`) .request('PUT', `/events/api/v1/tickets/register/${value}`)
.then(() => { .then(() => {
Quasar.Notify.create({ Quasar.Notify.create({
type: 'positive', type: 'positive',
message: 'Registered!' message: 'Registered!'
}) })
setTimeout(() => {
window.location.reload()
}, 2000)
}) })
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
}, },
getEventTickets() { getEventTickets() {
LNbits.api LNbits.api
.request('GET', `/events/api/v1/eventtickets/${event_id}`) .request('GET', `/events/api/v1/events/${this.eventId}/tickets`)
.then(response => { .then(response => {
this.tickets = response.data.map(obj => { this.tickets = response.data
return mapEvents(obj)
})
}) })
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
} }
}, },
created() { created() {
this.eventId = this.$route.params.id
this.getEventTickets() this.getEventTickets()
} }
}) }

64
static/js/register.vue Normal file
View file

@ -0,0 +1,64 @@
<template id="page-events-register">
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<center>
<h3 class="q-my-none">Registration</h3>
<br />
<br />
<q-btn unelevated color="primary" @click="showCamera" size="xl"
>Scan ticket</q-btn
>
</center>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<q-table
dense
flat
:rows="tickets"
row-key="id"
:columns="ticketsTable.columns"
v-model:pagination="ticketsTable.pagination"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.value"></span>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="sendCamera.show" position="top">
<q-card class="q-pa-lg q-pt-xl">
<div class="text-center q-mb-lg">
<qrcode-stream
@detect="decodeQR"
class="rounded-borders"
></qrcode-stream>
</div>
<div class="row q-mt-lg">
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-card>
</q-dialog>
</div>
</template>

26
static/js/ticket.js Normal file
View file

@ -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)
}
}
}

27
static/js/ticket.vue Normal file
View file

@ -0,0 +1,27 @@
<template id="page-events-ticket">
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<center>
<h3 class="q-my-none">Ticket</h3>
<br />
<h5 class="q-my-none">
Bookmark, print or screenshot this page,<br />
and present it for registration!
</h5>
<br />
<lnbits-qrcode
:value="`ticket://${ticketId}`"
:options="{width: 500}"
></lnbits-qrcode>
<br />
<q-btn @click="printWindow" color="grey" class="q-ml-auto">
<q-icon left size="3em" name="print"></q-icon> Print</q-btn
>
</center>
</q-card-section>
</q-card>
</div>
</div>
</template>

26
static/routes.json Normal file
View file

@ -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"
}
]

View file

@ -5,8 +5,24 @@ from lnbits.tasks import register_invoice_listener
from loguru import logger from loguru import logger
from .crud import get_ticket from .crud import get_ticket
from .models import Ticket
from .services import set_ticket_paid 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(): async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue() 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"): if not payment.extra or "events" != payment.extra.get("tag"):
return 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) ticket = await get_ticket(payment.payment_hash)
if not ticket: if not ticket:
logger.warning(f"Ticket for payment {payment.payment_hash} not found.") logger.warning(f"Ticket for payment {payment.payment_hash} not found.")
return 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)

View file

@ -1,25 +0,0 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="Info"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<h5 class="text-subtitle1 q-my-none">
Events: Sell and register ticket waves for an event
</h5>
<p>
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.<br />
<small>
Created by,
<a class="text-secondary" href="https://github.com/benarc">Ben Arc</a>
</small>
</p>
</q-card-section>
</q-card>
<q-btn flat label="Swagger API" type="a" href="../docs#/events"></q-btn>
</q-expansion-item>

View file

@ -1,116 +0,0 @@
{% extends "public.html" %} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card>
<q-img v-if="banner" :src="banner" transition="slide-up"></q-img>
<q-card-section class="q-pa-none">
<h3 class="q-my-none q-pa-lg">{{ event_name }}</h3>
<br />
<div v-html="formatDescription" class="q-pa-md"></div>
<br />
</q-card-section>
</q-card>
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<h5 class="q-mt-none">Buy Ticket</h5>
<q-form @submit="Invoice()" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialog.data.name"
label="Your name "
:rules="[val => nameValidation(val)]"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.email"
type="email"
label="Your email "
:rules="[val => !!val || '* Required', val => emailValidation(val)]"
lazy-rules
></q-input>
<q-input
v-if="this.extra?.conditional"
filled
dense
v-model.trim="formDialog.data.refund"
label="Refund lnadress or LNURL "
:rules="[val => !!val || '* Required']"
lazy-rules
:hint="`If minimum tickets (${this.extra?.min_tickets}) are not met, refund will be sent.`"
></q-input>
<q-input
v-if="hasPromoCodes"
filled
dense
v-model.trim="formDialog.data.promo_code"
label="Apply Promo Code "
></q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="formDialog.data.name == '' || formDialog.data.email == '' || Boolean(paymentReq)"
type="submit"
>Submit</q-btn
>
<q-btn @click="resetForm" flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card-section>
</q-card>
<q-card v-show="ticketLink.show" class="q-pa-lg">
<div class="text-center q-mb-lg">
<q-btn
unelevated
size="xl"
:href="ticketLink.data.link"
target="_blank"
color="primary"
type="a"
>Link to your ticket!</q-btn
>
<br /><br />
<p>You'll be redirected in a few moments...</p>
</div>
</q-card>
</div>
<q-dialog v-model="receive.show" position="top" @hide="closeReceiveDialog">
<q-card
v-if="!receive.paymentReq"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
</q-card>
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<lnbits-qrcode
:href="'lightning:' + receive.paymentReq"
:value="'lightning:' + receive.paymentReq.toUpperCase()"
></lnbits-qrcode>
</div>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="copyText(receive.paymentReq)"
>Copy invoice</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %}
<script>
const event_id = '{{ event_id }}'
const event_name = '{{ event_name }}'
const event_info = '{{ event_info | tojson }}'
const event_banner = JSON.parse('{{ event_banner | tojson | safe }}')
const event_extra = JSON.parse('{{ event_extra | safe }}')
const has_promoCodes = {{ has_promo_codes | tojson }}
</script>
<script src="{{ static_url_for('events/static', path='js/display.js') }}"></script>
{% endblock %}

View file

@ -1,31 +0,0 @@
{% extends "public.html" %} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<center>
<h3 class="q-my-none">{{ event_name }} error</h3>
<br />
<q-icon
name="warning"
class="text-grey"
style="font-size: 20rem"
></q-icon>
<h5 class="q-my-none">{{ event_error }}</h5>
<br />
</center>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %}
<script>
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin]
})
</script>
{% endblock %}

View file

@ -1,464 +0,0 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="openEventDialog"
>New Event</q-btn
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Events</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exporteventsCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:rows="events"
row-key="id"
:columns="eventsTable.columns"
v-model:pagination="eventsTable.pagination"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.label"></span>
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
size="sm"
color="accent"
round
dense
@click="props.expand = !props.expand"
:icon="props.expand ? 'expand_less' : 'expand_more'"
/>
</q-td>
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="link"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="props.row.displayUrl"
target="_blank"
></q-btn>
<q-btn
unelevated
dense
size="xs"
icon="how_to_reg"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'/events/register/' + props.row.id"
target="_blank"
></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.value"></span>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="updateformDialog(props.row.id)"
icon="edit"
color="light-blue"
></q-btn>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteEvent(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
<q-tr v-show="props.expand" :props="props">
<q-td colspan="100%">
<div class="q-pa-md">
<div class="text-subtitle1 q-mb-md">Promo codes</div>
<div class="column">
<div
v-if="props.row.extra.promo_codes.length == 0"
class="text-caption"
>
No promo codes for this event.
</div>
<div
v-for="(code, index) in props.row.extra.promo_codes"
:key="index"
class="row items-center q-col-gutter-sm q-mb-sm"
>
<div class="col-auto">
<q-chip
square
size="md"
clickable
@click="utils.copyText(code.code.toUpperCase())"
>
<q-avatar
icon="bookmark"
:color="code.active ? 'green' : 'grey'"
text-color="white"
></q-avatar>
<span v-text="code.code.toUpperCase()"></span>
</q-chip>
</div>
<div class="col-auto">
Discount: <span v-text="code.discount_percent"></span>%
</div>
<div class="col-auto">
Status:
<span
:class="code.active ? 'text-green' : 'text-grey'"
v-text="code.active ? 'Active' : 'Inactive'"
></span>
</div>
</div>
</div>
</div>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Tickets</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportticketsCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:rows="tickets"
row-key="id"
:columns="ticketsTable.columns"
v-model:pagination="ticketsTable.pagination"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="local_activity"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'/events/ticket/' + props.row.id"
target="_blank"
></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.value"></span>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteTicket(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} Events extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "events/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendEventData" class="q-gutter-md">
<div class="row">
<div class="col">
<q-input
filled
dense
v-model.trim="formDialog.data.name"
type="name"
label="Title of event "
></q-input>
</div>
<div class="col q-pl-sm">
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select>
</div>
</div>
<q-input
filled
dense
v-model.trim="formDialog.data.info"
type="textarea"
label="Info about the event"
hint="Markdown supported"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.banner"
type="url"
label="Image URL"
hint="Optional banner image to display on the event page"
></q-input>
<div class="row q-mt-lg">
<div class="col-4">Ticket closing date</div>
<div class="col-8">
<q-input
filled
dense
v-model.trim="formDialog.data.closing_date"
type="date"
></q-input>
</div>
</div>
<div class="row">
<div class="col-4">Event begins</div>
<div class="col-8">
<q-input
filled
dense
v-model.trim="formDialog.data.event_start_date"
type="date"
></q-input>
</div>
</div>
<div class="row">
<div class="col-4">Event ends</div>
<div class="col-8">
<q-input
filled
dense
v-model.trim="formDialog.data.event_end_date"
type="date"
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="col">
<q-select
filled
dense
v-model="formDialog.data.currency"
type="text"
label="Unit"
:options="currencies"
></q-select>
</div>
<div class="col">
<q-input
filled
dense
v-model.number="formDialog.data.amount_tickets"
type="number"
label="Amount of tickets "
></q-input>
</div>
<div class="col">
<q-input
filled
dense
v-model.number="formDialog.data.price_per_ticket"
type="number"
:label="'Price (' + formDialog.data.currency + ') *'"
:step="formDialog.data.currency != 'sats' ? '0.01' : '1'"
:mask="formDialog.data.currency != 'sats' ? '#.##' : '#'"
fill-mask="0"
reverse-fill-mask
:disable="formDialog.data.currency == null"
></q-input>
</div>
</div>
<q-expansion-item
group="advanced"
icon="settings"
label="Advanced options"
>
<div class="row q-mt-lg">
<div class="text-subtitle1 q-mb-md">Conditional Events</div>
<div class="text-caption">
Make this event conditional if
<strong>minimum tickets</strong> are sold. User will be asked to
provide a Lightning Address or LNURL pay for refunds.
</div>
<div class="col-8">
<q-toggle
v-model="formDialog.data.extra.conditional"
label="Conditional Event"
left-label
></q-toggle>
</div>
<div class="col-4">
<q-input
filled
dense
v-model.number="formDialog.data.extra.min_tickets"
type="number"
label="Minimum Tickets"
:disable="!formDialog.data.extra.conditional"
></q-input>
</div>
</div>
<q-separator class="q-my-md"></q-separator>
<div class="text-subtitle1 q-mb-md">Promo Codes</div>
<div class="text-caption">
Allow users to enter a promo code for discounts.
</div>
<div
v-for="(code, index) in formDialog.data.extra.promo_codes"
:key="index"
class="row q-col-gutter-sm q-mt-md"
>
<q-input
class="col-8"
filled
dense
v-model.trim="formDialog.data.extra.promo_codes[index].code"
type="text"
label="Promo Code"
>
<template v-slot:before>
<q-checkbox
left-label
v-model="formDialog.data.extra.promo_codes[index].active"
checked-icon="radio_button_checked"
unchecked-icon="radio_button_unchecked"
></q-checkbox>
<q-tooltip>
<span
v-text="formDialog.data.extra.promo_codes[index].active ? 'Active' : 'Inactive'"
></span>
</q-tooltip>
</template>
</q-input>
<q-input
class="col-4"
filled
dense
v-model.number="formDialog.data.extra.promo_codes[index].discount_percent"
type="number"
label="Discount (%)"
min="0"
max="100"
>
<template v-slot:after>
<q-btn
round
dense
flat
icon="delete"
@click="formDialog.data.extra.promo_codes.splice(index, 1)"
></q-btn>
</template>
</q-input>
</div>
<div class="col-12 q-mt-md">
<q-btn
@click="formDialog.data.extra.promo_codes.push({code: '', discount_percent: 0, active: true})"
>Add Promo Code</q-btn
>
</div>
</q-expansion-item>
<div class="row q-mt-lg">
<q-btn
v-if="formDialog.data.id"
unelevated
color="primary"
type="submit"
>Update Event</q-btn
>
<q-btn
v-else
unelevated
color="primary"
:disable="formDialog.data.wallet == null || formDialog.data.name == null || formDialog.data.info == null || formDialog.data.closing_date == null || formDialog.data.event_start_date == null || formDialog.data.event_end_date == null || formDialog.data.amount_tickets == null || formDialog.data.price_per_ticket == null"
type="submit"
>Create Event</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<style>
.q-field__native span {
overflow-x: hidden;
}
</style>
<script src="{{ static_url_for('events/static', path='js/index.js') }}"></script>
{% endblock %}

View file

@ -1,84 +0,0 @@
{% extends "public.html" %} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<center>
<h3 class="q-my-none">{{ event_name }} Registration</h3>
<br />
<br />
<q-btn unelevated color="primary" @click="showCamera" size="xl"
>Scan ticket</q-btn
>
</center>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<q-table
dense
flat
:rows="tickets"
row-key="id"
:columns="ticketsTable.columns"
v-model:pagination="ticketsTable.pagination"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="local_activity"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'/events/ticket/' + props.row.id"
target="_blank"
></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.value"></span>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="sendCamera.show" position="top">
<q-card class="q-pa-lg q-pt-xl">
<div class="text-center q-mb-lg">
<qrcode-stream
@detect="decodeQR"
class="rounded-borders"
></qrcode-stream>
</div>
<div class="row q-mt-lg">
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %}
<script>
const event_id = '{{ event_id }}'
</script>
<script src="{{ static_url_for('events/static', path='js/register.js') }}"></script>
{% endblock %}

View file

@ -1,39 +0,0 @@
{% extends "public.html" %} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<center>
<h3 class="q-my-none">{{ ticket_name }} Ticket</h3>
<br />
<h5 class="q-my-none">
Bookmark, print or screenshot this page,<br />
and present it for registration!
</h5>
<br />
<lnbits-qrcode
:value="'ticket://{{ ticket_id }}'"
:options="{width: 500}"
></lnbits-qrcode>
<br />
<q-btn @click="printWindow" color="grey" class="q-ml-auto">
<q-icon left size="3em" name="print"></q-icon> Print</q-btn
>
</center>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %}
<script>
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
methods: {
printWindow() {
window.print()
}
}
})
</script>
{% endblock %}

143
views.py
View file

@ -1,139 +1,24 @@
from datetime import date, datetime from fastapi import APIRouter, Depends
from http import HTTPStatus from lnbits.core.views.generic import index, index_public
from lnbits.decorators import check_account_id_exists
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
events_generic_router = APIRouter() events_generic_router = APIRouter()
events_generic_router.add_api_route(
def events_renderer(): "/",
return template_renderer(["events/templates"]) methods=["GET"],
endpoint=index,
dependencies=[Depends(check_account_id_exists)],
@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.add_api_route(
@events_generic_router.get("/{event_id}", response_class=HTMLResponse) "/{event_id}", methods=["GET"], endpoint=index_public
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) events_generic_router.add_api_route(
"/ticket/{ticket_id}", methods=["GET"], endpoint=index_public
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: events_generic_router.add_api_route(
return events_renderer().TemplateResponse( "/register/{event_id}", methods=["GET"], endpoint=index_public
"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.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."
)
return events_renderer().TemplateResponse(
"events/register.html",
{
"request": request,
"event_id": event_id,
"event_name": event.name,
"wallet_id": event.wallet,
},
) )

View file

@ -1,8 +1,17 @@
import asyncio
from datetime import datetime, timezone from datetime import datetime, timezone
from http import HTTPStatus from http import HTTPStatus
from typing import Any
from fastapi import APIRouter, Depends, Query from fastapi import (
from lnbits.core.crud import get_standalone_payment, get_user APIRouter,
Depends,
HTTPException,
Query,
WebSocket,
WebSocketDisconnect,
)
from lnbits.core.crud import get_user
from lnbits.core.models import WalletTypeInfo from lnbits.core.models import WalletTypeInfo
from lnbits.core.services import create_invoice from lnbits.core.services import create_invoice
from lnbits.decorators import ( from lnbits.decorators import (
@ -13,7 +22,6 @@ from lnbits.utils.exchange_rates import (
fiat_amount_as_satoshis, fiat_amount_as_satoshis,
get_fiat_rate_satoshis, get_fiat_rate_satoshis,
) )
from starlette.exceptions import HTTPException
from .crud import ( from .crud import (
create_event, create_event,
@ -26,36 +34,79 @@ from .crud import (
get_events, get_events,
get_ticket, get_ticket,
get_tickets, get_tickets,
purge_unpaid_tickets,
update_event, update_event,
update_ticket, update_ticket,
) )
from .models import CreateEvent, CreateTicket, Ticket from .models import (
from .services import refund_tickets, set_ticket_paid 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( async def api_events(
all_wallets: bool = Query(False), all_wallets: bool = Query(False),
wallet: WalletTypeInfo = Depends(require_invoice_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
): ) -> list[Event]:
wallet_ids = [wallet.wallet.id] wallet_ids = [wallet.wallet.id]
if all_wallets: if all_wallets:
user = await get_user(wallet.wallet.user) user = await get_user(wallet.wallet.user)
wallet_ids = user.wallet_ids if user else [] 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.get("/{event_id}", response_model=PublicEvent)
@events_api_router.put("/api/v1/events/{event_id}") 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( async def api_event_create(
data: CreateEvent, data: CreateEvent,
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_admin_key),
event_id: str | None = None, event_id: str | None = None,
): ) -> Event:
if event_id: if event_id:
event = await get_event(event_id) event = await get_event(event_id)
if not event: if not event:
@ -73,14 +124,14 @@ async def api_event_create(
else: else:
event = await create_event(data) 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( async def api_event_cancel(
event_id: str, event_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_admin_key),
): ) -> Event:
event = await get_event(event_id) event = await get_event(event_id)
if not event: if not event:
raise HTTPException( raise HTTPException(
@ -93,13 +144,13 @@ async def api_event_cancel(
event = await update_event(event) event = await update_event(event)
await refund_tickets(event.id) 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( async def api_form_delete(
event_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) event_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
): ) -> None:
event = await get_event(event_id) event = await get_event(event_id)
if not event: if not event:
raise HTTPException( raise HTTPException(
@ -111,47 +162,65 @@ async def api_form_delete(
await delete_event(event_id) await delete_event(event_id)
await delete_event_tickets(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( async def api_tickets(
all_wallets: bool = Query(False), all_wallets: bool = Query(False),
wallet: WalletTypeInfo = Depends(require_invoice_key), key_info: WalletTypeInfo = Depends(require_admin_key),
) -> list[Ticket]: ) -> list[Ticket]:
wallet_ids = [wallet.wallet.id] wallet_ids = [key_info.wallet.id]
if all_wallets: 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 [] wallet_ids = user.wallet_ids if user else []
return await get_tickets(wallet_ids) return await get_tickets(wallet_ids)
@events_api_router.post("/api/v1/tickets/{event_id}") @tickets_api_router.get("/{ticket_id}", response_model=PublicTicket)
async def api_ticket_create(event_id: str, data: CreateTicket): async def api_get_ticket(ticket_id: str) -> Ticket:
name = data.name ticket = await get_ticket(ticket_id)
email = data.email if not ticket:
promo_code = data.promo_code.upper() if data.promo_code else None raise HTTPException(
refund_address = data.refund_address status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
return await api_ticket_make_ticket(
event_id, name, email, promo_code, refund_address
) )
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}") @tickets_api_router.post("/{event_id}")
async def api_ticket_make_ticket(event_id, name, email, promo_code, refund_address): async def api_ticket_create(event_id: str, data: CreateTicket) -> TicketPaymentRequest:
event = await get_event(event_id) event = await get_event(event_id)
if not event: if not event:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." 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 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: if promo_code:
# check if promo_code exists in event.extra.promo_codes # check if promo_code exists in event.extra.promo_codes
@ -172,7 +241,6 @@ async def api_ticket_make_ticket(event_id, name, email, promo_code, refund_addre
price = await fiat_amount_as_satoshis(price, event.currency) price = await fiat_amount_as_satoshis(price, event.currency)
try:
payment = await create_invoice( payment = await create_invoice(
wallet_id=event.wallet, wallet_id=event.wallet,
amount=price, amount=price,
@ -191,65 +259,58 @@ async def api_ticket_make_ticket(event_id, name, email, promo_code, refund_addre
"sats_paid": int(price), "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}
return TicketPaymentRequest(
@events_api_router.post("/api/v1/tickets/{event_id}/{payment_hash}") payment_hash=payment.payment_hash, payment_request=payment.bolt11
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.",
) )
@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) ticket = await get_ticket(payment_hash)
if not ticket: if ticket and ticket.paid:
raise HTTPException( await websocket.send_json({"paid": True})
status_code=HTTPStatus.NOT_FOUND, return
detail="Ticket could not be fetched.",
)
payment = await get_standalone_payment(payment_hash, incoming=True)
assert payment
if ticket.extra.applied_promo_code: while True:
promo = next( disconnect_task = asyncio.create_task(websocket.receive_text())
( payment_task = asyncio.create_task(queue.get())
pc done, pending = await asyncio.wait(
for pc in event.extra.promo_codes {disconnect_task, payment_task}, return_when=asyncio.FIRST_COMPLETED
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 for task in pending:
lower_bound = price * 0.99 # 1% decrease task.cancel()
if not payment.pending and abs(payment.amount) >= lower_bound: # allow 1% error if disconnect_task in done:
ticket.extra.sats_paid = int(payment.amount / 1000) try:
await set_ticket_paid(ticket) disconnect_task.result()
return {"paid": True, "ticket_id": ticket.id} 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( 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) ticket = await get_ticket(ticket_id)
if not ticket: if not ticket:
raise HTTPException( raise HTTPException(
@ -262,14 +323,8 @@ async def api_ticket_delete(
await delete_ticket(ticket_id) await delete_ticket(ticket_id)
@events_api_router.get("/api/v1/eventtickets/{event_id}") @tickets_api_router.put("/register/{ticket_id}", response_model=PublicTicket)
async def api_event_tickets(event_id: str) -> list[Ticket]: async def api_event_register_ticket(ticket_id) -> 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) ticket = await get_ticket(ticket_id)
if not ticket: if not ticket:
@ -289,5 +344,5 @@ async def api_event_register_ticket(ticket_id) -> list[Ticket]:
ticket.registered = True ticket.registered = True
ticket.reg_timestamp = datetime.now(timezone.utc) ticket.reg_timestamp = datetime.now(timezone.utc)
await update_ticket(ticket) ticket = await update_ticket(ticket)
return await get_event_tickets(ticket.event) return ticket