commit bcde392f41a5fdb3af01a1d75d662fbdb3b349d9 Author: Arc <33088785+arcbtc@users.noreply.github.com> Date: Sat Feb 11 08:06:45 2023 +0000 Add files via upload diff --git a/README.md b/README.md new file mode 100644 index 0000000..11b62fe --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# Events + +## Sell tickets for events and use the built-in scanner for registering attendants + +Events alows you to make tickets for an event. Each ticket is in the form of a uniqque QR code. After registering, and paying for ticket, the user gets a QR code to present at registration/entrance. + +Events includes a shareable ticket scanner, which can be used to register attendees. + +## Usage + +1. Create an event\ + ![create event](https://i.imgur.com/dadK1dp.jpg) +2. Fill out the event information: + + - event name + - wallet (normally there's only one) + - event information + - closing date for event registration + - begin and end date of the event + + ![event info](https://imgur.com/KAv68Yr.jpg) + +3. Share the event registration link\ + ![event ticket](https://imgur.com/AQWUOBY.jpg) + + - ticket example\ + ![ticket example](https://i.imgur.com/trAVSLd.jpg) + + - QR code ticket, presented after invoice paid, to present at registration\ + ![event ticket](https://i.imgur.com/M0ROM82.jpg) + +4. Use the built-in ticket scanner to validate registered, and paid, attendees\ + ![ticket scanner](https://i.imgur.com/zrm9202.jpg) diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..b928336 --- /dev/null +++ b/__init__.py @@ -0,0 +1,35 @@ +import asyncio + +from fastapi import APIRouter +from fastapi.staticfiles import StaticFiles + +from lnbits.db import Database +from lnbits.helpers import template_renderer +from lnbits.tasks import catch_everything_and_restart + +db = Database("ext_events") + + +events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"]) + +events_static_files = [ + { + "path": "/events/static", + "app": StaticFiles(packages=[("lnbits", "extensions/events/static")]), + "name": "events_static", + } +] + + +def events_renderer(): + return template_renderer(["lnbits/extensions/events/templates"]) + + +from .tasks import wait_for_paid_invoices +from .views import * # noqa: F401,F403 +from .views_api import * # noqa: F401,F403 + + +def events_start(): + loop = asyncio.get_event_loop() + loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) diff --git a/config.json b/config.json new file mode 100644 index 0000000..a62bcc4 --- /dev/null +++ b/config.json @@ -0,0 +1,6 @@ +{ + "name": "Events", + "short_description": "Sell and register event tickets", + "tile": "/events/static/image/events.png", + "contributors": ["benarc"] +} diff --git a/crud.py b/crud.py new file mode 100644 index 0000000..12cc732 --- /dev/null +++ b/crud.py @@ -0,0 +1,144 @@ +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import CreateEvent, Events, Tickets + +# TICKETS + + +async def create_ticket( + payment_hash: str, wallet: str, event: str, name: str, email: str +) -> Tickets: + await db.execute( + """ + INSERT INTO events.ticket (id, wallet, event, name, email, registered, paid) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (payment_hash, wallet, event, name, email, False, True), + ) + + # UPDATE EVENT DATA ON SOLD TICKET + eventdata = await get_event(event) + assert eventdata, "Couldn't get event from ticket being paid" + sold = eventdata.sold + 1 + amount_tickets = eventdata.amount_tickets - 1 + await db.execute( + """ + UPDATE events.events + SET sold = ?, amount_tickets = ? + WHERE id = ? + """, + (sold, amount_tickets, event), + ) + + ticket = await get_ticket(payment_hash) + assert ticket, "Newly created ticket couldn't be retrieved" + return ticket + + +async def get_ticket(payment_hash: str) -> Optional[Tickets]: + row = await db.fetchone("SELECT * FROM events.ticket WHERE id = ?", (payment_hash,)) + return Tickets(**row) if row else None + + +async def get_tickets(wallet_ids: Union[str, List[str]]) -> List[Tickets]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM events.ticket WHERE wallet IN ({q})", (*wallet_ids,) + ) + return [Tickets(**row) for row in rows] + + +async def delete_ticket(payment_hash: str) -> None: + await db.execute("DELETE FROM events.ticket WHERE id = ?", (payment_hash,)) + + +async def delete_event_tickets(event_id: str) -> None: + await db.execute("DELETE FROM events.ticket WHERE event = ?", (event_id,)) + + +# EVENTS + + +async def create_event(data: CreateEvent) -> Events: + event_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO events.events (id, wallet, name, info, closing_date, event_start_date, event_end_date, amount_tickets, price_per_ticket, sold) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + event_id, + data.wallet, + data.name, + data.info, + data.closing_date, + data.event_start_date, + data.event_end_date, + data.amount_tickets, + data.price_per_ticket, + 0, + ), + ) + + event = await get_event(event_id) + assert event, "Newly created event couldn't be retrieved" + return event + + +async def update_event(event_id: str, **kwargs) -> Events: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE events.events SET {q} WHERE id = ?", (*kwargs.values(), event_id) + ) + event = await get_event(event_id) + assert event, "Newly updated event couldn't be retrieved" + return event + + +async def get_event(event_id: str) -> Optional[Events]: + row = await db.fetchone("SELECT * FROM events.events WHERE id = ?", (event_id,)) + return Events(**row) if row else None + + +async def get_events(wallet_ids: Union[str, List[str]]) -> List[Events]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM events.events WHERE wallet IN ({q})", (*wallet_ids,) + ) + + return [Events(**row) for row in rows] + + +async def delete_event(event_id: str) -> None: + await db.execute("DELETE FROM events.events WHERE id = ?", (event_id,)) + + +# EVENTTICKETS + + +async def get_event_tickets(event_id: str, wallet_id: str) -> List[Tickets]: + rows = await db.fetchall( + "SELECT * FROM events.ticket WHERE wallet = ? AND event = ?", + (wallet_id, event_id), + ) + return [Tickets(**row) for row in rows] + + +async def reg_ticket(ticket_id: str) -> List[Tickets]: + await db.execute( + "UPDATE events.ticket SET registered = ? WHERE id = ?", (True, ticket_id) + ) + ticket = await db.fetchone("SELECT * FROM events.ticket WHERE id = ?", (ticket_id,)) + rows = await db.fetchall( + "SELECT * FROM events.ticket WHERE event = ?", (ticket[1],) + ) + return [Tickets(**row) for row in rows] diff --git a/migrations.py b/migrations.py new file mode 100644 index 0000000..5b9d53b --- /dev/null +++ b/migrations.py @@ -0,0 +1,83 @@ +async def m001_initial(db): + + await db.execute( + """ + CREATE TABLE events.events ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + name TEXT NOT NULL, + info TEXT NOT NULL, + closing_date TEXT NOT NULL, + event_start_date TEXT NOT NULL, + event_end_date TEXT NOT NULL, + amount_tickets INTEGER NOT NULL, + price_per_ticket INTEGER NOT NULL, + sold INTEGER NOT NULL, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) + + await db.execute( + """ + CREATE TABLE events.tickets ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + event TEXT NOT NULL, + name TEXT NOT NULL, + email TEXT NOT NULL, + registered BOOLEAN NOT NULL, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) + + +async def m002_changed(db): + + await db.execute( + """ + CREATE TABLE events.ticket ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + event TEXT NOT NULL, + name TEXT NOT NULL, + email TEXT NOT NULL, + registered BOOLEAN NOT NULL, + paid BOOLEAN NOT NULL, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) + + for row in [list(row) for row in await db.fetchall("SELECT * FROM events.tickets")]: + usescsv = "" + + for i in range(row[5]): + if row[7]: + usescsv += "," + str(i + 1) + else: + usescsv += "," + str(1) + usescsv = usescsv[1:] + await db.execute( + """ + INSERT INTO events.ticket ( + id, + wallet, + event, + name, + email, + registered, + paid + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (row[0], row[1], row[2], row[3], row[4], row[5], True), + ) + await db.execute("DROP TABLE events.tickets") diff --git a/models.py b/models.py new file mode 100644 index 0000000..dd38e97 --- /dev/null +++ b/models.py @@ -0,0 +1,43 @@ +from fastapi.param_functions import Query +from pydantic import BaseModel + + +class CreateEvent(BaseModel): + wallet: str + name: str + info: str + closing_date: str + event_start_date: str + event_end_date: str + amount_tickets: int = Query(..., ge=0) + price_per_ticket: int = Query(..., ge=0) + + +class CreateTicket(BaseModel): + name: str + email: str + + +class Events(BaseModel): + id: str + wallet: str + name: str + info: str + closing_date: str + event_start_date: str + event_end_date: str + amount_tickets: int + price_per_ticket: int + sold: int + time: int + + +class Tickets(BaseModel): + id: str + wallet: str + event: str + name: str + email: str + registered: bool + paid: bool + time: int diff --git a/static/image/events.png b/static/image/events.png new file mode 100644 index 0000000..65c1bdd Binary files /dev/null and b/static/image/events.png differ diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..945e2d2 --- /dev/null +++ b/tasks.py @@ -0,0 +1,36 @@ +import asyncio + +from lnbits.core.models import Payment +from lnbits.helpers import get_current_extension_name +from lnbits.tasks import register_invoice_listener + +from .models import CreateTicket +from .views_api import api_ticket_send_ticket + + +async def wait_for_paid_invoices(): + invoice_queue = asyncio.Queue() + register_invoice_listener(invoice_queue, get_current_extension_name()) + + while True: + payment = await invoice_queue.get() + await on_invoice_paid(payment) + + +async def on_invoice_paid(payment: Payment) -> None: + # (avoid loops) + if ( + payment.extra + and "events" == payment.extra.get("tag") + and payment.extra.get("name") + and payment.extra.get("email") + ): + await api_ticket_send_ticket( + payment.memo, + payment.payment_hash, + CreateTicket( + name=str(payment.extra.get("name")), + email=str(payment.extra.get("email")), + ), + ) + return diff --git a/templates/events/_api_docs.html b/templates/events/_api_docs.html new file mode 100644 index 0000000..9a24d70 --- /dev/null +++ b/templates/events/_api_docs.html @@ -0,0 +1,25 @@ + + + +
+ Events: Sell and register ticket waves for an event +
+

