diff --git a/config.json b/config.json index 11312b6..acfb7d7 100644 --- a/config.json +++ b/config.json @@ -6,7 +6,7 @@ "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", diff --git a/pyproject.toml b/pyproject.toml index 4508802..bec6870 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,8 +10,8 @@ dependencies = [ "lnbits>1" ] [tool.poetry] package-mode = false -[tool.uv] -dev-dependencies = [ +[dependency-groups] +dev = [ "black", "pytest-asyncio", "pytest", diff --git a/static/js/display.js b/static/js/display.js index 6098e5a..6b3bea2 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -1,8 +1,8 @@ -window.app = Vue.createApp({ - el: '#vue', - mixins: [windowMixin], +window.PageEventsDisplay = { + template: '#page-events-display', data() { return { + eventName: '', paymentReq: null, redirectUrl: null, formDialog: { @@ -27,11 +27,8 @@ window.app = Vue.createApp({ } }, 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 + await this.getEvent() }, computed: { formatDescription() { @@ -39,6 +36,21 @@ window.app = Vue.createApp({ } }, methods: { + async getEvent() { + try { + const {data} = await LNbits.api.request( + 'GET', + `/events/api/v1/events/${this.eventId}` + ) + this.eventName = data.event_name + this.info = data.event_info + this.banner = data.event_banner + this.extra = data.event_extra + this.hasPromoCodes = data.has_promo_codes + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, resetForm(e) { e.preventDefault() this.formDialog.data.name = '' @@ -63,68 +75,78 @@ 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 + 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, + refund: this.formDialog.data.refund || null + } + ) + this.paymentReq = data.payment_request + this.paymentCheck = data.payment_hash - dismissMsg = Quasar.Notify.create({ - timeout: 0, - message: 'Waiting for payment...' - }) + dismissMsg = Quasar.Notify.create({ + timeout: 0, + message: 'Waiting for payment...' + }) - 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, + this.receive = { + show: true, + status: 'pending', + paymentReq: this.paymentReq + } + //TODO: use websockets + paymentChecker = setInterval(async () => { + try { + const res = await LNbits.api.request( + 'POST', + `/events/api/v1/tickets/${this.eventId}/${this.paymentCheck}`, + { + event: this.eventId, + event_name: this.eventName, 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 = '' + } + ) + if (res.data.paid) { + clearInterval(paymentChecker) + dismissMsg() + 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 - } + 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/${res.data.ticket_id}` - } - } - setTimeout(() => { - window.location.href = `/events/ticket/${res.data.ticket_id}` - }, 5000) + this.ticketLink = { + show: true, + data: { + link: `/events/ticket/${res.data.ticket_id}` } - }) - .catch(LNbits.utils.notifyApiError) - }, 2000) - }) - .catch(LNbits.utils.notifyApiError) + } + setTimeout(() => { + window.location.href = `/events/ticket/${res.data.ticket_id}` + }, 5000) + } + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, 2000) + } catch (error) { + LNbits.utils.notifyApiError(error) + } } } -}) +} diff --git a/static/js/display.vue b/static/js/display.vue new file mode 100644 index 0000000..253fefe --- /dev/null +++ b/static/js/display.vue @@ -0,0 +1,115 @@ + diff --git a/static/js/index.js b/static/js/index.js index d26133c..ccfa2ba 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -5,9 +5,8 @@ const mapEvents = function (obj) { return obj } -window.app = Vue.createApp({ - el: '#vue', - mixins: [windowMixin], +window.PageEvents = { + template: '#page-events', data() { return { events: [], @@ -290,7 +289,11 @@ window.app = Vue.createApp({ if (this.g.user.wallets.length) { this.getTickets() this.getEvents() - this.currencies = await LNbits.api.getCurrencies() + if (g.allowedCurrencies && g.allowedCurrencies.length) { + this.currencies = ['sats', ...g.allowedCurrencies] + } else { + this.currencies = ['sats', ...g.currencies] + } } } -}) +} diff --git a/static/js/index.vue b/static/js/index.vue new file mode 100644 index 0000000..5e8cc4c --- /dev/null +++ b/static/js/index.vue @@ -0,0 +1,512 @@ + diff --git a/static/js/register.js b/static/js/register.js index a7ab92f..32863ab 100644 --- a/static/js/register.js +++ b/static/js/register.js @@ -8,9 +8,8 @@ const mapEvents = function (obj) { return obj } -window.app = Vue.createApp({ - el: '#vue', - mixins: [windowMixin], +window.PageEventsRegister = { + template: '#page-events-register', data() { return { tickets: [], @@ -75,4 +74,4 @@ window.app = Vue.createApp({ created() { this.getEventTickets() } -}) +} diff --git a/static/js/register.vue b/static/js/register.vue new file mode 100644 index 0000000..34fa9a8 --- /dev/null +++ b/static/js/register.vue @@ -0,0 +1,78 @@ + diff --git a/static/js/ticket.js b/static/js/ticket.js new file mode 100644 index 0000000..3085f47 --- /dev/null +++ b/static/js/ticket.js @@ -0,0 +1,26 @@ +window.PageEventsTicket = { + template: '#page-events-ticket', + data() { + return { + ticketId: null, + ticketName: null + } + }, + methods: { + printWindow() { + window.print() + } + }, + async created() { + this.ticketId = this.$route.params.id + try { + const {data} = await LNbits.api.request( + 'GET', + `/events/api/v1/tickets/${this.ticketId}` + ) + this.ticketName = data.ticket_name + } catch (error) { + LNbits.utils.notifyApiError(error) + } + } +} diff --git a/static/js/ticket.vue b/static/js/ticket.vue new file mode 100644 index 0000000..7fdecce --- /dev/null +++ b/static/js/ticket.vue @@ -0,0 +1,27 @@ + diff --git a/static/routes.json b/static/routes.json new file mode 100644 index 0000000..ae46a9a --- /dev/null +++ b/static/routes.json @@ -0,0 +1,26 @@ +[ + { + "path": "/events/", + "name": "PageEvents", + "template": "/events/static/js/index.vue", + "component": "/events/static/js/index.js" + }, + { + "path": "/events/:id", + "name": "PageEventsDisplay", + "template": "/events/static/js/display.vue", + "component": "/events/static/js/display.js" + }, + { + "path": "/events/ticket/:id", + "name": "PageEventsTicket", + "template": "/events/static/js/ticket.vue", + "component": "/events/static/js/ticket.js" + }, + { + "path": "/events/register/:id", + "name": "PageEventsRegister", + "template": "/events/static/js/register.vue", + "component": "/events/static/js/register.js" + } +] diff --git a/templates/events/_api_docs.html b/templates/events/_api_docs.html deleted file mode 100644 index dbf0131..0000000 --- a/templates/events/_api_docs.html +++ /dev/null @@ -1,25 +0,0 @@ - - - -
- Events: Sell and register ticket waves for an event -
-

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

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

{{ event_name }}

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

-

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

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

{{ event_name }} error

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

{{ event_name }} Registration

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

{{ ticket_name }} Ticket

-
-
- Bookmark, print or screenshot this page,
- and present it for registration! -
-
- -
- - Print -
-
-
-
-
-{% endblock %} {% block scripts %} - -{% endblock %} diff --git a/views.py b/views.py index 0680dcc..39ac273 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 # type: ignore +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..03f03fa 100644 --- a/views_api.py +++ b/views_api.py @@ -26,6 +26,7 @@ get_events, get_ticket, get_tickets, + purge_unpaid_tickets, update_event, update_ticket, ) @@ -49,6 +50,46 @@ async def api_events( return [event.dict() for event in await get_events(wallet_ids)] +@events_api_router.get("/api/v1/events/{event_id}") +async def api_get_event(event_id: str) -> dict: + 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_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": len(event.extra.promo_codes) > 0, + } + + @events_api_router.post("/api/v1/events") @events_api_router.put("/api/v1/events/{event_id}") async def api_event_create( @@ -131,6 +172,25 @@ async def api_tickets( return await get_tickets(wallet_ids) +@events_api_router.get("/api/v1/tickets/{ticket_id}") +async def api_get_ticket(ticket_id: str) -> dict: + 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_id": ticket.id, + "ticket_name": event.name, + "ticket_info": event.info, + } + + @events_api_router.post("/api/v1/tickets/{event_id}") async def api_ticket_create(event_id: str, data: CreateTicket): name = data.name