+ Events alows you to make a wave of tickets for an event, each ticket is + in the form of a unqiue 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 new file mode 100644 index 0000000..45c2aca --- /dev/null +++ b/templates/events/display.html @@ -0,0 +1,216 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +

{{ event_name }}

+
+
{{ event_info }}
+
+ + + + +
+ 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 new file mode 100644 index 0000000..f231177 --- /dev/null +++ b/templates/events/error.html @@ -0,0 +1,35 @@ +{% 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 new file mode 100644 index 0000000..2125893 --- /dev/null +++ b/templates/events/index.html @@ -0,0 +1,538 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New Event + + + + + +
+
+
Events
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + + +
+
+
Tickets
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+
+ + +
+ {{SITE_TITLE}} Events extension +
+
+ + + {% include "events/_api_docs.html" %} + +
+
+ + + + +
+
+ +
+
+ + +
+
+ + +
+
Ticket closing date
+
+ +
+
+ +
+
Event begins
+
+ +
+
+ +
+
Event ends
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ Update Event + Create Event + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/templates/events/register.html b/templates/events/register.html new file mode 100644 index 0000000..43d4307 --- /dev/null +++ b/templates/events/register.html @@ -0,0 +1,176 @@ +{% extends "public.html" %} {% block page %} + +
+
+ + +
+

{{ event_name }} Registration

+
+ +
+ + Scan ticket +
+
+
+ + + + + {% raw %} + + + {% endraw %} + + + +
+ + + +
+ +
+
+ Cancel +
+
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/templates/events/ticket.html b/templates/events/ticket.html new file mode 100644 index 0000000..21b7cfa --- /dev/null +++ b/templates/events/ticket.html @@ -0,0 +1,44 @@ +{% 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 new file mode 100644 index 0000000..4ed5679 --- /dev/null +++ b/views.py @@ -0,0 +1,106 @@ +from datetime import date, datetime +from http import HTTPStatus + +from fastapi import Depends, Request +from fastapi.templating import Jinja2Templates +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import events_ext, events_renderer +from .crud import get_event, get_ticket + +templates = Jinja2Templates(directory="templates") + + +@events_ext.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.dict()} + ) + + +@events_ext.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." + ) + + 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 :(", + }, + ) + datetime_object = datetime.strptime(event.closing_date, "%Y-%m-%d").date() + if date.today() > datetime_object: + return events_renderer().TemplateResponse( + "events/error.html", + { + "request": request, + "event_name": event.name, + "event_error": "Sorry, ticket closing date has passed :(", + }, + ) + + 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, + }, + ) + + +@events_ext.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_ext.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, + }, + ) diff --git a/views_api.py b/views_api.py new file mode 100644 index 0000000..f27485d --- /dev/null +++ b/views_api.py @@ -0,0 +1,194 @@ +from http import HTTPStatus + +from fastapi import Depends, Query +from starlette.exceptions import HTTPException + +from lnbits.core.crud import get_user +from lnbits.core.services import create_invoice +from lnbits.core.views.api import api_payment +from lnbits.decorators import WalletTypeInfo, get_key_type + +from . import events_ext +from .crud import ( + create_event, + create_ticket, + delete_event, + delete_event_tickets, + delete_ticket, + get_event, + get_event_tickets, + get_events, + get_ticket, + get_tickets, + reg_ticket, + update_event, +) +from .models import CreateEvent, CreateTicket + +# Events + + +@events_ext.get("/api/v1/events") +async def api_events( + all_wallets: bool = Query(False), wallet: WalletTypeInfo = Depends(get_key_type) +): + wallet_ids = [wallet.wallet.id] + + if all_wallets: + user = await get_user(wallet.wallet.user) + wallet_ids = user.wallet_ids if user else [] + + return [event.dict() for event in await get_events(wallet_ids)] + + +@events_ext.post("/api/v1/events") +@events_ext.put("/api/v1/events/{event_id}") +async def api_event_create( + data: CreateEvent, event_id=None, wallet: WalletTypeInfo = Depends(get_key_type) +): + if event_id: + event = await get_event(event_id) + if not event: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." + ) + + if event.wallet != wallet.wallet.id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Not your event." + ) + event = await update_event(event_id, **data.dict()) + else: + event = await create_event(data=data) + + return event.dict() + + +@events_ext.delete("/api/v1/events/{event_id}") +async def api_form_delete(event_id, wallet: WalletTypeInfo = Depends(get_key_type)): + event = await get_event(event_id) + if not event: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." + ) + + if event.wallet != wallet.wallet.id: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.") + + await delete_event(event_id) + await delete_event_tickets(event_id) + return "", HTTPStatus.NO_CONTENT + + +#########Tickets########## + + +@events_ext.get("/api/v1/tickets") +async def api_tickets( + all_wallets: bool = Query(False), wallet: WalletTypeInfo = Depends(get_key_type) +): + wallet_ids = [wallet.wallet.id] + + if all_wallets: + user = await get_user(wallet.wallet.user) + wallet_ids = user.wallet_ids if user else [] + + return [ticket.dict() for ticket in await get_tickets(wallet_ids)] + + +@events_ext.get("/api/v1/tickets/{event_id}/{name}/{email}") +async def api_ticket_make_ticket(event_id, name, email): + event = await get_event(event_id) + if not event: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." + ) + try: + payment_hash, payment_request = await create_invoice( + wallet_id=event.wallet, + amount=event.price_per_ticket, + memo=f"{event_id}", + extra={"tag": "events", "name": name, "email": email}, + ) + except Exception as e: + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) + return {"payment_hash": payment_hash, "payment_request": payment_request} + + +@events_ext.post("/api/v1/tickets/{event_id}/{payment_hash}") +async def api_ticket_send_ticket(event_id, payment_hash, data: CreateTicket): + event = await get_event(event_id) + if not event: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Event could not be fetched.", + ) + + status = await api_payment(payment_hash) + if status["paid"]: + + exists = await get_ticket(payment_hash) + if exists: + return {"paid": True, "ticket_id": exists.id} + + ticket = await create_ticket( + payment_hash=payment_hash, + wallet=event.wallet, + event=event_id, + name=data.name, + email=data.email, + ) + if not ticket: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Event could not be fetched.", + ) + return {"paid": True, "ticket_id": ticket.id} + return {"paid": False} + + +@events_ext.delete("/api/v1/tickets/{ticket_id}") +async def api_ticket_delete(ticket_id, wallet: WalletTypeInfo = Depends(get_key_type)): + ticket = await get_ticket(ticket_id) + if not ticket: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist." + ) + + if ticket.wallet != wallet.wallet.id: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your ticket.") + + await delete_ticket(ticket_id) + return "", HTTPStatus.NO_CONTENT + + +# Event Tickets + + +@events_ext.get("/api/v1/eventtickets/{wallet_id}/{event_id}") +async def api_event_tickets(wallet_id, event_id): + return [ + ticket.dict() + for ticket in await get_event_tickets(wallet_id=wallet_id, event_id=event_id) + ] + + +@events_ext.get("/api/v1/register/ticket/{ticket_id}") +async def api_event_register_ticket(ticket_id): + ticket = await get_ticket(ticket_id) + if not ticket: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist." + ) + + if not ticket.paid: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Ticket not paid for." + ) + + if ticket.registered is True: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Ticket already registered" + ) + + return [ticket.dict() for ticket in await reg_ticket(ticket_id)]