Compare commits
74 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe9f005b53 | |||
| 2093e63020 | |||
| 9d7efd7662 | |||
| f8059516f8 | |||
| cfc2e38a5e | |||
| b5c87c60b4 | |||
| fd12476b90 | |||
| 1fb96bfe3c | |||
| 4238b41f10 | |||
| 66076d6ca7 | |||
| 37fad05c1f | |||
| 26b1be8ff0 | |||
| 3606fd9a0a | |||
| 66d263ef14 | |||
| 02071e6541 | |||
| 1d8dacbaa3 | |||
| 2b3d9df11d | |||
| 7b761a1aef | |||
| 59068fe09d | |||
| 36568d3eee | |||
| 902bafe7f2 | |||
| ced6ca2b2b | |||
| fa2a6e40f0 | |||
| 05593c9c3c | |||
| b0d089d3c9 | |||
| edf1493e0c | |||
| 814581f307 | |||
| 27cc8d2f1c | |||
| b576a490d2 | |||
| 16eb68d080 | |||
| 0dc2dcc35f | |||
| df4775126f | |||
| 6aa280680e | |||
| c7e95c5452 | |||
| dfabcb8f54 | |||
|
|
4bf867eef0 |
||
|
|
6768b78c6f | ||
|
|
0824b1120b |
||
|
|
32c230957e | ||
|
|
680b035ec9 |
||
|
|
4afc78d44d |
||
|
|
9e477ac959 |
||
|
|
f06bd9a668 |
||
|
|
78433a7d85 |
||
|
|
1dd6f8b67e |
||
|
|
42de6d4791 |
||
|
|
ee70c300f6 |
||
|
|
ae827a6545 |
||
|
|
7aeba1eeb4 |
||
|
|
c729ef17a6 |
||
|
|
6714dcddc7 |
||
|
|
9ca714d878 |
||
|
|
400b39211d |
||
|
|
3df2a56ca2 |
||
|
|
ea3a60ecd4 | ||
|
|
57f40b9790 | ||
|
|
9c82d9e2df | ||
|
|
c24f5ddb84 |
||
|
|
082f5e7488 |
||
|
|
1b1cf72e17 | ||
|
|
b985304384 |
||
|
|
662587dbf2 |
||
|
|
4f5fe8035d |
||
|
|
38951a7ebe | ||
|
|
07d2f59bc3 |
||
|
|
ae8930f884 | ||
|
|
f468183631 |
||
|
|
5e391a04bc |
||
|
|
ff73bc749b |
||
|
|
0c371e81df |
||
|
|
a0037f685a |
||
|
|
00f552c751 |
||
|
|
c8b31d8e3f |
||
|
|
4586164016 |
54 changed files with 7408 additions and 1388 deletions
10
.github/workflows/lint.yml
vendored
Normal file
10
.github/workflows/lint.yml
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
name: lint
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
uses: lnbits/lnbits/.github/workflows/lint.yml@dev
|
||||||
59
.github/workflows/release.yml
vendored
59
.github/workflows/release.yml
vendored
|
|
@ -1,19 +1,58 @@
|
||||||
name: release github version
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "[0-9]+.[0-9]+.[0-9]+"
|
- 'v[0-9]+.[0-9]+.[0-9]+'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Create GitHub Release
|
- uses: actions/checkout@v3
|
||||||
id: create_release
|
- name: Create github release
|
||||||
uses: actions/create-release@v1
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
tag: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
gh release create "$tag" --generate-notes
|
||||||
|
|
||||||
|
pullrequest:
|
||||||
|
needs: [release]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ github.ref }}
|
token: ${{ secrets.EXT_GITHUB }}
|
||||||
release_name: ${{ github.ref }}
|
repository: lnbits/lnbits-extensions
|
||||||
draft: false
|
path: './lnbits-extensions'
|
||||||
prerelease: false
|
|
||||||
|
- name: setup git user
|
||||||
|
run: |
|
||||||
|
git config --global user.name "alan"
|
||||||
|
git config --global user.email "alan@lnbits.com"
|
||||||
|
|
||||||
|
- name: Create pull request in extensions repo
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.EXT_GITHUB }}
|
||||||
|
repo_name: '${{ github.event.repository.name }}'
|
||||||
|
tag: '${{ github.ref_name }}'
|
||||||
|
branch: 'update-${{ github.event.repository.name }}-${{ github.ref_name }}'
|
||||||
|
title: '[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}'
|
||||||
|
body: 'https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}'
|
||||||
|
archive: 'https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip'
|
||||||
|
run: |
|
||||||
|
cd lnbits-extensions
|
||||||
|
git checkout -b $branch
|
||||||
|
|
||||||
|
# if there is another open PR
|
||||||
|
git pull origin $branch || echo "branch does not exist"
|
||||||
|
|
||||||
|
sh util.sh update_extension $repo_name $tag
|
||||||
|
|
||||||
|
git add -A
|
||||||
|
git commit -am "$title"
|
||||||
|
git push origin $branch
|
||||||
|
|
||||||
|
# check if pr exists before creating it
|
||||||
|
gh config set pager cat
|
||||||
|
check=$(gh pr list -H $branch | wc -l)
|
||||||
|
test $check -ne 0 || gh pr create --title "$title" --body "$body" --repo lnbits/lnbits-extensions
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1 +1,4 @@
|
||||||
__pycache__
|
__pycache__
|
||||||
|
node_modules
|
||||||
|
.mypy_cache
|
||||||
|
.venv
|
||||||
|
|
|
||||||
12
.prettierrc
Normal file
12
.prettierrc
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"insertPragma": false,
|
||||||
|
"printWidth": 80,
|
||||||
|
"proseWrap": "preserve",
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"useTabs": false,
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"bracketSpacing": false
|
||||||
|
}
|
||||||
47
Makefile
Normal file
47
Makefile
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
all: format check
|
||||||
|
|
||||||
|
format: prettier black ruff
|
||||||
|
|
||||||
|
check: mypy pyright checkblack checkruff checkprettier
|
||||||
|
|
||||||
|
prettier:
|
||||||
|
uv run ./node_modules/.bin/prettier --write .
|
||||||
|
pyright:
|
||||||
|
uv run ./node_modules/.bin/pyright
|
||||||
|
|
||||||
|
mypy:
|
||||||
|
uv run mypy .
|
||||||
|
|
||||||
|
black:
|
||||||
|
uv run black .
|
||||||
|
|
||||||
|
ruff:
|
||||||
|
uv run ruff check . --fix
|
||||||
|
|
||||||
|
checkruff:
|
||||||
|
uv run ruff check .
|
||||||
|
|
||||||
|
checkprettier:
|
||||||
|
uv run ./node_modules/.bin/prettier --check .
|
||||||
|
|
||||||
|
checkblack:
|
||||||
|
uv run black --check .
|
||||||
|
|
||||||
|
checkeditorconfig:
|
||||||
|
editorconfig-checker
|
||||||
|
|
||||||
|
test:
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
DEBUG=true \
|
||||||
|
uv run pytest
|
||||||
|
install-pre-commit-hook:
|
||||||
|
@echo "Installing pre-commit hook to git"
|
||||||
|
@echo "Uninstall the hook with uv run pre-commit uninstall"
|
||||||
|
uv run pre-commit install
|
||||||
|
|
||||||
|
pre-commit:
|
||||||
|
uv run pre-commit run --all-files
|
||||||
|
|
||||||
|
|
||||||
|
checkbundle:
|
||||||
|
@echo "skipping checkbundle"
|
||||||
22
README.md
22
README.md
|
|
@ -1,9 +1,20 @@
|
||||||
|
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
|
||||||
|
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:280px">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
[](./LICENSE)
|
||||||
|
[](https://github.com/lnbits/lnbits)
|
||||||
|
|
||||||
# Events - <small>[LNbits](https://github.com/lnbits/lnbits) extension</small>
|
# Events - <small>[LNbits](https://github.com/lnbits/lnbits) extension</small>
|
||||||
|
|
||||||
<small>For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions)</small>
|
<small>For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions)</small>
|
||||||
|
|
||||||
## Sell tickets for events and use the built-in scanner for registering attendants
|
## Sell tickets for events and use the built-in scanner for registering attendees
|
||||||
|
|
||||||
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 allows you to create tickets for an event. Each ticket is in the form of a unique QR code. After registering and paying, the user gets a QR code to present at registration/entrance.
|
||||||
|
|
||||||
Events includes a shareable ticket scanner, which can be used to register attendees.
|
Events includes a shareable ticket scanner, which can be used to register attendees.
|
||||||
|
|
||||||
|
|
@ -32,3 +43,10 @@ Events includes a shareable ticket scanner, which can be used to register attend
|
||||||
|
|
||||||
4. Use the built-in ticket scanner to validate registered, and paid, attendees\
|
4. Use the built-in ticket scanner to validate registered, and paid, attendees\
|
||||||

|

|
||||||
|
|
||||||
|
## Powered by LNbits
|
||||||
|
|
||||||
|
[LNbits](https://lnbits.com) is a free and open-source lightning accounts system.
|
||||||
|
|
||||||
|
[](https://shop.lnbits.com/)
|
||||||
|
[](https://my.lnbits.com/login)
|
||||||
|
|
|
||||||
109
__init__.py
109
__init__.py
|
|
@ -1,35 +1,114 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from fastapi.staticfiles import StaticFiles
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.db import Database
|
|
||||||
from lnbits.helpers import template_renderer
|
|
||||||
from lnbits.tasks import catch_everything_and_restart
|
|
||||||
|
|
||||||
db = Database("ext_events")
|
|
||||||
|
|
||||||
|
from .crud import db
|
||||||
|
from .tasks import wait_for_paid_invoices
|
||||||
|
from .views import events_generic_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_api_router)
|
||||||
|
events_ext.include_router(tickets_api_router)
|
||||||
|
|
||||||
events_static_files = [
|
events_static_files = [
|
||||||
{
|
{
|
||||||
"path": "/events/static",
|
"path": "/events/static",
|
||||||
"app": StaticFiles(packages=[("lnbits", "extensions/events/static")]),
|
|
||||||
"name": "events_static",
|
"name": "events_static",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
scheduled_tasks: list[asyncio.Task] = []
|
||||||
|
|
||||||
def events_renderer():
|
# Module-level NostrClient — None when nostrclient is unavailable. Set by the
|
||||||
return template_renderer(["lnbits/extensions/events/templates"])
|
# bootstrap task in events_start() and read via dynamic attribute lookup
|
||||||
|
# from nostr_hooks.publish_or_delete_nostr_event.
|
||||||
|
nostr_client = None
|
||||||
|
|
||||||
|
|
||||||
from .tasks import wait_for_paid_invoices
|
def events_stop():
|
||||||
from .views import * # noqa: F401,F403
|
for task in scheduled_tasks:
|
||||||
from .views_api import * # noqa: F401,F403
|
try:
|
||||||
|
task.cancel()
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning(ex)
|
||||||
|
|
||||||
|
global nostr_client
|
||||||
|
if nostr_client:
|
||||||
|
asyncio.get_event_loop().create_task(nostr_client.stop())
|
||||||
|
|
||||||
|
|
||||||
def events_start():
|
def events_start():
|
||||||
loop = asyncio.get_event_loop()
|
from lnbits.tasks import create_permanent_unique_task
|
||||||
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
|
||||||
|
task1 = create_permanent_unique_task("ext_events", wait_for_paid_invoices)
|
||||||
|
scheduled_tasks.append(task1)
|
||||||
|
|
||||||
|
# Register nostr-transport RPCs. Swallow ImportError on older LNbits
|
||||||
|
# versions that pre-date the transport (the events extension still
|
||||||
|
# works fine via HTTP without it).
|
||||||
|
try:
|
||||||
|
from lnbits.core.services.nostr_transport.dispatcher import (
|
||||||
|
AUTH_WALLET,
|
||||||
|
register_rpc,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .transport_rpcs import (
|
||||||
|
handle_events_list_event_tickets,
|
||||||
|
handle_events_ticket_register,
|
||||||
|
)
|
||||||
|
|
||||||
|
register_rpc(
|
||||||
|
"events_ticket_register", handle_events_ticket_register, AUTH_WALLET
|
||||||
|
)
|
||||||
|
register_rpc(
|
||||||
|
"events_list_event_tickets",
|
||||||
|
handle_events_list_event_tickets,
|
||||||
|
AUTH_WALLET,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"[EVENTS] Registered nostr-transport RPCs: "
|
||||||
|
"events_ticket_register, events_list_event_tickets"
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
logger.info(
|
||||||
|
"[EVENTS] nostr_transport not available on this LNbits — "
|
||||||
|
"ticket scanner over Nostr disabled, HTTP endpoint still works"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _start_nostr_client():
|
||||||
|
global nostr_client
|
||||||
|
await asyncio.sleep(10) # Wait for nostrclient to be ready
|
||||||
|
try:
|
||||||
|
from .nostr.nostr_client import NostrClient
|
||||||
|
|
||||||
|
nostr_client = NostrClient()
|
||||||
|
logger.info("[EVENTS] Starting NostrClient for NIP-52 sync")
|
||||||
|
await nostr_client.run_forever()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"[EVENTS] NostrClient failed to start: {exc}")
|
||||||
|
logger.info("[EVENTS] Events will work without Nostr sync")
|
||||||
|
|
||||||
|
task2 = create_permanent_unique_task("ext_events_nostr", _start_nostr_client)
|
||||||
|
scheduled_tasks.append(task2)
|
||||||
|
|
||||||
|
async def _sync_nostr_events():
|
||||||
|
global nostr_client
|
||||||
|
await asyncio.sleep(15) # Wait for NostrClient to connect
|
||||||
|
if not nostr_client:
|
||||||
|
logger.info("[EVENTS] No NostrClient, skipping Nostr sync")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from .nostr_sync import wait_for_nostr_events
|
||||||
|
|
||||||
|
await wait_for_nostr_events(nostr_client)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"[EVENTS] Nostr sync task failed: {exc}")
|
||||||
|
|
||||||
|
task3 = create_permanent_unique_task("ext_events_nostr_sync", _sync_nostr_events)
|
||||||
|
scheduled_tasks.append(task3)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["db", "events_ext", "events_start", "events_static_files", "events_stop"]
|
||||||
|
|
|
||||||
69
config.json
69
config.json
|
|
@ -1,6 +1,67 @@
|
||||||
{
|
{
|
||||||
"name": "Events",
|
"id": "events",
|
||||||
"short_description": "Sell and register event tickets",
|
"version": "1.6.1-aio.7",
|
||||||
"tile": "/events/static/image/events.png",
|
"name": "Events",
|
||||||
"contributors": ["benarc"]
|
"repo": "https://git.atitlan.io/aiolabs/events",
|
||||||
|
"short_description": "Sell and register event tickets",
|
||||||
|
"description": "",
|
||||||
|
"tile": "/events/static/image/events.png",
|
||||||
|
"min_lnbits_version": "1.4.1",
|
||||||
|
"contributors": [
|
||||||
|
{
|
||||||
|
"name": "talvasconcelos",
|
||||||
|
"uri": "https://github.com/talvasconcelos",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dni",
|
||||||
|
"uri": "https://github.com/dni",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "prusnak",
|
||||||
|
"uri": "https://github.com/prusnak",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ben Arc",
|
||||||
|
"uri": "https://github.com/arcbtc",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "motorina0",
|
||||||
|
"uri": "https://github.com/motorina0",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "padreug",
|
||||||
|
"uri": "https://git.atitlan.io/padreug",
|
||||||
|
"role": "Developer (aio fork: approval workflow + NIP-52 Nostr sync + edit gating)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"uri": "https://raw.githubusercontent.com/lnbits/events/main/static/image/1.jpg",
|
||||||
|
"link": "https://www.youtube.com/embed/hGTkJ9e5TNk?si=DXqBEEzpyyb33UQd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": "https://raw.githubusercontent.com/lnbits/events/main/static/image/1.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": "https://raw.githubusercontent.com/lnbits/events/main/static/image/2.jpeg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": "https://raw.githubusercontent.com/lnbits/events/main/static/image/3.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": "https://raw.githubusercontent.com/lnbits/events/main/static/image/4.png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description_md": "https://raw.githubusercontent.com/lnbits/events/main/description.md",
|
||||||
|
"terms_and_conditions_md": "https://raw.githubusercontent.com/lnbits/events/main/toc.md",
|
||||||
|
"license": "MIT",
|
||||||
|
"paid_features": "",
|
||||||
|
"tags": ["Fun & Social", "Ticketing"],
|
||||||
|
"donate": "",
|
||||||
|
"hidden": false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
309
crud.py
309
crud.py
|
|
@ -1,144 +1,269 @@
|
||||||
from typing import List, Optional, Union
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from lnbits.db import Database
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
|
||||||
from . import db
|
from .models import CreateEvent, Event, EventsSettings, Ticket, TicketExtra
|
||||||
from .models import CreateEvent, Event, Ticket
|
|
||||||
|
|
||||||
# TICKETS
|
db = Database("ext_events")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ticket_row(row) -> dict:
|
||||||
|
"""Normalize a ticket row before constructing a Ticket model.
|
||||||
|
|
||||||
|
- Empty-string sentinels in name/email (used because the DB columns are
|
||||||
|
NOT NULL but the Pydantic field is Optional when user_id is set) are
|
||||||
|
converted back to None.
|
||||||
|
- The `extra` JSON column may come back as a string when the row is
|
||||||
|
fetched without a model= argument; parse it so Pydantic can build
|
||||||
|
TicketExtra from a dict.
|
||||||
|
"""
|
||||||
|
ticket_data = dict(row)
|
||||||
|
|
||||||
|
if ticket_data.get("name") == "":
|
||||||
|
ticket_data["name"] = None
|
||||||
|
if ticket_data.get("email") == "":
|
||||||
|
ticket_data["email"] = None
|
||||||
|
|
||||||
|
extra = ticket_data.get("extra")
|
||||||
|
if isinstance(extra, str):
|
||||||
|
ticket_data["extra"] = json.loads(extra) if extra else {}
|
||||||
|
|
||||||
|
return ticket_data
|
||||||
|
|
||||||
|
|
||||||
async def create_ticket(
|
async def create_ticket(
|
||||||
payment_hash: str, wallet: str, event: str, name: str, email: str
|
payment_hash: str,
|
||||||
|
wallet: str,
|
||||||
|
event: str,
|
||||||
|
name: str | None = None,
|
||||||
|
email: str | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
extra: dict | None = None,
|
||||||
|
ticket_id: str | None = None,
|
||||||
) -> Ticket:
|
) -> Ticket:
|
||||||
await db.execute(
|
"""Persist one ticket row.
|
||||||
"""
|
|
||||||
INSERT INTO events.ticket (id, wallet, event, name, email, registered, paid)
|
`payment_hash` is the LNbits invoice hash shared across all rows
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
of a multi-ticket purchase. `ticket_id` is the row primary key /
|
||||||
""",
|
scannable id; defaults to `payment_hash` for single-ticket
|
||||||
(payment_hash, wallet, event, name, email, False, True),
|
purchases so the legacy id == payment_hash invariant holds.
|
||||||
|
Multi-ticket callers pass a unique uuid here so each attendee
|
||||||
|
gets a distinct scannable QR.
|
||||||
|
"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
row_id = ticket_id or payment_hash
|
||||||
|
|
||||||
|
# name/email columns are NOT NULL in the schema, so we store "" when only
|
||||||
|
# user_id is supplied. _parse_ticket_row reverses this on read.
|
||||||
|
if user_id:
|
||||||
|
db_name = ""
|
||||||
|
db_email = ""
|
||||||
|
else:
|
||||||
|
db_name = name or ""
|
||||||
|
db_email = email or ""
|
||||||
|
|
||||||
|
db_ticket = Ticket(
|
||||||
|
id=row_id,
|
||||||
|
wallet=wallet,
|
||||||
|
event=event,
|
||||||
|
name=db_name,
|
||||||
|
email=db_email,
|
||||||
|
user_id=user_id,
|
||||||
|
registered=False,
|
||||||
|
paid=False,
|
||||||
|
reg_timestamp=now,
|
||||||
|
time=now,
|
||||||
|
extra=TicketExtra(**extra) if extra else TicketExtra(),
|
||||||
|
payment_hash=payment_hash,
|
||||||
|
)
|
||||||
|
await db.insert("events.ticket", db_ticket)
|
||||||
|
|
||||||
|
return Ticket(
|
||||||
|
id=row_id,
|
||||||
|
wallet=wallet,
|
||||||
|
event=event,
|
||||||
|
name=name,
|
||||||
|
email=email,
|
||||||
|
user_id=user_id,
|
||||||
|
registered=False,
|
||||||
|
paid=False,
|
||||||
|
reg_timestamp=now,
|
||||||
|
time=now,
|
||||||
|
extra=TicketExtra(**extra) if extra else TicketExtra(),
|
||||||
|
payment_hash=payment_hash,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 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)
|
async def update_ticket(ticket: Ticket) -> Ticket:
|
||||||
assert ticket, "Newly created ticket couldn't be retrieved"
|
ticket_dict = ticket.dict()
|
||||||
|
if ticket_dict.get("name") is None:
|
||||||
|
ticket_dict["name"] = ""
|
||||||
|
if ticket_dict.get("email") is None:
|
||||||
|
ticket_dict["email"] = ""
|
||||||
|
await db.update("events.ticket", Ticket(**ticket_dict))
|
||||||
return ticket
|
return ticket
|
||||||
|
|
||||||
|
|
||||||
async def get_ticket(payment_hash: str) -> Optional[Ticket]:
|
async def get_tickets_by_payment_hash(payment_hash: str) -> list[Ticket]:
|
||||||
row = await db.fetchone("SELECT * FROM events.ticket WHERE id = ?", (payment_hash,))
|
"""All ticket rows sharing the given LNbits invoice payment_hash.
|
||||||
return Ticket(**row) if row else None
|
|
||||||
|
For a single-ticket purchase returns one row (legacy invariant
|
||||||
|
`id == payment_hash` still holds). For a multi-ticket purchase
|
||||||
|
returns the N rows created with shared `payment_hash` but
|
||||||
|
distinct `id`s — each attendee's scannable QR.
|
||||||
|
"""
|
||||||
|
rows = await db.fetchall(
|
||||||
|
"SELECT * FROM events.ticket WHERE payment_hash = :ph",
|
||||||
|
{"ph": payment_hash},
|
||||||
|
)
|
||||||
|
return [Ticket(**_parse_ticket_row(row)) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
async def get_tickets(wallet_ids: Union[str, List[str]]) -> List[Ticket]:
|
async def get_ticket(payment_hash: str) -> Ticket | None:
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT * FROM events.ticket WHERE id = :id",
|
||||||
|
{"id": payment_hash},
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return Ticket(**_parse_ticket_row(row))
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]:
|
||||||
if isinstance(wallet_ids, str):
|
if isinstance(wallet_ids, str):
|
||||||
wallet_ids = [wallet_ids]
|
wallet_ids = [wallet_ids]
|
||||||
|
q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids])
|
||||||
|
rows = await db.fetchall(f"SELECT * FROM events.ticket WHERE wallet IN ({q})")
|
||||||
|
return [Ticket(**_parse_ticket_row(row)) for row in rows]
|
||||||
|
|
||||||
q = ",".join(["?"] * len(wallet_ids))
|
|
||||||
|
async def get_tickets_by_event(event_id: str) -> list[Ticket]:
|
||||||
|
"""All ticket rows for the given calendar event id."""
|
||||||
rows = await db.fetchall(
|
rows = await db.fetchall(
|
||||||
f"SELECT * FROM events.ticket WHERE wallet IN ({q})", (*wallet_ids,)
|
"SELECT * FROM events.ticket WHERE event = :event_id",
|
||||||
|
{"event_id": event_id},
|
||||||
)
|
)
|
||||||
return [Ticket(**row) for row in rows]
|
return [Ticket(**_parse_ticket_row(row)) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tickets_by_user_id(user_id: str) -> list[Ticket]:
|
||||||
|
"""All tickets owned by the given LNbits user_id."""
|
||||||
|
rows = await db.fetchall(
|
||||||
|
"SELECT * FROM events.ticket WHERE user_id = :user_id ORDER BY time DESC",
|
||||||
|
{"user_id": user_id},
|
||||||
|
)
|
||||||
|
return [Ticket(**_parse_ticket_row(row)) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
async def delete_ticket(payment_hash: str) -> None:
|
async def delete_ticket(payment_hash: str) -> None:
|
||||||
await db.execute("DELETE FROM events.ticket WHERE id = ?", (payment_hash,))
|
await db.execute("DELETE FROM events.ticket WHERE id = :id", {"id": payment_hash})
|
||||||
|
|
||||||
|
|
||||||
async def delete_event_tickets(event_id: str) -> None:
|
async def delete_event_tickets(event_id: str) -> None:
|
||||||
await db.execute("DELETE FROM events.ticket WHERE event = ?", (event_id,))
|
await db.execute(
|
||||||
|
"DELETE FROM events.ticket WHERE event = :event", {"event": event_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# EVENTS
|
async def purge_unpaid_tickets(event_id: str) -> None:
|
||||||
|
time_diff = datetime.now() - timedelta(hours=24)
|
||||||
|
await db.execute(
|
||||||
|
f"""
|
||||||
|
DELETE FROM events.ticket WHERE event = :event AND paid = false
|
||||||
|
AND time < {db.timestamp_placeholder("time")}
|
||||||
|
""",
|
||||||
|
{"time": time_diff.timestamp(), "event": event_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def create_event(data: CreateEvent) -> Event:
|
async def create_event(data: CreateEvent) -> Event:
|
||||||
event_id = urlsafe_short_hash()
|
event_id = urlsafe_short_hash()
|
||||||
await db.execute(
|
# Default end_date to start_date and closing_date to end_date when omitted.
|
||||||
"""
|
if not data.event_end_date:
|
||||||
INSERT INTO events.events (id, wallet, name, info, closing_date, event_start_date, event_end_date, amount_tickets, price_per_ticket, sold)
|
data.event_end_date = data.event_start_date
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
if not data.closing_date:
|
||||||
""",
|
data.closing_date = data.event_end_date
|
||||||
(
|
event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict())
|
||||||
event_id,
|
await db.insert("events.events", event)
|
||||||
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
|
return event
|
||||||
|
|
||||||
|
|
||||||
async def update_event(event_id: str, **kwargs) -> Event:
|
async def update_event(event: Event) -> Event:
|
||||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
await db.update("events.events", event)
|
||||||
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
|
return event
|
||||||
|
|
||||||
|
|
||||||
async def get_event(event_id: str) -> Optional[Event]:
|
async def get_event(event_id: str) -> Event | None:
|
||||||
row = await db.fetchone("SELECT * FROM events.events WHERE id = ?", (event_id,))
|
return await db.fetchone(
|
||||||
return Event(**row) if row else None
|
"SELECT * FROM events.events WHERE id = :id",
|
||||||
|
{"id": event_id},
|
||||||
|
Event,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_events(wallet_ids: Union[str, List[str]]) -> List[Event]:
|
async def get_events(wallet_ids: str | list[str]) -> list[Event]:
|
||||||
if isinstance(wallet_ids, str):
|
if isinstance(wallet_ids, str):
|
||||||
wallet_ids = [wallet_ids]
|
wallet_ids = [wallet_ids]
|
||||||
|
q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids])
|
||||||
q = ",".join(["?"] * len(wallet_ids))
|
return await db.fetchall(
|
||||||
rows = await db.fetchall(
|
f"SELECT * FROM events.events WHERE wallet IN ({q})",
|
||||||
f"SELECT * FROM events.events WHERE wallet IN ({q})", (*wallet_ids,)
|
model=Event,
|
||||||
)
|
)
|
||||||
|
|
||||||
return [Event(**row) for row in rows]
|
|
||||||
|
async def get_all_events() -> list[Event]:
|
||||||
|
"""All events, no wallet filter. Admin-only callers."""
|
||||||
|
return await db.fetchall(
|
||||||
|
"SELECT * FROM events.events ORDER BY time DESC",
|
||||||
|
model=Event,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_public_events() -> list[Event]:
|
||||||
|
"""Approved, non-canceled events for the public listing."""
|
||||||
|
return await db.fetchall(
|
||||||
|
"""
|
||||||
|
SELECT * FROM events.events
|
||||||
|
WHERE status = 'approved' AND canceled = FALSE
|
||||||
|
ORDER BY event_start_date ASC
|
||||||
|
""",
|
||||||
|
model=Event,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_pending_events() -> list[Event]:
|
||||||
|
"""Proposed events awaiting admin approval."""
|
||||||
|
return await db.fetchall(
|
||||||
|
"SELECT * FROM events.events WHERE status = 'proposed' ORDER BY time DESC",
|
||||||
|
model=Event,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_settings() -> EventsSettings:
|
||||||
|
"""Singleton settings row, seeded by m010."""
|
||||||
|
row = await db.fetchone("SELECT * FROM events.settings WHERE id = 1")
|
||||||
|
if row:
|
||||||
|
return EventsSettings(**dict(row))
|
||||||
|
return EventsSettings()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_settings(settings: EventsSettings) -> EventsSettings:
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE events.settings SET auto_approve = :auto_approve WHERE id = 1",
|
||||||
|
{"auto_approve": settings.auto_approve},
|
||||||
|
)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
async def delete_event(event_id: str) -> None:
|
async def delete_event(event_id: str) -> None:
|
||||||
await db.execute("DELETE FROM events.events WHERE id = ?", (event_id,))
|
await db.execute("DELETE FROM events.events WHERE id = :id", {"id": event_id})
|
||||||
|
|
||||||
|
|
||||||
# EVENTTICKETS
|
async def get_event_tickets(event_id: str) -> list[Ticket]:
|
||||||
|
|
||||||
|
|
||||||
async def get_event_tickets(event_id: str, wallet_id: str) -> List[Ticket]:
|
|
||||||
rows = await db.fetchall(
|
rows = await db.fetchall(
|
||||||
"SELECT * FROM events.ticket WHERE wallet = ? AND event = ?",
|
"SELECT * FROM events.ticket WHERE event = :event",
|
||||||
(wallet_id, event_id),
|
{"event": event_id},
|
||||||
)
|
)
|
||||||
return [Ticket(**row) for row in rows]
|
return [Ticket(**_parse_ticket_row(row)) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
async def reg_ticket(ticket_id: str) -> List[Ticket]:
|
|
||||||
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 [Ticket(**row) for row in rows]
|
|
||||||
|
|
|
||||||
10
description.md
Normal file
10
description.md
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
Sell tickets for events and manage attendee registration with a built-in QR scanner.
|
||||||
|
|
||||||
|
Its features include:
|
||||||
|
|
||||||
|
- Creating events with ticket pricing
|
||||||
|
- Generating unique QR code tickets after payment
|
||||||
|
- Providing a shareable ticket scanner for check-in
|
||||||
|
- Tracking registered and checked-in attendees
|
||||||
|
|
||||||
|
A complete ticketing solution for event organizers, meetup hosts, and conference planners who want to sell tickets and manage attendance with Bitcoin.
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
{
|
{
|
||||||
"repos": [
|
"repos": [
|
||||||
{
|
{
|
||||||
"id": "events",
|
"id": "events",
|
||||||
"organisation": "lnbits",
|
"organisation": "lnbits",
|
||||||
"repository": "events"
|
"repository": "events"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
118
migrations.py
118
migrations.py
|
|
@ -1,5 +1,4 @@
|
||||||
async def m001_initial(db):
|
async def m001_initial(db):
|
||||||
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE events.events (
|
CREATE TABLE events.events (
|
||||||
|
|
@ -38,7 +37,6 @@ async def m001_initial(db):
|
||||||
|
|
||||||
|
|
||||||
async def m002_changed(db):
|
async def m002_changed(db):
|
||||||
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE events.ticket (
|
CREATE TABLE events.ticket (
|
||||||
|
|
@ -81,3 +79,119 @@ async def m002_changed(db):
|
||||||
(row[0], row[1], row[2], row[3], row[4], row[5], True),
|
(row[0], row[1], row[2], row[3], row[4], row[5], True),
|
||||||
)
|
)
|
||||||
await db.execute("DROP TABLE events.tickets")
|
await db.execute("DROP TABLE events.tickets")
|
||||||
|
|
||||||
|
|
||||||
|
async def m003_add_register_timestamp(db):
|
||||||
|
"""
|
||||||
|
Add a column to register the timestamp of ticket register
|
||||||
|
"""
|
||||||
|
await db.execute(
|
||||||
|
"ALTER TABLE events.ticket ADD COLUMN reg_timestamp TIMESTAMP;"
|
||||||
|
) # NULL means not registered, or old ticket
|
||||||
|
|
||||||
|
|
||||||
|
async def m004_add_currency(db):
|
||||||
|
"""
|
||||||
|
Add a currency table to allow fiat denomination
|
||||||
|
of tickets. Make price a float.
|
||||||
|
"""
|
||||||
|
await db.execute("ALTER TABLE events.events RENAME TO events_old")
|
||||||
|
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,
|
||||||
|
currency TEXT NOT NULL,
|
||||||
|
amount_tickets INTEGER NOT NULL,
|
||||||
|
price_per_ticket REAL NOT NULL,
|
||||||
|
sold INTEGER NOT NULL,
|
||||||
|
time TIMESTAMP NOT NULL DEFAULT """
|
||||||
|
+ db.timestamp_now
|
||||||
|
+ """
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in [
|
||||||
|
list(row) for row in await db.fetchall("SELECT * FROM events.events_old")
|
||||||
|
]:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO events.events (
|
||||||
|
id,
|
||||||
|
wallet,
|
||||||
|
name,
|
||||||
|
info,
|
||||||
|
closing_date,
|
||||||
|
event_start_date,
|
||||||
|
event_end_date,
|
||||||
|
currency,
|
||||||
|
amount_tickets,
|
||||||
|
price_per_ticket,
|
||||||
|
sold
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
row[0],
|
||||||
|
row[1],
|
||||||
|
row[2],
|
||||||
|
row[3],
|
||||||
|
row[4],
|
||||||
|
row[5],
|
||||||
|
row[6],
|
||||||
|
"sat",
|
||||||
|
row[7],
|
||||||
|
row[8],
|
||||||
|
row[9],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.execute("DROP TABLE events.events_old")
|
||||||
|
|
||||||
|
|
||||||
|
async def m005_add_image_banner(db):
|
||||||
|
"""
|
||||||
|
Add a column to allow an image banner for the event
|
||||||
|
"""
|
||||||
|
await db.execute("ALTER TABLE events.events ADD COLUMN banner TEXT;")
|
||||||
|
|
||||||
|
|
||||||
|
async def m006_add_extra_fields(db):
|
||||||
|
"""
|
||||||
|
Add a canceled and 'extra' column to events and ticket tables
|
||||||
|
to support promo codes and ticket metadata.
|
||||||
|
"""
|
||||||
|
# Add canceled and 'extra' columns to events table
|
||||||
|
await db.execute(
|
||||||
|
"ALTER TABLE events.events ADD COLUMN canceled BOOLEAN NOT NULL DEFAULT FALSE;"
|
||||||
|
)
|
||||||
|
await db.execute("ALTER TABLE events.events ADD COLUMN extra TEXT;")
|
||||||
|
|
||||||
|
# Add 'extra' column to ticket table
|
||||||
|
await db.execute("ALTER TABLE events.ticket ADD COLUMN extra TEXT;")
|
||||||
|
|
||||||
|
|
||||||
|
async def m007_add_allow_fiat(db):
|
||||||
|
"""
|
||||||
|
Add an allow_fiat column so event owners can explicitly enable fiat checkout.
|
||||||
|
"""
|
||||||
|
await db.execute("""
|
||||||
|
ALTER TABLE events.events
|
||||||
|
ADD COLUMN allow_fiat BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
async def m008_add_fiat_currency(db):
|
||||||
|
"""
|
||||||
|
Add a fiat_currency column for sat-denominated events using fiat checkout.
|
||||||
|
"""
|
||||||
|
await db.execute("""
|
||||||
|
ALTER TABLE events.events
|
||||||
|
ADD COLUMN fiat_currency TEXT NOT NULL DEFAULT 'GBP';
|
||||||
|
""")
|
||||||
|
|
|
||||||
130
migrations_fork.py
Normal file
130
migrations_fork.py
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
"""
|
||||||
|
Fork-specific database migrations for the aiolabs events extension.
|
||||||
|
|
||||||
|
These migrations are tracked separately under `events_fork` in the
|
||||||
|
`dbversions` table (loaded by `lnbits/core/helpers.py:migrate_extension_database`),
|
||||||
|
so they do not collide with upstream's `m{NNN}_*` numbering in
|
||||||
|
`migrations.py`. Keeping the upstream-tracked file untouched means
|
||||||
|
`git pull upstream` stays rebase-clean for schema changes.
|
||||||
|
|
||||||
|
Conventions:
|
||||||
|
- Sequential numbering starting from m001.
|
||||||
|
- Each migration is `async def m{NNN}_<description>(db)`.
|
||||||
|
- DDL must be idempotent: a fresh install runs every migration; an
|
||||||
|
install that previously ran the OLD versions of these as
|
||||||
|
`m007-m011` in `migrations.py` has the columns/tables already.
|
||||||
|
Use `_alter_add_column_safe` / `_create_table_safe` so re-runs are
|
||||||
|
no-ops instead of crashes.
|
||||||
|
|
||||||
|
History compressed into m001 (was m007-m011 in migrations.py pre-v1.6
|
||||||
|
rebase):
|
||||||
|
- m007 add_user_id_support (ticket.user_id column)
|
||||||
|
- m008 add_event_status (events.status column)
|
||||||
|
- m009 add_nostr_columns (events.nostr_event_id + created_at)
|
||||||
|
- m010 add_events_settings (events.settings singleton table)
|
||||||
|
- m011 add_location_and_categories (events.location + categories)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def _alter_add_column_safe(db, sql: str) -> None:
|
||||||
|
"""ALTER TABLE ADD COLUMN that swallows duplicate-column errors.
|
||||||
|
|
||||||
|
Re-running the squashed migration on a database that already has
|
||||||
|
these columns (from the pre-squash `m007-m011` in migrations.py)
|
||||||
|
must be a silent no-op. Same swallow we used in the old migrations.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await db.execute(sql)
|
||||||
|
except Exception as exc:
|
||||||
|
msg = str(exc).lower()
|
||||||
|
if "duplicate column" in msg or "already exists" in msg:
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def m001_aio_event_schema(db):
|
||||||
|
"""
|
||||||
|
Apply every aiolabs schema delta on top of upstream events v1.3.0.
|
||||||
|
|
||||||
|
This is the squashed equivalent of the pre-v1.6 sequence
|
||||||
|
m007 → m011. Order matters for the settings table seed insert
|
||||||
|
but the individual column adds are independent and idempotent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# --- ticket.user_id ----------------------------------------------
|
||||||
|
# Lets a ticket reference an LNbits user id instead of (name, email).
|
||||||
|
# Application logic enforces that exactly one identifier scheme is
|
||||||
|
# used per ticket.
|
||||||
|
await _alter_add_column_safe(
|
||||||
|
db, "ALTER TABLE events.ticket ADD COLUMN user_id TEXT"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- events.status -----------------------------------------------
|
||||||
|
# Proposal / approval workflow. Existing rows default to 'approved'
|
||||||
|
# so they stay visible after upgrade.
|
||||||
|
await _alter_add_column_safe(
|
||||||
|
db,
|
||||||
|
"ALTER TABLE events.events ADD COLUMN status TEXT NOT NULL DEFAULT 'approved'",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- events.nostr_event_id, nostr_event_created_at ---------------
|
||||||
|
# Track the most recent NIP-52 calendar event we published, so
|
||||||
|
# subsequent edits can issue replaceable updates and NIP-09 deletes
|
||||||
|
# against the right addressable coordinate.
|
||||||
|
await _alter_add_column_safe(
|
||||||
|
db, "ALTER TABLE events.events ADD COLUMN nostr_event_id TEXT"
|
||||||
|
)
|
||||||
|
await _alter_add_column_safe(
|
||||||
|
db, "ALTER TABLE events.events ADD COLUMN nostr_event_created_at INTEGER"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- events.settings ---------------------------------------------
|
||||||
|
# Singleton settings row used by the admin UI to toggle e.g.
|
||||||
|
# auto_approve. CREATE TABLE IF NOT EXISTS + a guarded seed keeps
|
||||||
|
# this idempotent.
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS events.settings (
|
||||||
|
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||||
|
auto_approve BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO events.settings (id, auto_approve) "
|
||||||
|
"SELECT 1, FALSE WHERE NOT EXISTS "
|
||||||
|
"(SELECT 1 FROM events.settings WHERE id = 1)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- events.location, events.categories --------------------------
|
||||||
|
# NIP-52 calendar metadata. `categories` carries a JSON-encoded
|
||||||
|
# list of hashtags (the NIP-52 `t` tags).
|
||||||
|
await _alter_add_column_safe(
|
||||||
|
db, "ALTER TABLE events.events ADD COLUMN location TEXT"
|
||||||
|
)
|
||||||
|
await _alter_add_column_safe(
|
||||||
|
db, "ALTER TABLE events.events ADD COLUMN categories TEXT"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def m002_ticket_payment_hash(db):
|
||||||
|
"""
|
||||||
|
Add `ticket.payment_hash` for multi-ticket purchases.
|
||||||
|
|
||||||
|
Multi-ticket purchases land as N rows sharing one LNbits invoice
|
||||||
|
(so each attendee gets a distinct scannable QR but the buyer
|
||||||
|
pays once). `ticket.id` stays the row primary key — for legacy
|
||||||
|
single-purchase rows it equals payment_hash; for multi-purchase
|
||||||
|
children it's a uuid generated at create-time. `payment_hash`
|
||||||
|
is the new join key for invoice lookup.
|
||||||
|
|
||||||
|
Backfill existing rows from id so the
|
||||||
|
GET-tickets-by-payment-hash path keeps working for pre-migration
|
||||||
|
data (id was the payment_hash by invariant before this column).
|
||||||
|
"""
|
||||||
|
await _alter_add_column_safe(
|
||||||
|
db, "ALTER TABLE events.ticket ADD COLUMN payment_hash TEXT"
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE events.ticket SET payment_hash = id "
|
||||||
|
"WHERE payment_hash IS NULL OR payment_hash = ''"
|
||||||
|
)
|
||||||
|
|
||||||
199
models.py
199
models.py
|
|
@ -1,43 +1,194 @@
|
||||||
from fastapi import Query
|
import json
|
||||||
from pydantic import BaseModel
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, Field, root_validator, validator
|
||||||
|
|
||||||
|
|
||||||
|
class PromoCode(BaseModel):
|
||||||
|
code: str
|
||||||
|
discount_percent: float = 0.0
|
||||||
|
active: bool = True
|
||||||
|
|
||||||
|
# make the promo code uppercase
|
||||||
|
@validator("code")
|
||||||
|
def uppercase_code(cls, v):
|
||||||
|
return v.upper()
|
||||||
|
|
||||||
|
@validator("discount_percent")
|
||||||
|
def validate_discount_percent(cls, v):
|
||||||
|
assert 0 <= v <= 100, "Discount must be between 0 and 100."
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class EventExtra(BaseModel):
|
||||||
|
promo_codes: list[PromoCode] = Field(default_factory=list)
|
||||||
|
conditional: bool = False
|
||||||
|
min_tickets: int = 1
|
||||||
|
email_notifications: bool = False
|
||||||
|
nostr_notifications: bool = False
|
||||||
|
notification_subject: str = ""
|
||||||
|
notification_body: str = ""
|
||||||
|
|
||||||
|
|
||||||
class CreateEvent(BaseModel):
|
class CreateEvent(BaseModel):
|
||||||
wallet: str
|
wallet: str | None = None # filled from caller's wallet if absent
|
||||||
name: str
|
name: str # title (required)
|
||||||
info: str
|
info: str = "" # description (optional)
|
||||||
closing_date: str
|
closing_date: str | None = None # date-only YYYY-MM-DD; defaults to event_end_date
|
||||||
|
# ISO 8601: date-only ("2026-05-19") or datetime ("2026-05-19T18:30").
|
||||||
|
# Presence of a "T" toggles NIP-52 kind (31922 date / 31923 time).
|
||||||
event_start_date: str
|
event_start_date: str
|
||||||
event_end_date: str
|
event_end_date: str | None = None # same format as event_start_date
|
||||||
amount_tickets: int = Query(..., ge=0)
|
currency: str = "sat"
|
||||||
price_per_ticket: int = Query(..., ge=0)
|
allow_fiat: bool = False
|
||||||
|
fiat_currency: str = "GBP"
|
||||||
|
amount_tickets: int = 0 # 0 = unlimited / not ticketed
|
||||||
class CreateTicket(BaseModel):
|
price_per_ticket: float = 0 # 0 = free
|
||||||
name: str
|
banner: str | None = None
|
||||||
email: str
|
location: str | None = None # venue/address (NIP-52 'location' tag)
|
||||||
|
categories: list[str] = Field(default_factory=list) # NIP-52 't' tags
|
||||||
|
extra: EventExtra = Field(default_factory=EventExtra)
|
||||||
|
status: str = "approved" # proposed, approved, rejected
|
||||||
|
|
||||||
|
|
||||||
class Event(BaseModel):
|
class Event(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
wallet: str
|
wallet: str
|
||||||
name: str
|
name: str
|
||||||
info: str
|
info: str = ""
|
||||||
closing_date: str
|
closing_date: str | None = None
|
||||||
|
canceled: bool = False
|
||||||
event_start_date: str
|
event_start_date: str
|
||||||
event_end_date: str
|
event_end_date: str | None = None
|
||||||
amount_tickets: int
|
currency: str = "sat"
|
||||||
price_per_ticket: int
|
allow_fiat: bool = False
|
||||||
sold: int
|
fiat_currency: str = "GBP"
|
||||||
time: int
|
amount_tickets: int = 0
|
||||||
|
price_per_ticket: float = 0
|
||||||
|
time: datetime
|
||||||
|
sold: int = 0
|
||||||
|
banner: str | None = None
|
||||||
|
location: str | None = None
|
||||||
|
categories: list[str] = Field(default_factory=list)
|
||||||
|
extra: EventExtra = Field(default_factory=EventExtra)
|
||||||
|
status: str = "approved"
|
||||||
|
nostr_event_id: str | None = None
|
||||||
|
nostr_event_created_at: int | None = None
|
||||||
|
|
||||||
|
@validator("categories", pre=True)
|
||||||
|
def parse_categories(cls, v):
|
||||||
|
if isinstance(v, str):
|
||||||
|
return json.loads(v) if v else []
|
||||||
|
return v or []
|
||||||
|
|
||||||
|
|
||||||
|
class PublicEvent(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
info: str
|
||||||
|
closing_date: str | None = None
|
||||||
|
canceled: bool
|
||||||
|
event_start_date: str
|
||||||
|
event_end_date: str | None = None
|
||||||
|
currency: str
|
||||||
|
allow_fiat: bool = False
|
||||||
|
fiat_currency: str = "GBP"
|
||||||
|
price_per_ticket: float
|
||||||
|
banner: str | None
|
||||||
|
location: str | None = None
|
||||||
|
categories: list[str] = Field(default_factory=list)
|
||||||
|
extra: EventExtra = Field(default_factory=EventExtra)
|
||||||
|
status: str = "approved" # surfaces "proposed"/"rejected" so SFC can render banner
|
||||||
|
|
||||||
|
@validator("categories", pre=True)
|
||||||
|
def parse_categories(cls, v):
|
||||||
|
if isinstance(v, str):
|
||||||
|
return json.loads(v) if v else []
|
||||||
|
return v or []
|
||||||
|
|
||||||
|
|
||||||
|
class EventsSettings(BaseModel):
|
||||||
|
"""Extension-level settings for the events extension."""
|
||||||
|
|
||||||
|
auto_approve: bool = False # Skip approval workflow for non-admin users
|
||||||
|
|
||||||
|
|
||||||
|
class TicketExtra(BaseModel):
|
||||||
|
applied_promo_code: str | None = None
|
||||||
|
sats_paid: int | None = None
|
||||||
|
refund_address: str | None = None
|
||||||
|
nostr_identifier: str | None = None
|
||||||
|
ticket_base_url: str | None = None
|
||||||
|
email_notification_sent: bool = False
|
||||||
|
nostr_notification_sent: bool = False
|
||||||
|
refunded: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class CreateTicket(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
email: EmailStr | None = None
|
||||||
|
user_id: str | None = None # LNbits user id (alternative to name+email)
|
||||||
|
promo_code: str | None = None
|
||||||
|
refund_address: str | None = None
|
||||||
|
nostr_identifier: str | None = None
|
||||||
|
payment_method: str | None = None
|
||||||
|
fiat_provider: str | None = None
|
||||||
|
# Number of tickets to buy on this single invoice. Bounded so a
|
||||||
|
# bad client can't run away with the organizer's capacity.
|
||||||
|
quantity: int = Field(default=1, ge=1, le=10)
|
||||||
|
|
||||||
|
@root_validator
|
||||||
|
def validate_identifiers(cls, values):
|
||||||
|
name = values.get("name")
|
||||||
|
email = values.get("email")
|
||||||
|
user_id = values.get("user_id")
|
||||||
|
if not user_id and not (name and email):
|
||||||
|
raise ValueError("Either user_id or both name and email must be provided")
|
||||||
|
if user_id and (name or email):
|
||||||
|
raise ValueError("Cannot provide both user_id and name/email")
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
class Ticket(BaseModel):
|
class Ticket(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
wallet: str
|
wallet: str
|
||||||
event: str
|
event: str
|
||||||
name: str
|
name: str | None = None
|
||||||
email: str
|
email: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
registered: bool
|
registered: bool
|
||||||
paid: bool
|
paid: bool
|
||||||
time: int
|
time: datetime
|
||||||
|
reg_timestamp: datetime
|
||||||
|
extra: TicketExtra = Field(default_factory=TicketExtra)
|
||||||
|
# Shared LNbits invoice payment_hash. Equals `id` for single-ticket
|
||||||
|
# purchases (legacy + post-migration default). Multi-ticket
|
||||||
|
# purchases create N rows sharing one payment_hash so each attendee
|
||||||
|
# gets a distinct scannable id while the buyer pays once.
|
||||||
|
payment_hash: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PublicTicket(BaseModel):
|
||||||
|
event: str
|
||||||
|
name: str | None = None
|
||||||
|
registered: bool
|
||||||
|
paid: bool
|
||||||
|
time: datetime
|
||||||
|
reg_timestamp: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TicketPaymentRequest(BaseModel):
|
||||||
|
payment_hash: str
|
||||||
|
payment_request: str | None = None
|
||||||
|
fiat_payment_request: str | None = None
|
||||||
|
fiat_provider: str | None = None
|
||||||
|
is_fiat: bool = False
|
||||||
|
# True when the tickets are already issued + paid with no invoice to
|
||||||
|
# settle — free events (price 0) or a 100%-off promo. The client skips
|
||||||
|
# the QR / payment-poll step and goes straight to the ticket QRs.
|
||||||
|
paid: bool = False
|
||||||
|
# Row ids created on this invoice — one for single-ticket
|
||||||
|
# purchases, N for multi-ticket (each independently scannable at
|
||||||
|
# the door). Buyers fetch these after payment to render N QRs in
|
||||||
|
# My Tickets.
|
||||||
|
ticket_ids: list[str] = Field(default_factory=list)
|
||||||
|
|
|
||||||
0
nostr/__init__.py
Normal file
0
nostr/__init__.py
Normal file
26
nostr/event.py
Normal file
26
nostr/event.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class NostrEvent(BaseModel):
|
||||||
|
id: str = ""
|
||||||
|
pubkey: str
|
||||||
|
created_at: int
|
||||||
|
kind: int
|
||||||
|
tags: list[list[str]] = []
|
||||||
|
content: str = ""
|
||||||
|
sig: str | None = None
|
||||||
|
|
||||||
|
def serialize(self) -> list:
|
||||||
|
return [0, self.pubkey, self.created_at, self.kind, self.tags, self.content]
|
||||||
|
|
||||||
|
def serialize_json(self) -> str:
|
||||||
|
e = self.serialize()
|
||||||
|
return json.dumps(e, separators=(",", ":"), ensure_ascii=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def event_id(self) -> str:
|
||||||
|
data = self.serialize_json()
|
||||||
|
return hashlib.sha256(data.encode()).hexdigest()
|
||||||
135
nostr/nostr_client.py
Normal file
135
nostr/nostr_client.py
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
"""
|
||||||
|
Bidirectional Nostr client for the events extension.
|
||||||
|
|
||||||
|
Connects to the nostrclient extension's internal WebSocket to publish
|
||||||
|
and subscribe to NIP-52 calendar events. Based on nostrmarket's
|
||||||
|
NostrClient pattern.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from asyncio import Queue
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
from lnbits.helpers import encrypt_internal_message, urlsafe_short_hash
|
||||||
|
from lnbits.settings import settings
|
||||||
|
from loguru import logger
|
||||||
|
from websocket import WebSocketApp
|
||||||
|
|
||||||
|
from .event import NostrEvent
|
||||||
|
|
||||||
|
MAX_SEEN_EVENTS = 500
|
||||||
|
|
||||||
|
|
||||||
|
class NostrClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.receive_event_queue: Queue = Queue()
|
||||||
|
self.send_req_queue: Queue = Queue()
|
||||||
|
self.ws: WebSocketApp | None = None
|
||||||
|
self.subscription_id = "events-" + urlsafe_short_hash()[:32]
|
||||||
|
self.running = False
|
||||||
|
self._seen_events: OrderedDict[str, None] = OrderedDict()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_websocket_connected(self):
|
||||||
|
if not self.ws:
|
||||||
|
return False
|
||||||
|
return self.ws.keep_running
|
||||||
|
|
||||||
|
async def connect(self) -> WebSocketApp:
|
||||||
|
relay_endpoint = encrypt_internal_message("relay", urlsafe=True)
|
||||||
|
ws_url = (
|
||||||
|
f"ws://localhost:{settings.port}" f"/nostrclient/api/v1/{relay_endpoint}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("[EVENTS] Connecting to nostrclient WebSocket...")
|
||||||
|
|
||||||
|
def on_open(_):
|
||||||
|
logger.info("[EVENTS] Connected to nostrclient WebSocket")
|
||||||
|
|
||||||
|
def on_message(_, message):
|
||||||
|
try:
|
||||||
|
self.receive_event_queue.put_nowait(message)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[EVENTS] Failed to queue message: {e}")
|
||||||
|
|
||||||
|
def on_error(_, error):
|
||||||
|
logger.warning(f"[EVENTS] WebSocket error: {error}")
|
||||||
|
|
||||||
|
def on_close(_, status_code, message):
|
||||||
|
logger.warning(f"[EVENTS] WebSocket closed: {status_code} {message}")
|
||||||
|
self.receive_event_queue.put_nowait(ValueError("WebSocket closed"))
|
||||||
|
|
||||||
|
ws = WebSocketApp(
|
||||||
|
ws_url,
|
||||||
|
on_message=on_message,
|
||||||
|
on_open=on_open,
|
||||||
|
on_close=on_close,
|
||||||
|
on_error=on_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
from threading import Thread
|
||||||
|
|
||||||
|
wst = Thread(target=ws.run_forever)
|
||||||
|
wst.daemon = True
|
||||||
|
wst.start()
|
||||||
|
|
||||||
|
return ws
|
||||||
|
|
||||||
|
async def run_forever(self):
|
||||||
|
self.running = True
|
||||||
|
while self.running:
|
||||||
|
try:
|
||||||
|
if not self.is_websocket_connected:
|
||||||
|
self.ws = await self.connect()
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
req = await self.send_req_queue.get()
|
||||||
|
assert self.ws
|
||||||
|
self.ws.send(json.dumps(req))
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning(f"[EVENTS] NostrClient error: {ex}")
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
|
def is_duplicate_event(self, event_id: str) -> bool:
|
||||||
|
"""Check if an event has been seen recently."""
|
||||||
|
if event_id in self._seen_events:
|
||||||
|
return True
|
||||||
|
self._seen_events[event_id] = None
|
||||||
|
if len(self._seen_events) > MAX_SEEN_EVENTS:
|
||||||
|
self._seen_events.popitem(last=False)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_event(self):
|
||||||
|
"""Get next event from the receive queue."""
|
||||||
|
value = await self.receive_event_queue.get()
|
||||||
|
if isinstance(value, ValueError):
|
||||||
|
raise value
|
||||||
|
return value
|
||||||
|
|
||||||
|
async def publish_nostr_event(self, e: NostrEvent):
|
||||||
|
await self.send_req_queue.put(["EVENT", e.dict()])
|
||||||
|
|
||||||
|
async def subscribe(self, filters: list[dict]):
|
||||||
|
"""Subscribe to events matching the given filters."""
|
||||||
|
self.subscription_id = "events-" + urlsafe_short_hash()[:32]
|
||||||
|
await self.send_req_queue.put(["REQ", self.subscription_id, *filters])
|
||||||
|
logger.info(
|
||||||
|
f"[EVENTS] Subscribed to NIP-52 events "
|
||||||
|
f"(sub: {self.subscription_id[:20]}...)"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def unsubscribe(self):
|
||||||
|
"""Unsubscribe from current subscription."""
|
||||||
|
await self.send_req_queue.put(["CLOSE", self.subscription_id])
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
await self.unsubscribe()
|
||||||
|
self.running = False
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
if self.ws:
|
||||||
|
try:
|
||||||
|
self.ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.ws = None
|
||||||
48
nostr_hooks.py
Normal file
48
nostr_hooks.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"""Helpers that bridge event-mutation handlers to the Nostr publisher.
|
||||||
|
|
||||||
|
Lives in its own module so both `events_api_router` and any future router
|
||||||
|
can call it without importing through `views_api`, which would create an
|
||||||
|
import cycle (views_api -> nostr_hooks -> nostr_publisher -> models).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from .crud import update_event
|
||||||
|
from .models import Event
|
||||||
|
from .nostr_publisher import publish_event_to_nostr
|
||||||
|
|
||||||
|
|
||||||
|
async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -> None:
|
||||||
|
"""Publish or delete the NIP-52 calendar event for `event`.
|
||||||
|
|
||||||
|
Resolves a `NostrSigner` for the wallet owner — backend-agnostic
|
||||||
|
(LocalSigner / RemoteBunkerSigner / ClientSideOnlySigner). The
|
||||||
|
signer abstraction handles the actual key material; this hook
|
||||||
|
only needs `signer.pubkey` for event construction and
|
||||||
|
`await signer.sign_event(...)` for signing. Failures are logged
|
||||||
|
and swallowed so a Nostr outage doesn't break the HTTP flow that
|
||||||
|
triggered the publish.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from lnbits.core.signers import resolve_for_wallet
|
||||||
|
|
||||||
|
from . import nostr_client
|
||||||
|
|
||||||
|
signer = await resolve_for_wallet(event.wallet)
|
||||||
|
if signer is None:
|
||||||
|
# Wallet missing, account missing, unclassified row, or
|
||||||
|
# ClientSideOnlySigner account (server can't sign for them).
|
||||||
|
# Soft-fail: skip the publish silently. The user can still
|
||||||
|
# publish kind-31922/31923 events client-side once we have
|
||||||
|
# that path.
|
||||||
|
return
|
||||||
|
|
||||||
|
nostr_event = await publish_event_to_nostr(
|
||||||
|
nostr_client, event, signer, delete=delete
|
||||||
|
)
|
||||||
|
if nostr_event and not delete:
|
||||||
|
event.nostr_event_id = nostr_event.id
|
||||||
|
event.nostr_event_created_at = nostr_event.created_at
|
||||||
|
await update_event(event)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"[EVENTS] Nostr publish failed: {exc}")
|
||||||
204
nostr_publisher.py
Normal file
204
nostr_publisher.py
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
"""
|
||||||
|
NIP-52 calendar event publishing for the events extension.
|
||||||
|
|
||||||
|
Builds NIP-52 calendar events from the Event model, signs them via the
|
||||||
|
core `NostrSigner` abstraction (backend-agnostic: LocalSigner,
|
||||||
|
RemoteBunkerSigner, etc.), and publishes via the NostrClient.
|
||||||
|
|
||||||
|
Kind 31922 is used for date-only events; kind 31923 (time-based) is used
|
||||||
|
when event_start_date / event_end_date include a time component.
|
||||||
|
|
||||||
|
Reference: https://github.com/nostr-protocol/nips/blob/master/52.md
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from lnbits.core.signers import NostrSigner
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from .models import Event
|
||||||
|
from .nostr.event import NostrEvent
|
||||||
|
from .nostr_timestamp import monotonic_created_at
|
||||||
|
|
||||||
|
|
||||||
|
def _has_time(value: str | None) -> bool:
|
||||||
|
"""ISO 8601 datetime strings contain a 'T' between date and time."""
|
||||||
|
return value is not None and "T" in value
|
||||||
|
|
||||||
|
|
||||||
|
def _to_unix(value: str) -> int:
|
||||||
|
"""Parse ISO 8601 datetime (assume UTC if naive) to unix seconds."""
|
||||||
|
dt = datetime.fromisoformat(value)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return int(dt.timestamp())
|
||||||
|
|
||||||
|
|
||||||
|
def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
|
||||||
|
"""
|
||||||
|
Convert an Event model to a NIP-52 calendar event.
|
||||||
|
|
||||||
|
Time-based (kind 31923) if event_start_date carries an HH:MM, otherwise
|
||||||
|
date-based (kind 31922). Tags:
|
||||||
|
d - event.id
|
||||||
|
title - event.name
|
||||||
|
start - unix timestamp (31923) or YYYY-MM-DD (31922)
|
||||||
|
end - same encoding (optional)
|
||||||
|
image, location, t (categories) - optional
|
||||||
|
tickets_available - current remaining capacity (omitted when unlimited)
|
||||||
|
tickets_sold - running paid-count (always emitted; clients can
|
||||||
|
derive original_capacity = available + sold)
|
||||||
|
tickets_price - price_per_ticket (always emitted; 0 means free)
|
||||||
|
tickets_currency - the currency string
|
||||||
|
tickets_allow_fiat - "true" when fiat checkout is enabled (omitted otherwise)
|
||||||
|
tickets_fiat_currency - the fiat settle currency (only when allow_fiat)
|
||||||
|
Content: event.info
|
||||||
|
|
||||||
|
The four ticket_* tags are AIO custom additions outside the NIP-52
|
||||||
|
spec; spec-compliant clients ignore unknown tags so this stays
|
||||||
|
backwards-compatible. They let connected clients render the
|
||||||
|
"X tickets remaining" badge and the Buy CTA without an extra REST hop,
|
||||||
|
and pick up live inventory updates via the same relay subscription.
|
||||||
|
"""
|
||||||
|
time_based = _has_time(event.event_start_date)
|
||||||
|
kind = 31923 if time_based else 31922
|
||||||
|
start_value = (
|
||||||
|
str(_to_unix(event.event_start_date)) if time_based else event.event_start_date
|
||||||
|
)
|
||||||
|
|
||||||
|
tags = [
|
||||||
|
["d", event.id],
|
||||||
|
["title", event.name],
|
||||||
|
["start", start_value],
|
||||||
|
]
|
||||||
|
|
||||||
|
end_unix: int | None = None
|
||||||
|
if event.event_end_date:
|
||||||
|
end_value = (
|
||||||
|
str(_to_unix(event.event_end_date)) if time_based else event.event_end_date
|
||||||
|
)
|
||||||
|
tags.append(["end", end_value])
|
||||||
|
if time_based:
|
||||||
|
end_unix = _to_unix(event.event_end_date)
|
||||||
|
|
||||||
|
if time_based:
|
||||||
|
start_unix = _to_unix(event.event_start_date)
|
||||||
|
start_day = start_unix // 86400
|
||||||
|
end_day = (end_unix // 86400) if end_unix is not None else start_day
|
||||||
|
for day in range(start_day, end_day + 1):
|
||||||
|
tags.append(["D", str(day)])
|
||||||
|
|
||||||
|
if event.banner:
|
||||||
|
tags.append(["image", event.banner])
|
||||||
|
if event.location:
|
||||||
|
tags.append(["location", event.location])
|
||||||
|
for cat in event.categories or []:
|
||||||
|
tags.append(["t", cat])
|
||||||
|
|
||||||
|
# `amount_tickets == 0` means unlimited capacity in this extension's
|
||||||
|
# schema. Omitting the tag is how clients distinguish unlimited from
|
||||||
|
# "0 left" (sold out).
|
||||||
|
if event.amount_tickets > 0:
|
||||||
|
tags.append(["tickets_available", str(event.amount_tickets)])
|
||||||
|
tags.append(["tickets_sold", str(event.sold)])
|
||||||
|
tags.append(["tickets_price", str(event.price_per_ticket)])
|
||||||
|
tags.append(["tickets_currency", event.currency])
|
||||||
|
# Fiat-checkout config — only emitted when allow_fiat is on so
|
||||||
|
# clients can branch the buy UI without re-reading the schema.
|
||||||
|
if event.allow_fiat:
|
||||||
|
tags.append(["tickets_allow_fiat", "true"])
|
||||||
|
if event.fiat_currency:
|
||||||
|
tags.append(["tickets_fiat_currency", event.fiat_currency])
|
||||||
|
|
||||||
|
# NIP-52 calendar events are replaceable: this d-tag is republished
|
||||||
|
# whenever inventory changes (a ticket sells). Use a strictly-monotonic
|
||||||
|
# created_at anchored on the last published value so a same-second
|
||||||
|
# republish still outranks the prior version and relays push it to open
|
||||||
|
# subscriptions — a bare int(time.time()) can tie and be silently
|
||||||
|
# dropped, stalling clients' live "tickets remaining" badge.
|
||||||
|
nostr_event = NostrEvent(
|
||||||
|
pubkey=pubkey,
|
||||||
|
created_at=monotonic_created_at(event.nostr_event_created_at),
|
||||||
|
kind=kind,
|
||||||
|
tags=tags,
|
||||||
|
content=event.info or "",
|
||||||
|
)
|
||||||
|
nostr_event.id = nostr_event.event_id
|
||||||
|
return nostr_event
|
||||||
|
|
||||||
|
|
||||||
|
def build_nip52_delete_event(event: Event, pubkey: str) -> NostrEvent:
|
||||||
|
"""
|
||||||
|
Build a kind 5 delete event for a published NIP-52 calendar event.
|
||||||
|
|
||||||
|
Uses an 'a' tag to reference the parameterized replaceable event per
|
||||||
|
NIP-09. The referenced kind must match what we published — 31923 for
|
||||||
|
time-based events, 31922 for date-only.
|
||||||
|
"""
|
||||||
|
referenced_kind = 31923 if _has_time(event.event_start_date) else 31922
|
||||||
|
nostr_event = NostrEvent(
|
||||||
|
pubkey=pubkey,
|
||||||
|
created_at=int(time.time()),
|
||||||
|
kind=5,
|
||||||
|
tags=[
|
||||||
|
["a", f"{referenced_kind}:{pubkey}:{event.id}"],
|
||||||
|
],
|
||||||
|
content="Event canceled",
|
||||||
|
)
|
||||||
|
nostr_event.id = nostr_event.event_id
|
||||||
|
return nostr_event
|
||||||
|
|
||||||
|
|
||||||
|
async def publish_event_to_nostr(
|
||||||
|
nostr_client,
|
||||||
|
event: Event,
|
||||||
|
signer: NostrSigner,
|
||||||
|
delete: bool = False,
|
||||||
|
) -> NostrEvent | None:
|
||||||
|
"""
|
||||||
|
Build, sign, and publish a NIP-52 calendar event (or delete event).
|
||||||
|
|
||||||
|
Signing routes through the core `NostrSigner` abstraction —
|
||||||
|
`signer.pubkey` for the event identity, `await signer.sign_event(...)`
|
||||||
|
for the Schnorr signature. The signer backend (LocalSigner /
|
||||||
|
RemoteBunkerSigner) is transparent to this function.
|
||||||
|
|
||||||
|
Returns the published NostrEvent for metadata storage, or None on failure.
|
||||||
|
"""
|
||||||
|
if not nostr_client:
|
||||||
|
logger.debug("[EVENTS] No NostrClient available, skipping publish")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if delete:
|
||||||
|
nostr_event = build_nip52_delete_event(event, signer.pubkey)
|
||||||
|
else:
|
||||||
|
nostr_event = build_nip52_event(event, signer.pubkey)
|
||||||
|
|
||||||
|
# Hand the unsigned event to the signer — it fills in `id`,
|
||||||
|
# `pubkey`, and `sig`. The signer's serialization rules match
|
||||||
|
# NIP-01 (same as the local `event_id` property uses), so the
|
||||||
|
# returned id matches what we'd have computed locally.
|
||||||
|
unsigned = {
|
||||||
|
"kind": nostr_event.kind,
|
||||||
|
"created_at": nostr_event.created_at,
|
||||||
|
"tags": nostr_event.tags,
|
||||||
|
"content": nostr_event.content,
|
||||||
|
}
|
||||||
|
signed = await signer.sign_event(unsigned)
|
||||||
|
nostr_event.id = signed["id"]
|
||||||
|
nostr_event.pubkey = signed["pubkey"]
|
||||||
|
nostr_event.sig = signed["sig"]
|
||||||
|
|
||||||
|
await nostr_client.publish_nostr_event(nostr_event)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[EVENTS] Published NIP-52 {'delete' if delete else 'calendar'} "
|
||||||
|
f"event: {nostr_event.id[:16]}... (kind {nostr_event.kind})"
|
||||||
|
)
|
||||||
|
return nostr_event
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[EVENTS] Failed to publish to Nostr: {e}")
|
||||||
|
return None
|
||||||
157
nostr_sync.py
Normal file
157
nostr_sync.py
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
"""
|
||||||
|
Bidirectional Nostr sync for the events extension.
|
||||||
|
|
||||||
|
Subscribes to NIP-52 calendar events (kind 31922/31923) from relays
|
||||||
|
and upserts them into the local database. Enables federated event
|
||||||
|
discovery — events published by other LNbits instances or Nostr
|
||||||
|
clients appear in the local events listing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from .crud import db, get_event, update_event
|
||||||
|
from .models import Event
|
||||||
|
from .nostr.nostr_client import NostrClient
|
||||||
|
|
||||||
|
|
||||||
|
async def process_nostr_message(nostr_client: NostrClient, message: str):
|
||||||
|
"""Process an incoming Nostr relay message."""
|
||||||
|
try:
|
||||||
|
data = json.loads(message)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not isinstance(data, list) or len(data) < 2:
|
||||||
|
return
|
||||||
|
|
||||||
|
msg_type = data[0]
|
||||||
|
|
||||||
|
if msg_type == "EVENT" and len(data) >= 3:
|
||||||
|
event_data = data[2]
|
||||||
|
await _handle_calendar_event(nostr_client, event_data)
|
||||||
|
elif msg_type == "EOSE":
|
||||||
|
logger.debug("[EVENTS] End of stored events from relay")
|
||||||
|
elif msg_type == "NOTICE":
|
||||||
|
logger.info(f"[EVENTS] Relay notice: {data[1]}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_calendar_event(nostr_client: NostrClient, event_data: dict):
|
||||||
|
"""Handle an incoming NIP-52 calendar event (kind 31922 or 31923)."""
|
||||||
|
kind = event_data.get("kind")
|
||||||
|
if kind not in (31922, 31923):
|
||||||
|
return
|
||||||
|
|
||||||
|
event_id = event_data.get("id", "")
|
||||||
|
if nostr_client.is_duplicate_event(event_id):
|
||||||
|
return
|
||||||
|
|
||||||
|
tags = {t[0]: t[1] for t in event_data.get("tags", []) if len(t) >= 2}
|
||||||
|
tag_lists = {}
|
||||||
|
for t in event_data.get("tags", []):
|
||||||
|
if len(t) >= 2:
|
||||||
|
tag_lists.setdefault(t[0], []).append(t[1])
|
||||||
|
|
||||||
|
d_tag = tags.get("d")
|
||||||
|
if not d_tag:
|
||||||
|
return
|
||||||
|
|
||||||
|
title = tags.get("title", "Untitled Event")
|
||||||
|
start = tags.get("start")
|
||||||
|
if not start:
|
||||||
|
return
|
||||||
|
|
||||||
|
end = tags.get("end")
|
||||||
|
description = event_data.get("content", "")
|
||||||
|
image = tags.get("image")
|
||||||
|
location = tags.get("location")
|
||||||
|
categories = tag_lists.get("t", [])
|
||||||
|
|
||||||
|
# Check if we already have this event (by d-tag as our event ID
|
||||||
|
# or by nostr_event_id)
|
||||||
|
existing = await get_event(d_tag)
|
||||||
|
if not existing:
|
||||||
|
# Check by nostr_event_id
|
||||||
|
existing = await db.fetchone(
|
||||||
|
"SELECT * FROM events.events WHERE nostr_event_id = :nid",
|
||||||
|
{"nid": event_id},
|
||||||
|
Event,
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Update if the incoming event is newer
|
||||||
|
incoming_created_at = event_data.get("created_at", 0)
|
||||||
|
if (
|
||||||
|
existing.nostr_event_created_at
|
||||||
|
and incoming_created_at <= existing.nostr_event_created_at
|
||||||
|
):
|
||||||
|
return # We already have a newer version
|
||||||
|
|
||||||
|
existing.name = title
|
||||||
|
existing.info = description
|
||||||
|
existing.event_start_date = start
|
||||||
|
existing.event_end_date = end
|
||||||
|
existing.banner = image
|
||||||
|
existing.location = location
|
||||||
|
existing.categories = categories
|
||||||
|
existing.nostr_event_id = event_id
|
||||||
|
existing.nostr_event_created_at = incoming_created_at
|
||||||
|
await update_event(existing)
|
||||||
|
logger.info(f"[EVENTS] Updated event from Nostr: {title}")
|
||||||
|
else:
|
||||||
|
# Create new event from Nostr — discovered events are auto-approved
|
||||||
|
# (they're already public on relays). Use the d-tag as the event ID
|
||||||
|
# for replaceable-event correlation.
|
||||||
|
new_event = Event(
|
||||||
|
id=d_tag,
|
||||||
|
wallet="",
|
||||||
|
name=title,
|
||||||
|
info=description,
|
||||||
|
event_start_date=start,
|
||||||
|
event_end_date=end,
|
||||||
|
banner=image,
|
||||||
|
location=location,
|
||||||
|
categories=categories,
|
||||||
|
status="approved",
|
||||||
|
time=datetime.now(timezone.utc),
|
||||||
|
nostr_event_id=event_id,
|
||||||
|
nostr_event_created_at=event_data.get("created_at", 0),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await db.insert("events.events", new_event)
|
||||||
|
logger.info(f"[EVENTS] Discovered event from Nostr: {title}")
|
||||||
|
except Exception as e:
|
||||||
|
# Likely duplicate key — skip
|
||||||
|
logger.debug(f"[EVENTS] Skipped duplicate event: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_nostr_events(nostr_client: NostrClient):
|
||||||
|
"""
|
||||||
|
Background task: subscribe to NIP-52 events and process them.
|
||||||
|
"""
|
||||||
|
logger.info("[EVENTS] Starting Nostr event sync...")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# Subscribe to NIP-52 calendar events
|
||||||
|
await nostr_client.subscribe(
|
||||||
|
[
|
||||||
|
{"kinds": [31922, 31923]},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process incoming events
|
||||||
|
while True:
|
||||||
|
message = await nostr_client.get_event()
|
||||||
|
await process_nostr_message(nostr_client, message)
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
# WebSocket closed — will reconnect
|
||||||
|
logger.warning("[EVENTS] Nostr connection lost, resubscribing...")
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[EVENTS] Nostr sync error: {e}")
|
||||||
|
await asyncio.sleep(30)
|
||||||
34
nostr_timestamp.py
Normal file
34
nostr_timestamp.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
"""Monotonic ``created_at`` for replaceable / addressable Nostr events.
|
||||||
|
|
||||||
|
Relays only push a replaceable update to OPEN subscriptions when its
|
||||||
|
``created_at`` is strictly newer than the version they already hold.
|
||||||
|
``created_at`` is integer seconds, so a publisher that stamps
|
||||||
|
``int(time.time())`` can emit two versions within the same wall-clock
|
||||||
|
second (e.g. two ticket sales republishing the NIP-52 calendar event) —
|
||||||
|
the relay treats the second as not-newer and never propagates it to live
|
||||||
|
subscribers (it only surfaces on a reload / fresh REQ).
|
||||||
|
|
||||||
|
Returning ``max(now, last_created_at + 1)`` guarantees a strictly
|
||||||
|
increasing timestamp across successive publishes of the same replaceable
|
||||||
|
event. When enough real seconds have elapsed it tracks wall-clock; only
|
||||||
|
same-second (or clock-skewed) republishes get nudged forward.
|
||||||
|
|
||||||
|
Mirrors the webapp's ``monotonicCreatedAt`` (src/lib/nostr/timestamp.ts)
|
||||||
|
and ``docs/nostr-patterns/replaceable-events.md``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def monotonic_created_at(last_created_at: int | None, now: int | None = None) -> int:
|
||||||
|
"""Strictly-newer ``created_at`` for the next publish of a coord.
|
||||||
|
|
||||||
|
:param last_created_at: ``created_at`` of the previously published
|
||||||
|
version (seconds), or ``None`` if none has been published yet.
|
||||||
|
:param now: Current time in seconds — injectable for tests; defaults
|
||||||
|
to ``int(time.time())``.
|
||||||
|
"""
|
||||||
|
base = int(time.time()) if now is None else now
|
||||||
|
if last_created_at is None:
|
||||||
|
return base
|
||||||
|
return max(base, last_created_at + 1)
|
||||||
59
package-lock.json
generated
Normal file
59
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
{
|
||||||
|
"name": "events",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "events",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"prettier": "^3.2.5",
|
||||||
|
"pyright": "^1.1.358"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prettier": {
|
||||||
|
"version": "3.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
|
||||||
|
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
|
||||||
|
"bin": {
|
||||||
|
"prettier": "bin/prettier.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pyright": {
|
||||||
|
"version": "1.1.374",
|
||||||
|
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.374.tgz",
|
||||||
|
"integrity": "sha512-ISbC1YnYDYrEatoKKjfaA5uFIp0ddC/xw9aSlN/EkmwupXUMVn41Jl+G6wHEjRhC+n4abHZeGpEvxCUus/K9dA==",
|
||||||
|
"bin": {
|
||||||
|
"pyright": "index.js",
|
||||||
|
"pyright-langserver": "langserver.index.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
package.json
Normal file
15
package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"name": "events",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"prettier": "^3.2.5",
|
||||||
|
"pyright": "^1.1.358"
|
||||||
|
}
|
||||||
|
}
|
||||||
90
pyproject.toml
Normal file
90
pyproject.toml
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
[project]
|
||||||
|
name = "lnbits-events"
|
||||||
|
version = "0.0.0"
|
||||||
|
requires-python = ">=3.10,<3.13"
|
||||||
|
description = "LNbits, free and open-source Lightning wallet and accounts system."
|
||||||
|
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
|
||||||
|
urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/events" }
|
||||||
|
dependencies = [ "lnbits>1" ]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"black",
|
||||||
|
"pytest-asyncio",
|
||||||
|
"pytest",
|
||||||
|
"mypy",
|
||||||
|
"pre-commit",
|
||||||
|
"ruff",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.poetry]
|
||||||
|
package-mode = false
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
exclude = "(nostr/*)"
|
||||||
|
plugins = ["pydantic.mypy"]
|
||||||
|
|
||||||
|
[tool.pydantic-mypy]
|
||||||
|
init_forbid_extra = true
|
||||||
|
init_typed = true
|
||||||
|
warn_required_dynamic_aliases = true
|
||||||
|
warn_untyped_fields = true
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
log_cli = false
|
||||||
|
testpaths = [
|
||||||
|
"tests"
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 88
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
# Same as Black. + 10% rule of black
|
||||||
|
line-length = 88
|
||||||
|
exclude = [
|
||||||
|
"nostr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
# Enable:
|
||||||
|
# F - pyflakes
|
||||||
|
# E - pycodestyle errors
|
||||||
|
# W - pycodestyle warnings
|
||||||
|
# I - isort
|
||||||
|
# A - flake8-builtins
|
||||||
|
# C - mccabe
|
||||||
|
# N - naming
|
||||||
|
# UP - pyupgrade
|
||||||
|
# RUF - ruff
|
||||||
|
# B - bugbear
|
||||||
|
select = ["F", "E", "W", "I", "A", "C", "N", "UP", "RUF", "B"]
|
||||||
|
ignore = ["C901"]
|
||||||
|
|
||||||
|
# Allow autofix for all enabled rules (when `--fix`) is provided.
|
||||||
|
fixable = ["ALL"]
|
||||||
|
unfixable = []
|
||||||
|
|
||||||
|
# Allow unused variables when underscore-prefixed.
|
||||||
|
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
||||||
|
|
||||||
|
# needed for pydantic
|
||||||
|
[tool.ruff.lint.pep8-naming]
|
||||||
|
classmethod-decorators = [
|
||||||
|
"validator",
|
||||||
|
"root_validator",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Ignore unused imports in __init__.py files.
|
||||||
|
# [tool.ruff.lint.extend-per-file-ignores]
|
||||||
|
# "__init__.py" = ["F401", "F403"]
|
||||||
|
|
||||||
|
# [tool.ruff.lint.mccabe]
|
||||||
|
# max-complexity = 10
|
||||||
|
|
||||||
|
[tool.ruff.lint.flake8-bugbear]
|
||||||
|
# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`.
|
||||||
|
extend-immutable-calls = [
|
||||||
|
"fastapi.Depends",
|
||||||
|
"fastapi.Query",
|
||||||
|
]
|
||||||
188
services.py
Normal file
188
services.py
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from asyncio.tasks import create_task
|
||||||
|
|
||||||
|
from lnbits.core.models.users import UserNotifications
|
||||||
|
from lnbits.core.services.nostr import send_nostr_dm
|
||||||
|
from lnbits.core.services.notifications import (
|
||||||
|
send_email_notification,
|
||||||
|
send_user_notification,
|
||||||
|
)
|
||||||
|
from lnbits.settings import settings
|
||||||
|
from lnbits.utils.nostr import normalize_private_key, normalize_public_key
|
||||||
|
from lnurl import execute
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from .crud import (
|
||||||
|
get_event,
|
||||||
|
get_event_tickets,
|
||||||
|
purge_unpaid_tickets,
|
||||||
|
update_event,
|
||||||
|
update_ticket,
|
||||||
|
)
|
||||||
|
from .models import Event, Ticket
|
||||||
|
from .nostr_hooks import publish_or_delete_nostr_event
|
||||||
|
|
||||||
|
DEFAULT_NOSTR_RELAYS = [
|
||||||
|
"wss://relay.damus.io",
|
||||||
|
"wss://relay.primal.net",
|
||||||
|
"wss://relay.nostr.band",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Per-event lock: serializes the counter-update + Nostr republish for a
|
||||||
|
# single event_id so two paid invoices landing on the listener queue back-
|
||||||
|
# to-back can't reorder the published state. Lazy-populated; entries are
|
||||||
|
# left in memory for the lifetime of the process (cheap — one asyncio.Lock
|
||||||
|
# object per event ever sold).
|
||||||
|
_event_paid_locks: dict[str, asyncio.Lock] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _event_paid_lock(event_id: str) -> asyncio.Lock:
|
||||||
|
lock = _event_paid_locks.get(event_id)
|
||||||
|
if lock is None:
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
_event_paid_locks[event_id] = lock
|
||||||
|
return lock
|
||||||
|
|
||||||
|
|
||||||
|
async def set_ticket_paid(ticket: Ticket) -> Ticket:
|
||||||
|
if ticket.paid:
|
||||||
|
return ticket
|
||||||
|
|
||||||
|
async with _event_paid_lock(ticket.event):
|
||||||
|
ticket.paid = True
|
||||||
|
await update_ticket(ticket)
|
||||||
|
|
||||||
|
event = await get_event(ticket.event)
|
||||||
|
assert event, "Couldn't get event from ticket being paid"
|
||||||
|
event.sold += 1
|
||||||
|
event.amount_tickets -= 1
|
||||||
|
await update_event(event)
|
||||||
|
|
||||||
|
# Republish the NIP-52 calendar event so connected clients see
|
||||||
|
# the new tickets_available / tickets_sold counters via their
|
||||||
|
# existing relay subscription. Failures are logged + swallowed
|
||||||
|
# inside publish_or_delete_nostr_event so a Nostr outage doesn't
|
||||||
|
# break the payment flow.
|
||||||
|
await publish_or_delete_nostr_event(event)
|
||||||
|
|
||||||
|
return ticket
|
||||||
|
|
||||||
|
|
||||||
|
def send_ticket_notification_in_background(ticket: Ticket) -> None:
|
||||||
|
create_task(_send_ticket_notification(ticket))
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_ticket_notification(ticket: Ticket) -> None:
|
||||||
|
event = await get_event(ticket.event)
|
||||||
|
if not event:
|
||||||
|
logger.warning(f"Event {ticket.event} not found for ticket notification.")
|
||||||
|
return
|
||||||
|
|
||||||
|
subject, message = _ticket_notification_message(ticket, event)
|
||||||
|
updated = False
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.extra.email_notifications
|
||||||
|
and settings.lnbits_email_notifications_enabled
|
||||||
|
and ticket.email
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
await send_email_notification([ticket.email], message, subject)
|
||||||
|
ticket.extra.email_notification_sent = True
|
||||||
|
updated = True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"Failed to email ticket {ticket.id}: {exc}")
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.extra.nostr_notifications
|
||||||
|
and settings.is_nostr_notifications_configured()
|
||||||
|
and ticket.extra.nostr_identifier
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
await _send_nostr_ticket_notification(
|
||||||
|
ticket.extra.nostr_identifier, message
|
||||||
|
)
|
||||||
|
ticket.extra.nostr_notification_sent = True
|
||||||
|
updated = True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"Failed to send nostr DM for ticket {ticket.id}: {exc}")
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
await update_ticket(ticket)
|
||||||
|
|
||||||
|
|
||||||
|
async def resend_ticket_email_notification(ticket: Ticket) -> Ticket:
|
||||||
|
event = await get_event(ticket.event)
|
||||||
|
if not event:
|
||||||
|
raise ValueError("Event does not exist.")
|
||||||
|
if not settings.lnbits_email_notifications_enabled:
|
||||||
|
raise ValueError("Email notifications are not enabled.")
|
||||||
|
if not ticket.email:
|
||||||
|
raise ValueError("Ticket does not have an email address.")
|
||||||
|
|
||||||
|
subject, message = _ticket_notification_message(ticket, event)
|
||||||
|
await send_email_notification([ticket.email], message, subject)
|
||||||
|
ticket.extra.email_notification_sent = True
|
||||||
|
return await update_ticket(ticket)
|
||||||
|
|
||||||
|
|
||||||
|
def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str]:
|
||||||
|
ticket_url = _ticket_url(ticket)
|
||||||
|
subject = (
|
||||||
|
event.extra.notification_subject.strip()
|
||||||
|
or f"Your ticket for '{event.name}' is ready"
|
||||||
|
)
|
||||||
|
body = (
|
||||||
|
event.extra.notification_body.strip()
|
||||||
|
or f"Your ticket for '{event.name}' is ready."
|
||||||
|
)
|
||||||
|
|
||||||
|
return subject, f"{body}\n\nOpen it here: {ticket_url}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_nostr_ticket_notification(identifier: str, message: str) -> None:
|
||||||
|
if "@" in identifier:
|
||||||
|
await send_user_notification(
|
||||||
|
UserNotifications(nostr_identifier=identifier),
|
||||||
|
message,
|
||||||
|
"text_message",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
private_key = normalize_private_key(settings.lnbits_nostr_notifications_private_key)
|
||||||
|
public_key = normalize_public_key(identifier)
|
||||||
|
await send_nostr_dm(private_key, public_key, message, DEFAULT_NOSTR_RELAYS)
|
||||||
|
|
||||||
|
|
||||||
|
def _ticket_url(ticket: Ticket) -> str:
|
||||||
|
base_url = (ticket.extra.ticket_base_url or settings.lnbits_baseurl).rstrip("/")
|
||||||
|
return f"{base_url}/events/ticket/{ticket.id}"
|
||||||
|
|
||||||
|
|
||||||
|
async def refund_tickets(event_id: str):
|
||||||
|
"""
|
||||||
|
Refund tickets for an event that has not met the minimum ticket requirement.
|
||||||
|
This function should be called when the event is closed and the minimum ticket
|
||||||
|
condition is not met.
|
||||||
|
"""
|
||||||
|
await purge_unpaid_tickets(event_id)
|
||||||
|
tickets = await get_event_tickets(event_id)
|
||||||
|
|
||||||
|
if not tickets:
|
||||||
|
return
|
||||||
|
|
||||||
|
for ticket in tickets:
|
||||||
|
if ticket.extra.refunded:
|
||||||
|
continue
|
||||||
|
if ticket.paid and ticket.extra.refund_address and ticket.extra.sats_paid:
|
||||||
|
try:
|
||||||
|
res = await execute(
|
||||||
|
ticket.extra.refund_address, str(ticket.extra.sats_paid)
|
||||||
|
)
|
||||||
|
if res:
|
||||||
|
ticket.extra.refunded = True
|
||||||
|
await update_ticket(ticket)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error refunding ticket {ticket.id}: {e}")
|
||||||
BIN
static/image/1.jpg
Normal file
BIN
static/image/1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
static/image/1.png
Normal file
BIN
static/image/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
BIN
static/image/2.jpeg
Normal file
BIN
static/image/2.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
static/image/3.png
Normal file
BIN
static/image/3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
static/image/4.png
Normal file
BIN
static/image/4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
205
static/js/display.js
Normal file
205
static/js/display.js
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
window.PageEventsDisplay = {
|
||||||
|
template: '#page-events-display',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
eventErrorLabel: '',
|
||||||
|
event: null,
|
||||||
|
paymentReq: null,
|
||||||
|
redirectUrl: null,
|
||||||
|
formDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
refund: '',
|
||||||
|
nostr_identifier: '',
|
||||||
|
payment_method: 'lightning'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ticketLink: {
|
||||||
|
show: false,
|
||||||
|
data: {
|
||||||
|
link: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
receive: {
|
||||||
|
show: false,
|
||||||
|
status: 'pending',
|
||||||
|
paymentReq: null,
|
||||||
|
isFiat: false
|
||||||
|
},
|
||||||
|
paymentDismissMsg: null,
|
||||||
|
paymentWebsocket: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async created() {
|
||||||
|
this.eventId = this.$route.params.id
|
||||||
|
this.event = await this.getEvent()
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
formatDescription() {
|
||||||
|
return LNbits.utils.convertMarkdown(this.event?.info || '')
|
||||||
|
},
|
||||||
|
allowFiatCheckout() {
|
||||||
|
return Boolean(this.event?.allow_fiat)
|
||||||
|
},
|
||||||
|
fiatCheckoutLabel() {
|
||||||
|
if (!this.allowFiatCheckout) return 'Fiat'
|
||||||
|
const unit = ['sat', 'sats'].includes(
|
||||||
|
(this.event?.currency || '').toLowerCase()
|
||||||
|
)
|
||||||
|
? this.event?.fiat_currency
|
||||||
|
: this.event?.currency
|
||||||
|
return `Fiat (${(unit || 'GBP').toUpperCase()})`
|
||||||
|
},
|
||||||
|
allowEmailNotifications() {
|
||||||
|
return Boolean(this.event?.extra?.email_notifications)
|
||||||
|
},
|
||||||
|
allowNostrNotifications() {
|
||||||
|
return Boolean(this.event?.extra?.nostr_notifications)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async getEvent() {
|
||||||
|
try {
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
`/events/api/v1/events/${this.eventId}`
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
} catch (error) {
|
||||||
|
this.eventErrorLabel = 'Event unavailable.'
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resetForm(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
this.formDialog.data.name = ''
|
||||||
|
this.formDialog.data.email = ''
|
||||||
|
this.formDialog.data.refund = ''
|
||||||
|
this.formDialog.data.nostr_identifier = ''
|
||||||
|
this.formDialog.data.payment_method = 'lightning'
|
||||||
|
},
|
||||||
|
|
||||||
|
closeReceiveDialog() {
|
||||||
|
if (this.paymentDismissMsg) {
|
||||||
|
this.paymentDismissMsg()
|
||||||
|
this.paymentDismissMsg = null
|
||||||
|
}
|
||||||
|
if (this.paymentWebsocket) {
|
||||||
|
this.paymentWebsocket.close()
|
||||||
|
this.paymentWebsocket = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nameValidation(val) {
|
||||||
|
const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g
|
||||||
|
return (
|
||||||
|
!regex.test(val) ||
|
||||||
|
'Please enter valid name. No special character allowed.'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
emailValidation(val) {
|
||||||
|
const regex = /^[\w\.-]+@[a-zA-Z\d\.-]+\.[a-zA-Z]{2,}$/
|
||||||
|
return regex.test(val) || 'Please enter valid email.'
|
||||||
|
},
|
||||||
|
paymentSuccess(paymentHash) {
|
||||||
|
if (this.paymentDismissMsg) {
|
||||||
|
this.paymentDismissMsg()
|
||||||
|
this.paymentDismissMsg = null
|
||||||
|
}
|
||||||
|
this.paymentReq = null
|
||||||
|
this.formDialog.data.name = ''
|
||||||
|
this.formDialog.data.email = ''
|
||||||
|
this.formDialog.data.refund = ''
|
||||||
|
this.formDialog.data.nostr_identifier = ''
|
||||||
|
this.formDialog.data.payment_method = 'lightning'
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Sent, thank you!',
|
||||||
|
icon: null
|
||||||
|
})
|
||||||
|
this.receive = {
|
||||||
|
show: false,
|
||||||
|
status: 'complete',
|
||||||
|
paymentReq: null,
|
||||||
|
isFiat: false
|
||||||
|
}
|
||||||
|
this.ticketLink = {
|
||||||
|
show: true,
|
||||||
|
data: {
|
||||||
|
link: `/events/ticket/${paymentHash}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.open(`/events/ticket/${paymentHash}`, '_blank', 'noopener')
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
nostr_identifier: this.formDialog.data.nostr_identifier || null,
|
||||||
|
payment_method: this.formDialog.data.payment_method
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const isFiat = Boolean(data.is_fiat)
|
||||||
|
this.paymentReq = isFiat
|
||||||
|
? data.fiat_payment_request || null
|
||||||
|
: data.payment_request
|
||||||
|
this.paymentHash = data.payment_hash
|
||||||
|
|
||||||
|
this.paymentDismissMsg = Quasar.Notify.create({
|
||||||
|
timeout: 0,
|
||||||
|
message: 'Waiting for payment...'
|
||||||
|
})
|
||||||
|
this.receive = {
|
||||||
|
show: true,
|
||||||
|
status: 'pending',
|
||||||
|
paymentReq: this.paymentReq,
|
||||||
|
isFiat
|
||||||
|
}
|
||||||
|
if (isFiat && this.paymentReq) {
|
||||||
|
window.open(this.paymentReq, '_blank', 'noopener')
|
||||||
|
}
|
||||||
|
this.paymentWatcher(this.paymentHash)
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
paymentWatcher(paymentHash) {
|
||||||
|
if (this.paymentWebsocket) {
|
||||||
|
this.paymentWebsocket.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(window.location)
|
||||||
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
url.pathname = `/api/v1/ws/${paymentHash}`
|
||||||
|
url.search = ''
|
||||||
|
url.hash = ''
|
||||||
|
|
||||||
|
const ws = new WebSocket(url.toString())
|
||||||
|
this.paymentWebsocket = ws
|
||||||
|
|
||||||
|
ws.onmessage = event => {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
if (data.pending === false) {
|
||||||
|
this.paymentSuccess(paymentHash)
|
||||||
|
ws.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ws.onerror = error => {
|
||||||
|
console.error('WebSocket error:', error)
|
||||||
|
}
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (this.paymentWebsocket === ws) {
|
||||||
|
this.paymentWebsocket = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
215
static/js/display.vue
Normal file
215
static/js/display.vue
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
<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-banner
|
||||||
|
v-if="event.status === 'proposed'"
|
||||||
|
class="bg-orange-2 text-orange-10"
|
||||||
|
rounded
|
||||||
|
>
|
||||||
|
<template v-slot:avatar>
|
||||||
|
<q-icon name="pending" color="orange-10"></q-icon>
|
||||||
|
</template>
|
||||||
|
<span class="text-weight-medium">Pending approval</span> — this
|
||||||
|
event is awaiting an admin review and is not yet open for tickets.
|
||||||
|
</q-banner>
|
||||||
|
|
||||||
|
<q-banner
|
||||||
|
v-else-if="event.status === 'rejected'"
|
||||||
|
class="bg-red-2 text-red-10"
|
||||||
|
rounded
|
||||||
|
>
|
||||||
|
<template v-slot:avatar>
|
||||||
|
<q-icon name="block" color="red-10"></q-icon>
|
||||||
|
</template>
|
||||||
|
<span class="text-weight-medium">Not approved</span> — this event
|
||||||
|
was reviewed and is not being published.
|
||||||
|
</q-banner>
|
||||||
|
|
||||||
|
<q-card v-if="event.status === 'approved'" 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">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.name"
|
||||||
|
label="Your name "
|
||||||
|
:rules="[val => nameValidation(val)]"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 q-pr-sm">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.email"
|
||||||
|
type="email"
|
||||||
|
:label="
|
||||||
|
allowEmailNotifications
|
||||||
|
? 'Your email (ticket delivery) '
|
||||||
|
: 'Your email '
|
||||||
|
"
|
||||||
|
:rules="[
|
||||||
|
val => !!val || '* Required',
|
||||||
|
val => emailValidation(val)
|
||||||
|
]"
|
||||||
|
lazy-rules
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div v-if="allowNostrNotifications" class="col-12 col-md-6">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.nostr_identifier"
|
||||||
|
label="(optional) Nostr NIP-05 or npub"
|
||||||
|
hint="If provided, we'll DM your ticket link after payment."
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-input
|
||||||
|
v-if="event.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 (${event.extra?.min_tickets}) are not met, refund will be sent.`"
|
||||||
|
></q-input>
|
||||||
|
<div class="row q-col-gutter-md q-pt-lg items-center">
|
||||||
|
<div v-if="allowFiatCheckout" class="col-auto">
|
||||||
|
<q-option-group
|
||||||
|
v-model="formDialog.data.payment_method"
|
||||||
|
inline
|
||||||
|
:options="[
|
||||||
|
{label: 'Lightning', value: 'lightning'},
|
||||||
|
{
|
||||||
|
label: fiatCheckoutLabel,
|
||||||
|
value: 'fiat'
|
||||||
|
}
|
||||||
|
]"
|
||||||
|
></q-option-group>
|
||||||
|
</div>
|
||||||
|
<div :class="allowFiatCheckout ? 'col-12 col-md-3' : 'col-12'">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.promo_code"
|
||||||
|
label="(optional) Promo Code "
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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
|
||||||
|
>
|
||||||
|
</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-if="receive.isFiat"
|
||||||
|
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
||||||
|
>
|
||||||
|
<div class="text-center q-mb-lg">
|
||||||
|
<div class="text-h6 q-mb-sm">Continue to checkout</div>
|
||||||
|
<div class="text-body2 text-grey-5 q-mb-lg">
|
||||||
|
Your fiat checkout opened in a new tab. If it did not, use the
|
||||||
|
button below.
|
||||||
|
</div>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
type="a"
|
||||||
|
:href="receive.paymentReq"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
Go to checkout
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
@click="utils.copyText(receive.paymentReq)"
|
||||||
|
>Copy payment link</q-btn
|
||||||
|
>
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
574
static/js/index.js
Normal file
574
static/js/index.js
Normal file
|
|
@ -0,0 +1,574 @@
|
||||||
|
window.PageEvents = {
|
||||||
|
template: '#page-events',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
events: [],
|
||||||
|
tickets: [],
|
||||||
|
resendingTicketEmails: [],
|
||||||
|
currencies: [],
|
||||||
|
pendingEvents: [],
|
||||||
|
allUserEvents: [],
|
||||||
|
isAdmin: false,
|
||||||
|
republishing: false,
|
||||||
|
republishingMine: false,
|
||||||
|
settings: {
|
||||||
|
auto_approve: false
|
||||||
|
},
|
||||||
|
allUsersEventsTable: {
|
||||||
|
// Shown on the admin All Users' Events card. Includes the
|
||||||
|
// wallet owner (`wallet_user_id` resolved server-side) so
|
||||||
|
// cross-tenant rows are attributable to a user.
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'wallet_user_id',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Owner',
|
||||||
|
field: 'wallet_user_id'
|
||||||
|
},
|
||||||
|
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||||
|
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||||
|
{
|
||||||
|
name: 'event_start_date',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Start date',
|
||||||
|
field: 'event_start_date'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'event_end_date',
|
||||||
|
align: 'left',
|
||||||
|
label: 'End date',
|
||||||
|
field: 'event_end_date'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'closing_date',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Ticket close',
|
||||||
|
field: 'closing_date'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'canceled',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Canceled',
|
||||||
|
field: row => {
|
||||||
|
if (row.extra && row.extra.conditional && row.canceled) {
|
||||||
|
return 'Yes'
|
||||||
|
}
|
||||||
|
return 'No'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{name: 'status', align: 'left', label: 'Status', field: 'status'}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
eventsTable: {
|
||||||
|
columns: [
|
||||||
|
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||||
|
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||||
|
{
|
||||||
|
name: 'event_start_date',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Start date',
|
||||||
|
field: 'event_start_date'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'event_end_date',
|
||||||
|
align: 'left',
|
||||||
|
label: 'End date',
|
||||||
|
field: 'event_end_date'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'closing_date',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Ticket close',
|
||||||
|
field: 'closing_date'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'canceled',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Canceled',
|
||||||
|
field: row => {
|
||||||
|
if (row.extra.conditional && row.canceled) {
|
||||||
|
return 'Yes'
|
||||||
|
}
|
||||||
|
return 'No'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'price_per_ticket',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Price',
|
||||||
|
field: row => {
|
||||||
|
if (this.isFiatCurrency(row.currency)) {
|
||||||
|
return LNbits.utils.formatCurrency(
|
||||||
|
row.price_per_ticket.toFixed(2),
|
||||||
|
row.currency
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return row.price_per_ticket
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'amount_tickets',
|
||||||
|
align: 'left',
|
||||||
|
label: 'No tickets',
|
||||||
|
field: 'amount_tickets'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sold',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Sold',
|
||||||
|
field: 'sold'
|
||||||
|
},
|
||||||
|
{name: 'info', align: 'left', label: 'Info', field: 'info'},
|
||||||
|
{name: 'banner', align: 'left', label: 'Banner', field: 'banner'},
|
||||||
|
{name: 'status', align: 'left', label: 'Status', field: 'status'}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ticketsTable: {
|
||||||
|
columns: [
|
||||||
|
{name: 'event', align: 'left', label: 'Event', field: 'event'},
|
||||||
|
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||||
|
{name: 'email', align: 'left', label: 'Email', field: 'email'},
|
||||||
|
{
|
||||||
|
name: 'registered',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Registered',
|
||||||
|
field: 'registered'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'promo_code',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Promo Code',
|
||||||
|
field: row => row.extra.applied_promo_code || ''
|
||||||
|
},
|
||||||
|
{name: 'id', align: 'left', label: 'ID', field: 'id'}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {
|
||||||
|
currency: 'sats',
|
||||||
|
allow_fiat: false,
|
||||||
|
fiat_currency: 'GBP',
|
||||||
|
extra: {
|
||||||
|
promo_codes: [],
|
||||||
|
notification_subject: '',
|
||||||
|
notification_body: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
isFiatCurrency(currency) {
|
||||||
|
return !['sat', 'sats'].includes((currency || '').toLowerCase())
|
||||||
|
},
|
||||||
|
getTickets() {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/events/api/v1/tickets?all_wallets=true',
|
||||||
|
this.g.user.wallets[0].adminkey
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
this.tickets = response.data.filter(e => e.paid)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteTicket(ticketId) {
|
||||||
|
const tickets = _.findWhere(this.tickets, {id: ticketId})
|
||||||
|
const wallet = _.findWhere(this.g.user.wallets, {id: tickets.wallet})
|
||||||
|
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog('Are you sure you want to delete this ticket')
|
||||||
|
.onOk(() => {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'DELETE',
|
||||||
|
'/events/api/v1/tickets/' + ticketId,
|
||||||
|
wallet.adminkey
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
this.tickets = _.reject(this.tickets, function (obj) {
|
||||||
|
return obj.id == ticketId
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(LNbits.utils.notifyApiError)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
resendTicketEmail(ticket) {
|
||||||
|
if (!ticket.paid || !ticket.email) return
|
||||||
|
const wallet = _.findWhere(this.g.user.wallets, {id: ticket.wallet})
|
||||||
|
if (!wallet) return
|
||||||
|
|
||||||
|
this.resendingTicketEmails.push(ticket.id)
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'POST',
|
||||||
|
'/events/api/v1/tickets/' + ticket.id + '/resend-email',
|
||||||
|
wallet.adminkey
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
this.tickets = this.tickets.map(obj =>
|
||||||
|
obj.id === ticket.id ? response.data : obj
|
||||||
|
)
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Ticket email resent.',
|
||||||
|
icon: null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(LNbits.utils.notifyApiError)
|
||||||
|
.finally(() => {
|
||||||
|
this.resendingTicketEmails = this.resendingTicketEmails.filter(
|
||||||
|
ticketId => ticketId !== ticket.id
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
exportticketsCSV() {
|
||||||
|
LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets)
|
||||||
|
},
|
||||||
|
getEvents() {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/events/api/v1/events?all_wallets=true',
|
||||||
|
this.g.user.wallets[0].inkey
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
this.events = response.data
|
||||||
|
this.checkCanceledEvents()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Admin probe: a 200 from /all means we're an LNbits admin.
|
||||||
|
LNbits.api
|
||||||
|
.request('GET', '/events/api/v1/events/all')
|
||||||
|
.then(response => {
|
||||||
|
this.isAdmin = true
|
||||||
|
const ownWalletIds = this.g.user.wallets.map(w => w.id)
|
||||||
|
this.allUserEvents = response.data.filter(
|
||||||
|
e => !ownWalletIds.includes(e.wallet)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.isAdmin = false
|
||||||
|
this.allUserEvents = []
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getSettings() {
|
||||||
|
LNbits.api
|
||||||
|
.request('GET', '/events/api/v1/events/settings')
|
||||||
|
.then(response => {
|
||||||
|
this.settings = response.data
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Not admin or settings unavailable; keep defaults.
|
||||||
|
})
|
||||||
|
},
|
||||||
|
saveSettings() {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'PUT',
|
||||||
|
'/events/api/v1/events/settings',
|
||||||
|
null,
|
||||||
|
this.settings
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
Quasar.Notify.create({type: 'positive', message: 'Settings saved'})
|
||||||
|
})
|
||||||
|
.catch(LNbits.utils.notifyApiError)
|
||||||
|
},
|
||||||
|
getPendingEvents() {
|
||||||
|
LNbits.api
|
||||||
|
.request('GET', '/events/api/v1/events/pending')
|
||||||
|
.then(response => {
|
||||||
|
this.pendingEvents = response.data
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.pendingEvents = []
|
||||||
|
})
|
||||||
|
},
|
||||||
|
approveEvent(eventId) {
|
||||||
|
LNbits.utils.confirmDialog('Approve this event?').onOk(() => {
|
||||||
|
LNbits.api
|
||||||
|
.request('PUT', '/events/api/v1/events/' + eventId + '/approve')
|
||||||
|
.then(() => {
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Event approved'
|
||||||
|
})
|
||||||
|
this.getEvents()
|
||||||
|
this.getPendingEvents()
|
||||||
|
})
|
||||||
|
.catch(LNbits.utils.notifyApiError)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
rejectEvent(eventId) {
|
||||||
|
LNbits.utils.confirmDialog('Reject this event?').onOk(() => {
|
||||||
|
LNbits.api
|
||||||
|
.request('PUT', '/events/api/v1/events/' + eventId + '/reject')
|
||||||
|
.then(() => {
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Event rejected'
|
||||||
|
})
|
||||||
|
this.getEvents()
|
||||||
|
this.getPendingEvents()
|
||||||
|
})
|
||||||
|
.catch(LNbits.utils.notifyApiError)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
republishAllEvents() {
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog(
|
||||||
|
'Re-emit every approved event to Nostr relays? This is safe ' +
|
||||||
|
'to run multiple times but generates one event per approved row.'
|
||||||
|
)
|
||||||
|
.onOk(() => {
|
||||||
|
this.republishing = true
|
||||||
|
LNbits.api
|
||||||
|
.request('POST', '/events/api/v1/events/republish-all')
|
||||||
|
.then(response => {
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message:
|
||||||
|
'Republished ' +
|
||||||
|
response.data.republished +
|
||||||
|
' of ' +
|
||||||
|
response.data.total +
|
||||||
|
' events'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(LNbits.utils.notifyApiError)
|
||||||
|
.finally(() => {
|
||||||
|
this.republishing = false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
republishMyEvents() {
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog(
|
||||||
|
'Re-emit your approved events to Nostr relays?'
|
||||||
|
)
|
||||||
|
.onOk(() => {
|
||||||
|
this.republishingMine = true
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'POST',
|
||||||
|
'/events/api/v1/events/republish-mine?all_wallets=true',
|
||||||
|
this.g.user.wallets[0].adminkey
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message:
|
||||||
|
'Republished ' +
|
||||||
|
response.data.republished +
|
||||||
|
' of your ' +
|
||||||
|
response.data.total +
|
||||||
|
' events'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(LNbits.utils.notifyApiError)
|
||||||
|
.finally(() => {
|
||||||
|
this.republishingMine = false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
foldDateTime(day, time) {
|
||||||
|
// Combine separate date/time inputs into the wire format
|
||||||
|
// expected by the events extension: "YYYY-MM-DD" or
|
||||||
|
// "YYYY-MM-DDTHH:MM" (time is optional).
|
||||||
|
if (!day) return null
|
||||||
|
return time ? `${day}T${time}` : day
|
||||||
|
},
|
||||||
|
splitDateTime(value) {
|
||||||
|
// Inverse of foldDateTime: split a stored string back into the
|
||||||
|
// day/time pieces the form inputs bind to.
|
||||||
|
if (!value) return {day: '', time: ''}
|
||||||
|
const [day, time = ''] = value.split('T')
|
||||||
|
// Time inputs only accept HH:MM, drop any seconds we stored.
|
||||||
|
return {day, time: time.slice(0, 5)}
|
||||||
|
},
|
||||||
|
sendEventData() {
|
||||||
|
const wallet = _.findWhere(this.g.user.wallets, {
|
||||||
|
id: this.formDialog.data.wallet
|
||||||
|
})
|
||||||
|
const data = {...this.formDialog.data}
|
||||||
|
data.event_start_date = this.foldDateTime(
|
||||||
|
data.event_start_day,
|
||||||
|
data.event_start_time
|
||||||
|
)
|
||||||
|
data.event_end_date = this.foldDateTime(
|
||||||
|
data.event_end_day,
|
||||||
|
data.event_end_time
|
||||||
|
)
|
||||||
|
delete data.event_start_day
|
||||||
|
delete data.event_start_time
|
||||||
|
delete data.event_end_day
|
||||||
|
delete data.event_end_time
|
||||||
|
|
||||||
|
if (data.extra?.promo_codes) {
|
||||||
|
data.extra.promo_codes = data.extra.promo_codes
|
||||||
|
.filter(code => code.code?.trim() !== '')
|
||||||
|
.map(code => ({
|
||||||
|
...code,
|
||||||
|
code: code.code.trim().toUpperCase()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
if (!this.isFiatCurrency(data.currency)) {
|
||||||
|
if (!data.allow_fiat) {
|
||||||
|
data.fiat_currency = 'GBP'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.id) {
|
||||||
|
this.updateEvent(wallet, data)
|
||||||
|
} else {
|
||||||
|
this.createEvent(wallet, data)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
openEventDialog(data = false) {
|
||||||
|
if (data && data.id) {
|
||||||
|
const start = this.splitDateTime(data.event_start_date)
|
||||||
|
const end = this.splitDateTime(data.event_end_date)
|
||||||
|
this.formDialog.data = {
|
||||||
|
...data,
|
||||||
|
event_start_day: start.day,
|
||||||
|
event_start_time: start.time,
|
||||||
|
event_end_day: end.day,
|
||||||
|
event_end_time: end.time
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.formDialog.data = {
|
||||||
|
currency: 'sats',
|
||||||
|
allow_fiat: false,
|
||||||
|
fiat_currency: 'GBP',
|
||||||
|
event_start_day: '',
|
||||||
|
event_start_time: '',
|
||||||
|
event_end_day: '',
|
||||||
|
event_end_time: '',
|
||||||
|
extra: {
|
||||||
|
conditional: false,
|
||||||
|
min_tickets: 1,
|
||||||
|
email_notifications: false,
|
||||||
|
nostr_notifications: false,
|
||||||
|
promo_codes: [],
|
||||||
|
notification_subject: '',
|
||||||
|
notification_body: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.formDialog.show = true
|
||||||
|
},
|
||||||
|
resetEventDialog() {
|
||||||
|
this.formDialog.show = false
|
||||||
|
this.formDialog.data = {
|
||||||
|
currency: 'sats',
|
||||||
|
allow_fiat: false,
|
||||||
|
fiat_currency: 'GBP',
|
||||||
|
extra: {
|
||||||
|
email_notifications: false,
|
||||||
|
nostr_notifications: false,
|
||||||
|
promo_codes: [],
|
||||||
|
notification_subject: '',
|
||||||
|
notification_body: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createEvent(wallet, data) {
|
||||||
|
LNbits.api
|
||||||
|
.request('POST', '/events/api/v1/events', wallet.adminkey, data)
|
||||||
|
.then(response => {
|
||||||
|
this.events.push(response.data)
|
||||||
|
this.resetEventDialog()
|
||||||
|
})
|
||||||
|
.catch(LNbits.utils.notifyApiError)
|
||||||
|
},
|
||||||
|
updateformDialog(formId) {
|
||||||
|
const link = _.findWhere(this.events, {id: formId})
|
||||||
|
this.openEventDialog(link)
|
||||||
|
},
|
||||||
|
updateEvent(wallet, data) {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'PUT',
|
||||||
|
'/events/api/v1/events/' + data.id,
|
||||||
|
wallet.adminkey,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
this.events = _.reject(this.events, function (obj) {
|
||||||
|
return obj.id == data.id
|
||||||
|
})
|
||||||
|
this.events.push(response.data)
|
||||||
|
this.resetEventDialog()
|
||||||
|
})
|
||||||
|
.catch(LNbits.utils.notifyApiError)
|
||||||
|
},
|
||||||
|
deleteEvent(eventsId) {
|
||||||
|
const events = _.findWhere(this.events, {id: eventsId})
|
||||||
|
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog('Are you sure you want to delete this form link?')
|
||||||
|
.onOk(() => {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'DELETE',
|
||||||
|
'/events/api/v1/events/' + eventsId,
|
||||||
|
_.findWhere(this.g.user.wallets, {id: events.wallet}).adminkey
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
this.events = _.reject(this.events, function (obj) {
|
||||||
|
return obj.id == eventsId
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(LNbits.utils.notifyApiError)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
exporteventsCSV() {
|
||||||
|
LNbits.utils.exportCSV(this.eventsTable.columns, this.events)
|
||||||
|
},
|
||||||
|
async checkCanceledEvents() {
|
||||||
|
const events = this.events
|
||||||
|
.filter(event => event.extra.conditional)
|
||||||
|
.filter(e => !e.canceled)
|
||||||
|
if (!events.length) return
|
||||||
|
const now = new Date()
|
||||||
|
events.forEach(async ev => {
|
||||||
|
if (new Date(ev.closing_date) < now && ev.sold < ev.extra.min_tickets) {
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'PUT',
|
||||||
|
'/events/api/v1/events/' + ev.id + '/cancel',
|
||||||
|
_.findWhere(this.g.user.wallets, {id: ev.wallet}).adminkey
|
||||||
|
)
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'warning',
|
||||||
|
message: `Event ${ev.name} has been canceled and refunds have been issued.`,
|
||||||
|
icon: null
|
||||||
|
})
|
||||||
|
this.events = this.events.map(e => (e.id === ev.id ? data : e))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async created() {
|
||||||
|
if (this.g.user.wallets.length) {
|
||||||
|
this.getTickets()
|
||||||
|
this.getEvents()
|
||||||
|
this.getSettings()
|
||||||
|
this.getPendingEvents()
|
||||||
|
if (this.g.allowedCurrencies && this.g.allowedCurrencies.length > 0) {
|
||||||
|
this.currencies = ['sats', ...this.g.allowedCurrencies]
|
||||||
|
} else {
|
||||||
|
this.currencies = ['sats', ...this.g.currencies]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
783
static/js/index.vue
Normal file
783
static/js/index.vue
Normal file
|
|
@ -0,0 +1,783 @@
|
||||||
|
<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 v-if="isAdmin">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center justify-between">
|
||||||
|
<div class="col">
|
||||||
|
<span class="text-subtitle1">Settings</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-toggle
|
||||||
|
v-model="settings.auto_approve"
|
||||||
|
label="Auto-approve events"
|
||||||
|
@update:model-value="saveSettings"
|
||||||
|
></q-toggle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-separator class="q-my-md"></q-separator>
|
||||||
|
<div class="row items-center justify-between">
|
||||||
|
<div class="col">
|
||||||
|
<span class="text-subtitle2">Republish to Nostr</span>
|
||||||
|
<div class="text-caption text-grey-7" style="color: #aaa">
|
||||||
|
Re-emit every approved event so connected clients pick
|
||||||
|
up the latest tag set. Useful after the extension
|
||||||
|
publisher changes (e.g. new tickets_* tags) so existing
|
||||||
|
events don't need a per-event edit.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="primary"
|
||||||
|
icon="cloud_upload"
|
||||||
|
label="Republish all"
|
||||||
|
:loading="republishing"
|
||||||
|
@click="republishAllEvents"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center q-gutter-sm">
|
||||||
|
<q-btn unelevated color="primary" @click="openEventDialog"
|
||||||
|
>New Event</q-btn
|
||||||
|
>
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="primary"
|
||||||
|
icon="cloud_upload"
|
||||||
|
label="Republish mine"
|
||||||
|
:loading="republishingMine"
|
||||||
|
@click="republishMyEvents"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
<div class="text-caption q-mt-sm" style="color: #aaa">
|
||||||
|
Re-emit your approved events to Nostr relays. Useful after
|
||||||
|
a publisher upgrade or if a relay dropped your events.
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card v-if="pendingEvents.length > 0">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col">
|
||||||
|
<h5 class="text-subtitle1 q-my-none">
|
||||||
|
<q-icon name="pending" color="orange" class="q-mr-sm"></q-icon>
|
||||||
|
Pending Approvals
|
||||||
|
<q-badge
|
||||||
|
color="orange"
|
||||||
|
:label="pendingEvents.length"
|
||||||
|
class="q-ml-sm"
|
||||||
|
></q-badge>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-list separator>
|
||||||
|
<q-item v-for="event in pendingEvents" :key="event.id">
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label v-text="event.name"></q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
<span v-text="event.event_start_date"></span>
|
||||||
|
—
|
||||||
|
<span v-text="event.info.substring(0, 80)"></span
|
||||||
|
><span v-if="event.info.length > 80">...</span>
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
<span v-text="event.amount_tickets"></span> tickets •
|
||||||
|
<span v-text="event.price_per_ticket"></span>
|
||||||
|
<span v-text="event.currency"></span>
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section side>
|
||||||
|
<div class="row q-gutter-sm">
|
||||||
|
<q-btn
|
||||||
|
dense
|
||||||
|
color="green"
|
||||||
|
icon="check_circle"
|
||||||
|
label="Approve"
|
||||||
|
size="sm"
|
||||||
|
@click="approveEvent(event.id)"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
dense
|
||||||
|
outline
|
||||||
|
color="red"
|
||||||
|
icon="block"
|
||||||
|
label="Reject"
|
||||||
|
size="sm"
|
||||||
|
@click="rejectEvent(event.id)"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</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
|
||||||
|
v-if="isAdmin && props.row.status === 'proposed'"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
@click="approveEvent(props.row.id)"
|
||||||
|
icon="check_circle"
|
||||||
|
color="green"
|
||||||
|
>
|
||||||
|
<q-tooltip>Approve</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
v-if="isAdmin && props.row.status === 'proposed'"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
@click="rejectEvent(props.row.id)"
|
||||||
|
icon="block"
|
||||||
|
color="red"
|
||||||
|
>
|
||||||
|
<q-tooltip>Reject</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<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">
|
||||||
|
<q-badge
|
||||||
|
v-if="col.name === 'status'"
|
||||||
|
:color="col.value === 'approved' ? 'green' : col.value === 'proposed' ? 'orange' : 'red'"
|
||||||
|
:label="col.value"
|
||||||
|
></q-badge>
|
||||||
|
<span v-else 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 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
|
||||||
|
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 auto-width>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
@click="resendTicketEmail(props.row)"
|
||||||
|
icon="email"
|
||||||
|
color="primary"
|
||||||
|
:disable="!props.row.paid || !props.row.email"
|
||||||
|
:loading="resendingTicketEmails.includes(props.row.id)"
|
||||||
|
>
|
||||||
|
<q-tooltip>Resend ticket email</q-tooltip>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<q-card v-if="isAdmin && allUserEvents.length > 0">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col">
|
||||||
|
<h5 class="text-subtitle1 q-my-none">
|
||||||
|
All Users' Events
|
||||||
|
<q-badge
|
||||||
|
color="blue"
|
||||||
|
:label="allUserEvents.length"
|
||||||
|
class="q-ml-sm"
|
||||||
|
></q-badge>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:rows="allUserEvents"
|
||||||
|
row-key="id"
|
||||||
|
:columns="allUsersEventsTable.columns"
|
||||||
|
:pagination="{rowsPerPage: 10}"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
<q-badge
|
||||||
|
v-if="col.name === 'status'"
|
||||||
|
:color="col.value === 'approved' ? 'green' : col.value === 'proposed' ? 'orange' : 'red'"
|
||||||
|
:label="col.value"
|
||||||
|
></q-badge>
|
||||||
|
<span v-else v-text="col.value"></span>
|
||||||
|
</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 q-col-gutter-sm">
|
||||||
|
<div class="col-4">Event begins</div>
|
||||||
|
<div class="col-5">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.event_start_day"
|
||||||
|
type="date"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.event_start_time"
|
||||||
|
type="time"
|
||||||
|
hint="Optional"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row q-col-gutter-sm">
|
||||||
|
<div class="col-4">Event ends</div>
|
||||||
|
<div class="col-5">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.event_end_day"
|
||||||
|
type="date"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.event_end_time"
|
||||||
|
type="time"
|
||||||
|
hint="Optional"
|
||||||
|
></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-toggle
|
||||||
|
v-model="formDialog.data.allow_fiat"
|
||||||
|
label="Allow fiat checkout"
|
||||||
|
left-label
|
||||||
|
hint="Lets attendees pay through a configured fiat provider using the event currency."
|
||||||
|
></q-toggle>
|
||||||
|
<q-select
|
||||||
|
v-if="
|
||||||
|
formDialog.data.allow_fiat &&
|
||||||
|
['sat', 'sats'].includes(
|
||||||
|
(formDialog.data.currency || '').toLowerCase()
|
||||||
|
)
|
||||||
|
"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="formDialog.data.fiat_currency"
|
||||||
|
label="Fiat checkout currency"
|
||||||
|
:options="
|
||||||
|
currencies.filter(
|
||||||
|
c => !['sat', 'sats'].includes((c || '').toLowerCase())
|
||||||
|
)
|
||||||
|
"
|
||||||
|
></q-select>
|
||||||
|
<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-separator class="q-my-md"></q-separator>
|
||||||
|
<div class="text-subtitle1 q-mb-md">Ticket Delivery</div>
|
||||||
|
<div class="text-caption">
|
||||||
|
Send the paid ticket link automatically by email or Nostr DM.
|
||||||
|
</div>
|
||||||
|
<q-toggle
|
||||||
|
v-model="formDialog.data.extra.email_notifications"
|
||||||
|
label="Email notifications"
|
||||||
|
left-label
|
||||||
|
></q-toggle>
|
||||||
|
<q-toggle
|
||||||
|
v-model="formDialog.data.extra.nostr_notifications"
|
||||||
|
label="Nostr notifications"
|
||||||
|
left-label
|
||||||
|
></q-toggle>
|
||||||
|
</q-expansion-item>
|
||||||
|
|
||||||
|
<q-separator class="q-my-md"></q-separator>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.extra.notification_subject"
|
||||||
|
type="text"
|
||||||
|
label="Ticket notification subject"
|
||||||
|
hint="Used as the email subject when sending paid ticket links."
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.extra.notification_body"
|
||||||
|
type="textarea"
|
||||||
|
label="Ticket notification body"
|
||||||
|
hint="Shown before the ticket link in the paid ticket notification."
|
||||||
|
></q-input>
|
||||||
|
|
||||||
|
<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_day == null ||
|
||||||
|
formDialog.data.event_end_day == 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>
|
||||||
74
static/js/register.js
Normal file
74
static/js/register.js
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
window.PageEventsRegister = {
|
||||||
|
template: '#page-events-register',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
tickets: [],
|
||||||
|
ticketsTable: {
|
||||||
|
columns: [
|
||||||
|
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||||
|
{name: 'email', align: 'left', label: 'Email', field: 'email'},
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
align: 'left',
|
||||||
|
label: 'ID',
|
||||||
|
field: 'id',
|
||||||
|
format: val => this.shortId(val)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sendCamera: {
|
||||||
|
show: false,
|
||||||
|
camera: 'auto'
|
||||||
|
},
|
||||||
|
lastScan: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
storageKey() {
|
||||||
|
return `events_scanned_${this.eventId}`
|
||||||
|
},
|
||||||
|
loadScannedTickets() {
|
||||||
|
this.tickets = Quasar.LocalStorage.getItem(this.storageKey()) || []
|
||||||
|
},
|
||||||
|
saveScannedTicket(ticket) {
|
||||||
|
this.tickets.unshift(ticket)
|
||||||
|
Quasar.LocalStorage.set(this.storageKey(), this.tickets)
|
||||||
|
},
|
||||||
|
closeCamera() {
|
||||||
|
this.sendCamera.show = false
|
||||||
|
},
|
||||||
|
showCamera() {
|
||||||
|
this.sendCamera.show = true
|
||||||
|
},
|
||||||
|
shortId(id) {
|
||||||
|
return id ? `${id.slice(0, 6)}...${id.slice(-4)}` : ''
|
||||||
|
},
|
||||||
|
decodeQR(res) {
|
||||||
|
this.sendCamera.show = false
|
||||||
|
const value = res[0].rawValue.split('//')[1]
|
||||||
|
LNbits.api
|
||||||
|
.request('PUT', `/events/api/v1/tickets/register/${value}`)
|
||||||
|
.then(response => {
|
||||||
|
this.saveScannedTicket(response.data)
|
||||||
|
this.lastScan = {success: true, ticket: response.data}
|
||||||
|
Quasar.Notify.create({type: 'positive', message: 'Registered!'})
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
this.lastScan = {
|
||||||
|
success: false,
|
||||||
|
ticketId: value,
|
||||||
|
error:
|
||||||
|
error.response?.data?.detail || error.message || 'Unknown error'
|
||||||
|
}
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.eventId = this.$route.params.id
|
||||||
|
this.loadScannedTickets()
|
||||||
|
}
|
||||||
|
}
|
||||||
86
static/js/register.vue
Normal file
86
static/js/register.vue
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
<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
|
||||||
|
v-if="lastScan"
|
||||||
|
:class="lastScan.success ? 'bg-positive' : 'bg-negative'"
|
||||||
|
>
|
||||||
|
<q-card-section class="text-white">
|
||||||
|
<div v-if="lastScan.success">
|
||||||
|
<div class="text-h6 q-mb-sm">Registered</div>
|
||||||
|
<div><strong>Name:</strong> {{ lastScan.ticket.name }}</div>
|
||||||
|
<div><strong>Email:</strong> {{ lastScan.ticket.email }}</div>
|
||||||
|
<div><strong>Paid:</strong> {{ lastScan.ticket.paid }}</div>
|
||||||
|
<div><strong>ID:</strong> {{ shortId(lastScan.ticket.id) }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="text-h6 q-mb-sm">Failed</div>
|
||||||
|
<div>
|
||||||
|
<strong>Ticket ID:</strong> {{ shortId(lastScan.ticketId) }}
|
||||||
|
</div>
|
||||||
|
<div><strong>Error:</strong> {{ lastScan.error }}</div>
|
||||||
|
</div>
|
||||||
|
</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
26
static/js/ticket.js
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
window.PageEventsTicket = {
|
||||||
|
template: '#page-events-ticket',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
ticketId: null,
|
||||||
|
ticket: 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.ticket = data
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
static/js/ticket.vue
Normal file
39
static/js/ticket.vue
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<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>
|
||||||
|
<h5 v-if="ticket" v-text="ticket.name" class="q-my-none"></h5>
|
||||||
|
<br />
|
||||||
|
<h5 class="q-my-none">
|
||||||
|
Bookmark, print or screenshot this page,<br />
|
||||||
|
and present it for registration!
|
||||||
|
</h5>
|
||||||
|
<div v-if="ticket" class="row justify-center q-gutter-sm q-mb-md">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
:color="ticket.paid ? 'positive' : 'negative'"
|
||||||
|
:label="ticket.paid ? 'Paid' : 'Not Paid'"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
:color="ticket.registered ? 'positive' : 'warning'"
|
||||||
|
:label="ticket.registered ? 'Checked In' : 'Not Checked In'"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
<lnbits-qrcode
|
||||||
|
:value="`ticket://${ticketId}`"
|
||||||
|
:options="{width: 500}"
|
||||||
|
></lnbits-qrcode>
|
||||||
|
<br />
|
||||||
|
<q-btn @click="printWindow" color="grey">
|
||||||
|
<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
26
static/routes.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
68
tasks.py
68
tasks.py
|
|
@ -1,16 +1,32 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from lnbits.core.models import Payment
|
from lnbits.core.models import Payment
|
||||||
from lnbits.helpers import get_current_extension_name
|
|
||||||
from lnbits.tasks import register_invoice_listener
|
from lnbits.tasks import register_invoice_listener
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from .models import CreateTicket
|
from .crud import get_ticket, get_tickets_by_payment_hash
|
||||||
from .views_api import api_ticket_send_ticket
|
from .models import Ticket
|
||||||
|
from .services import send_ticket_notification_in_background, 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()
|
||||||
register_invoice_listener(invoice_queue, get_current_extension_name())
|
register_invoice_listener(invoice_queue, "ext_events")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
payment = await invoice_queue.get()
|
payment = await invoice_queue.get()
|
||||||
|
|
@ -18,15 +34,35 @@ async def wait_for_paid_invoices():
|
||||||
|
|
||||||
|
|
||||||
async def on_invoice_paid(payment: Payment) -> None:
|
async def on_invoice_paid(payment: Payment) -> None:
|
||||||
# (avoid loops)
|
if not payment.extra or "events" != payment.extra.get("tag"):
|
||||||
if (
|
return
|
||||||
payment.extra
|
|
||||||
and "events" == payment.extra.get("tag")
|
# Multi-ticket purchases land as N rows sharing this payment_hash;
|
||||||
and payment.extra.get("name")
|
# each one needs to be marked paid + counted against capacity, and
|
||||||
and payment.extra.get("email")
|
# each gets its own buyer notification (mostly a no-op when all
|
||||||
):
|
# rows are owned by the same buyer, but cheap and consistent).
|
||||||
await api_ticket_send_ticket(
|
tickets = await get_tickets_by_payment_hash(payment.payment_hash)
|
||||||
payment.memo,
|
if not tickets:
|
||||||
payment.payment_hash,
|
# Backstop for any legacy row created before the payment_hash
|
||||||
)
|
# column was populated by the migration backfill.
|
||||||
return
|
legacy = await get_ticket(payment.payment_hash)
|
||||||
|
if legacy:
|
||||||
|
tickets = [legacy]
|
||||||
|
|
||||||
|
if not tickets:
|
||||||
|
logger.warning(f"No tickets for payment {payment.payment_hash}.")
|
||||||
|
return
|
||||||
|
|
||||||
|
paid_tickets: list[Ticket] = []
|
||||||
|
for ticket in tickets:
|
||||||
|
paid_tickets.append(await set_ticket_paid(ticket))
|
||||||
|
|
||||||
|
for paid_ticket in paid_tickets:
|
||||||
|
send_ticket_notification_in_background(paid_ticket)
|
||||||
|
|
||||||
|
# Wake up the WebSocket / poll listeners. Forward the first paid
|
||||||
|
# ticket so the existing single-ticket subscribers still work; the
|
||||||
|
# webapp re-fetches all ids via the polling endpoint anyway.
|
||||||
|
if payment_listeners.get(payment.payment_hash):
|
||||||
|
for paid_ticket_queue in payment_listeners[payment.payment_hash]:
|
||||||
|
paid_ticket_queue.put_nowait(paid_tickets[0])
|
||||||
|
|
|
||||||
|
|
@ -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 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.<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>
|
|
||||||
|
|
@ -1,216 +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">
|
|
||||||
<h3 class="q-my-none">{{ event_name }}</h3>
|
|
||||||
<br />
|
|
||||||
<h5 class="q-my-none">{{ event_info }}</h5>
|
|
||||||
<br />
|
|
||||||
<q-form @submit="Invoice()" class="q-gutter-md">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formDialog.data.name"
|
|
||||||
type="name"
|
|
||||||
label="Your name "
|
|
||||||
></q-input>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formDialog.data.email"
|
|
||||||
type="email"
|
|
||||||
label="Your email "
|
|
||||||
></q-input>
|
|
||||||
|
|
||||||
<div class="row q-mt-lg">
|
|
||||||
<q-btn
|
|
||||||
unelevated
|
|
||||||
color="primary"
|
|
||||||
:disable="formDialog.data.name == '' || formDialog.data.email == '' || 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">
|
|
||||||
<a class="text-secondary" :href="'lightning:' + receive.paymentReq">
|
|
||||||
<q-responsive :ratio="1" class="q-mx-xl">
|
|
||||||
<qrcode
|
|
||||||
:value="'lightning:' + receive.paymentReq.toUpperCase()"
|
|
||||||
:options="{width: 340}"
|
|
||||||
class="rounded-borders"
|
|
||||||
></qrcode>
|
|
||||||
</q-responsive>
|
|
||||||
</a>
|
|
||||||
</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>
|
|
||||||
console.log('{{ form_costpword }}')
|
|
||||||
Vue.component(VueQrcode.name, VueQrcode)
|
|
||||||
|
|
||||||
new Vue({
|
|
||||||
el: '#vue',
|
|
||||||
mixins: [windowMixin],
|
|
||||||
data: function () {
|
|
||||||
return {
|
|
||||||
paymentReq: null,
|
|
||||||
redirectUrl: null,
|
|
||||||
formDialog: {
|
|
||||||
show: false,
|
|
||||||
data: {
|
|
||||||
name: '',
|
|
||||||
email: ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
ticketLink: {
|
|
||||||
show: false,
|
|
||||||
data: {
|
|
||||||
link: ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
receive: {
|
|
||||||
show: false,
|
|
||||||
status: 'pending',
|
|
||||||
paymentReq: null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
resetForm: function (e) {
|
|
||||||
e.preventDefault()
|
|
||||||
this.formDialog.data.name = ''
|
|
||||||
this.formDialog.data.email = ''
|
|
||||||
},
|
|
||||||
|
|
||||||
closeReceiveDialog: function () {
|
|
||||||
var checker = this.receive.paymentChecker
|
|
||||||
dismissMsg()
|
|
||||||
|
|
||||||
clearInterval(paymentChecker)
|
|
||||||
setTimeout(function () {}, 10000)
|
|
||||||
},
|
|
||||||
Invoice: function () {
|
|
||||||
var self = this
|
|
||||||
axios
|
|
||||||
|
|
||||||
.get(
|
|
||||||
'/events/api/v1/tickets/' +
|
|
||||||
'{{ event_id }}' +
|
|
||||||
'/' +
|
|
||||||
self.formDialog.data.name +
|
|
||||||
'/' +
|
|
||||||
self.formDialog.data.email
|
|
||||||
)
|
|
||||||
.then(function (response) {
|
|
||||||
self.paymentReq = response.data.payment_request
|
|
||||||
self.paymentCheck = response.data.payment_hash
|
|
||||||
|
|
||||||
dismissMsg = self.$q.notify({
|
|
||||||
timeout: 0,
|
|
||||||
message: 'Waiting for payment...'
|
|
||||||
})
|
|
||||||
|
|
||||||
self.receive = {
|
|
||||||
show: true,
|
|
||||||
status: 'pending',
|
|
||||||
paymentReq: self.paymentReq
|
|
||||||
}
|
|
||||||
|
|
||||||
paymentChecker = setInterval(function () {
|
|
||||||
axios
|
|
||||||
.post(
|
|
||||||
'/events/api/v1/tickets/' +
|
|
||||||
'{{ event_id }}/' +
|
|
||||||
self.paymentCheck,
|
|
||||||
{
|
|
||||||
event: '{{ event_id }}',
|
|
||||||
event_name: '{{ event_name }}',
|
|
||||||
name: self.formDialog.data.name,
|
|
||||||
email: self.formDialog.data.email
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then(function (res) {
|
|
||||||
if (res.data.paid) {
|
|
||||||
clearInterval(paymentChecker)
|
|
||||||
dismissMsg()
|
|
||||||
self.formDialog.data.name = ''
|
|
||||||
self.formDialog.data.email = ''
|
|
||||||
|
|
||||||
self.$q.notify({
|
|
||||||
type: 'positive',
|
|
||||||
message: 'Sent, thank you!',
|
|
||||||
icon: null
|
|
||||||
})
|
|
||||||
self.receive = {
|
|
||||||
show: false,
|
|
||||||
status: 'complete',
|
|
||||||
paymentReq: null
|
|
||||||
}
|
|
||||||
|
|
||||||
self.ticketLink = {
|
|
||||||
show: true,
|
|
||||||
data: {
|
|
||||||
link: '/events/ticket/' + res.data.ticket_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setTimeout(function () {
|
|
||||||
window.location.href =
|
|
||||||
'/events/ticket/' + res.data.ticket_id
|
|
||||||
}, 5000)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(function (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
})
|
|
||||||
}, 2000)
|
|
||||||
})
|
|
||||||
.catch(function (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,35 +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>
|
|
||||||
|
|
||||||
{% endblock %} {% block scripts %}
|
|
||||||
|
|
||||||
<script>
|
|
||||||
new Vue({
|
|
||||||
el: '#vue',
|
|
||||||
mixins: [windowMixin],
|
|
||||||
data: function () {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,538 +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="formDialog.show = true"
|
|
||||||
>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
|
|
||||||
:data="events"
|
|
||||||
row-key="id"
|
|
||||||
:columns="eventsTable.columns"
|
|
||||||
:pagination.sync="eventsTable.pagination"
|
|
||||||
>
|
|
||||||
{% raw %}
|
|
||||||
<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">
|
|
||||||
{{ col.label }}
|
|
||||||
</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
|
|
||||||
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">
|
|
||||||
{{ col.value }}
|
|
||||||
</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>
|
|
||||||
</template>
|
|
||||||
{% endraw %}
|
|
||||||
</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
|
|
||||||
:data="tickets"
|
|
||||||
row-key="id"
|
|
||||||
:columns="ticketsTable.columns"
|
|
||||||
:pagination.sync="ticketsTable.pagination"
|
|
||||||
>
|
|
||||||
{% raw %}
|
|
||||||
<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">
|
|
||||||
{{ col.label }}
|
|
||||||
</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">
|
|
||||||
{{ col.value }}
|
|
||||||
</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>
|
|
||||||
{% endraw %}
|
|
||||||
</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 "
|
|
||||||
></q-input>
|
|
||||||
<div class="row">
|
|
||||||
<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">
|
|
||||||
<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-pl-sm">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.number="formDialog.data.price_per_ticket"
|
|
||||||
type="number"
|
|
||||||
label="Sats per ticket "
|
|
||||||
></q-input>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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) }}
|
|
||||||
<script>
|
|
||||||
var mapEvents = function (obj) {
|
|
||||||
obj.date = Quasar.utils.date.formatDate(
|
|
||||||
new Date(obj.time * 1000),
|
|
||||||
'YYYY-MM-DD HH:mm'
|
|
||||||
)
|
|
||||||
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
|
|
||||||
obj.displayUrl = ['/events/', obj.id].join('')
|
|
||||||
return obj
|
|
||||||
}
|
|
||||||
|
|
||||||
new Vue({
|
|
||||||
el: '#vue',
|
|
||||||
mixins: [windowMixin],
|
|
||||||
data: function () {
|
|
||||||
return {
|
|
||||||
events: [],
|
|
||||||
tickets: [],
|
|
||||||
eventsTable: {
|
|
||||||
columns: [
|
|
||||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
|
||||||
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
|
||||||
{name: 'info', align: 'left', label: 'Info', field: 'info'},
|
|
||||||
{
|
|
||||||
name: 'event_start_date',
|
|
||||||
align: 'left',
|
|
||||||
label: 'Start date',
|
|
||||||
field: 'event_start_date'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'event_end_date',
|
|
||||||
align: 'left',
|
|
||||||
label: 'End date',
|
|
||||||
field: 'event_end_date'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'closing_date',
|
|
||||||
align: 'left',
|
|
||||||
label: 'Ticket close',
|
|
||||||
field: 'closing_date'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'price_per_ticket',
|
|
||||||
align: 'left',
|
|
||||||
label: 'Price',
|
|
||||||
field: 'price_per_ticket'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'amount_tickets',
|
|
||||||
align: 'left',
|
|
||||||
label: 'No tickets',
|
|
||||||
field: 'amount_tickets'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'sold',
|
|
||||||
align: 'left',
|
|
||||||
label: 'Sold',
|
|
||||||
field: 'sold'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
pagination: {
|
|
||||||
rowsPerPage: 10
|
|
||||||
}
|
|
||||||
},
|
|
||||||
ticketsTable: {
|
|
||||||
columns: [
|
|
||||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
|
||||||
{name: 'event', align: 'left', label: 'Event', field: 'event'},
|
|
||||||
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
|
||||||
{name: 'email', align: 'left', label: 'Email', field: 'email'},
|
|
||||||
{
|
|
||||||
name: 'registered',
|
|
||||||
align: 'left',
|
|
||||||
label: 'Registered',
|
|
||||||
field: 'registered'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
pagination: {
|
|
||||||
rowsPerPage: 10
|
|
||||||
}
|
|
||||||
},
|
|
||||||
formDialog: {
|
|
||||||
show: false,
|
|
||||||
data: {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
getTickets: function () {
|
|
||||||
var self = this
|
|
||||||
LNbits.api
|
|
||||||
.request(
|
|
||||||
'GET',
|
|
||||||
'/events/api/v1/tickets?all_wallets=true',
|
|
||||||
this.g.user.wallets[0].inkey
|
|
||||||
)
|
|
||||||
.then(function (response) {
|
|
||||||
console.log(response)
|
|
||||||
self.tickets = response.data.map(function (obj) {
|
|
||||||
console.log(obj)
|
|
||||||
return mapEvents(obj)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
deleteTicket: function (ticketId) {
|
|
||||||
var self = this
|
|
||||||
var tickets = _.findWhere(this.tickets, {id: ticketId})
|
|
||||||
|
|
||||||
LNbits.utils
|
|
||||||
.confirmDialog('Are you sure you want to delete this ticket')
|
|
||||||
.onOk(function () {
|
|
||||||
LNbits.api
|
|
||||||
.request(
|
|
||||||
'DELETE',
|
|
||||||
'/events/api/v1/tickets/' + ticketId,
|
|
||||||
_.findWhere(self.g.user.wallets, {id: tickets.wallet}).inkey
|
|
||||||
)
|
|
||||||
.then(function (response) {
|
|
||||||
self.tickets = _.reject(self.tickets, function (obj) {
|
|
||||||
return obj.id == ticketId
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch(function (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
exportticketsCSV: function () {
|
|
||||||
LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets)
|
|
||||||
},
|
|
||||||
|
|
||||||
getEvents: function () {
|
|
||||||
var self = this
|
|
||||||
|
|
||||||
LNbits.api
|
|
||||||
.request(
|
|
||||||
'GET',
|
|
||||||
'/events/api/v1/events?all_wallets=true',
|
|
||||||
this.g.user.wallets[0].inkey
|
|
||||||
)
|
|
||||||
.then(function (response) {
|
|
||||||
self.events = response.data.map(function (obj) {
|
|
||||||
return mapEvents(obj)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
sendEventData: function () {
|
|
||||||
var wallet = _.findWhere(this.g.user.wallets, {
|
|
||||||
id: this.formDialog.data.wallet
|
|
||||||
})
|
|
||||||
var data = this.formDialog.data
|
|
||||||
|
|
||||||
if (data.id) {
|
|
||||||
this.updateEvent(wallet, data)
|
|
||||||
} else {
|
|
||||||
this.createEvent(wallet, data)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
createEvent: function (wallet, data) {
|
|
||||||
var self = this
|
|
||||||
LNbits.api
|
|
||||||
.request('POST', '/events/api/v1/events', wallet.inkey, data)
|
|
||||||
.then(function (response) {
|
|
||||||
self.events.push(mapEvents(response.data))
|
|
||||||
self.formDialog.show = false
|
|
||||||
self.formDialog.data = {}
|
|
||||||
})
|
|
||||||
.catch(function (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
updateformDialog: function (formId) {
|
|
||||||
var link = _.findWhere(this.events, {id: formId})
|
|
||||||
console.log(link.id)
|
|
||||||
this.formDialog.data.id = link.id
|
|
||||||
this.formDialog.data.wallet = link.wallet
|
|
||||||
this.formDialog.data.name = link.name
|
|
||||||
this.formDialog.data.info = link.info
|
|
||||||
this.formDialog.data.closing_date = link.closing_date
|
|
||||||
this.formDialog.data.event_start_date = link.event_start_date
|
|
||||||
this.formDialog.data.event_end_date = link.event_end_date
|
|
||||||
this.formDialog.data.amount_tickets = link.amount_tickets
|
|
||||||
this.formDialog.data.price_per_ticket = link.price_per_ticket
|
|
||||||
this.formDialog.show = true
|
|
||||||
},
|
|
||||||
updateEvent: function (wallet, data) {
|
|
||||||
var self = this
|
|
||||||
console.log(data)
|
|
||||||
|
|
||||||
LNbits.api
|
|
||||||
.request(
|
|
||||||
'PUT',
|
|
||||||
'/events/api/v1/events/' + data.id,
|
|
||||||
wallet.inkey,
|
|
||||||
data
|
|
||||||
)
|
|
||||||
.then(function (response) {
|
|
||||||
self.events = _.reject(self.events, function (obj) {
|
|
||||||
return obj.id == data.id
|
|
||||||
})
|
|
||||||
self.events.push(mapEvents(response.data))
|
|
||||||
self.formDialog.show = false
|
|
||||||
self.formDialog.data = {}
|
|
||||||
})
|
|
||||||
.catch(function (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
deleteEvent: function (eventsId) {
|
|
||||||
var self = this
|
|
||||||
var events = _.findWhere(this.events, {id: eventsId})
|
|
||||||
|
|
||||||
LNbits.utils
|
|
||||||
.confirmDialog('Are you sure you want to delete this form link?')
|
|
||||||
.onOk(function () {
|
|
||||||
LNbits.api
|
|
||||||
.request(
|
|
||||||
'DELETE',
|
|
||||||
'/events/api/v1/events/' + eventsId,
|
|
||||||
_.findWhere(self.g.user.wallets, {id: events.wallet}).inkey
|
|
||||||
)
|
|
||||||
.then(function (response) {
|
|
||||||
self.events = _.reject(self.events, function (obj) {
|
|
||||||
return obj.id == eventsId
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch(function (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
exporteventsCSV: function () {
|
|
||||||
LNbits.utils.exportCSV(this.eventsTable.columns, this.events)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
created: function () {
|
|
||||||
if (this.g.user.wallets.length) {
|
|
||||||
this.getTickets()
|
|
||||||
this.getEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,176 +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
|
|
||||||
:data="tickets"
|
|
||||||
row-key="id"
|
|
||||||
:columns="ticketsTable.columns"
|
|
||||||
:pagination.sync="ticketsTable.pagination"
|
|
||||||
>
|
|
||||||
{% raw %}
|
|
||||||
<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">
|
|
||||||
{{ col.label }}
|
|
||||||
</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">
|
|
||||||
{{ col.value }}
|
|
||||||
</q-td>
|
|
||||||
</q-tr>
|
|
||||||
</template>
|
|
||||||
{% endraw %}
|
|
||||||
</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
|
|
||||||
@decode="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>
|
|
||||||
Vue.component(VueQrcode.name, VueQrcode)
|
|
||||||
Vue.use(VueQrcodeReader)
|
|
||||||
var mapEvents = function (obj) {
|
|
||||||
obj.date = Quasar.utils.date.formatDate(
|
|
||||||
new Date(obj.time * 1000),
|
|
||||||
'YYYY-MM-DD HH:mm'
|
|
||||||
)
|
|
||||||
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
|
|
||||||
obj.displayUrl = ['/events/', obj.id].join('')
|
|
||||||
return obj
|
|
||||||
}
|
|
||||||
new Vue({
|
|
||||||
el: '#vue',
|
|
||||||
mixins: [windowMixin],
|
|
||||||
data: function () {
|
|
||||||
return {
|
|
||||||
tickets: [],
|
|
||||||
ticketsTable: {
|
|
||||||
columns: [
|
|
||||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
|
||||||
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
|
||||||
{
|
|
||||||
name: 'registered',
|
|
||||||
align: 'left',
|
|
||||||
label: 'Registered',
|
|
||||||
field: 'registered'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
pagination: {
|
|
||||||
rowsPerPage: 10
|
|
||||||
}
|
|
||||||
},
|
|
||||||
sendCamera: {
|
|
||||||
show: false,
|
|
||||||
camera: 'auto'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
hoverEmail: function (tmp) {
|
|
||||||
this.tickets.data.emailtemp = tmp
|
|
||||||
},
|
|
||||||
closeCamera: function () {
|
|
||||||
this.sendCamera.show = false
|
|
||||||
},
|
|
||||||
showCamera: function () {
|
|
||||||
this.sendCamera.show = true
|
|
||||||
},
|
|
||||||
decodeQR: function (res) {
|
|
||||||
this.sendCamera.show = false
|
|
||||||
var self = this
|
|
||||||
|
|
||||||
LNbits.api
|
|
||||||
.request(
|
|
||||||
'GET',
|
|
||||||
'/events/api/v1/register/ticket/' + res.split('//')[1]
|
|
||||||
)
|
|
||||||
.then(function (response) {
|
|
||||||
self.$q.notify({
|
|
||||||
type: 'positive',
|
|
||||||
message: 'Registered!'
|
|
||||||
})
|
|
||||||
setTimeout(function () {
|
|
||||||
window.location.reload()
|
|
||||||
}, 2000)
|
|
||||||
})
|
|
||||||
.catch(function (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
getEventTickets: function () {
|
|
||||||
var self = this
|
|
||||||
console.log('obj')
|
|
||||||
LNbits.api
|
|
||||||
.request(
|
|
||||||
'GET',
|
|
||||||
'/events/api/v1/eventtickets/{{ wallet_id }}/{{ event_id }}'
|
|
||||||
)
|
|
||||||
.then(function (response) {
|
|
||||||
self.tickets = response.data.map(function (obj) {
|
|
||||||
return mapEvents(obj)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch(function (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
created: function () {
|
|
||||||
this.getEventTickets()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,41 +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 />
|
|
||||||
<q-responsive :ratio="1" class="q-mb-md" style="max-width: 300px">
|
|
||||||
<qrcode :value="'ticket://{{ ticket_id }}'" :options="{width: 500}"></qrcode>
|
|
||||||
</q-responsive>
|
|
||||||
<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>
|
|
||||||
Vue.component(VueQrcode.name, VueQrcode)
|
|
||||||
new Vue({
|
|
||||||
el: '#vue',
|
|
||||||
mixins: [windowMixin],
|
|
||||||
data: function () {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
printWindow: function () {
|
|
||||||
window.print()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
11
tests/test_init.py
Normal file
11
tests/test_init.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import pytest
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from .. import events_ext
|
||||||
|
|
||||||
|
|
||||||
|
# just import router and add it to a test router
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_router():
|
||||||
|
router = APIRouter()
|
||||||
|
router.include_router(events_ext)
|
||||||
32
tests/test_nostr_timestamp.py
Normal file
32
tests/test_nostr_timestamp.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
from itertools import pairwise
|
||||||
|
|
||||||
|
from ..nostr_timestamp import monotonic_created_at
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_prior_uses_now():
|
||||||
|
assert monotonic_created_at(None, now=1000) == 1000
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_second_bumps_past_prior():
|
||||||
|
# now == last: a naive int(time.time()) would tie and the relay would
|
||||||
|
# drop the update; we must produce a strictly newer stamp.
|
||||||
|
assert monotonic_created_at(1000, now=1000) == 1001
|
||||||
|
|
||||||
|
|
||||||
|
def test_tracks_wallclock_once_seconds_elapse():
|
||||||
|
assert monotonic_created_at(1000, now=1005) == 1005
|
||||||
|
|
||||||
|
|
||||||
|
def test_steps_past_future_dated_prior():
|
||||||
|
# clock skew / rapid bursts left the stored value ahead of now
|
||||||
|
assert monotonic_created_at(2000, now=1000) == 2001
|
||||||
|
|
||||||
|
|
||||||
|
def test_strictly_increasing_same_second_burst():
|
||||||
|
last = None
|
||||||
|
stamps = []
|
||||||
|
for _ in range(5):
|
||||||
|
last = monotonic_created_at(last, now=1000) # clock frozen at 1000
|
||||||
|
stamps.append(last)
|
||||||
|
assert stamps == [1000, 1001, 1002, 1003, 1004]
|
||||||
|
assert all(b > a for a, b in pairwise(stamps))
|
||||||
29
toc.md
Normal file
29
toc.md
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Terms and Conditions for LNbits Extension
|
||||||
|
|
||||||
|
## 1. Acceptance of Terms
|
||||||
|
|
||||||
|
By installing and using the LNbits extension ("Extension"), you agree to be bound by these terms and conditions ("Terms"). If you do not agree to these Terms, do not use the Extension.
|
||||||
|
|
||||||
|
## 2. License
|
||||||
|
|
||||||
|
The Extension is free and open-source software, released under [specify the FOSS license here, e.g., GPL-3.0, MIT, etc.]. You are permitted to use, copy, modify, and distribute the Extension under the terms of that license.
|
||||||
|
|
||||||
|
## 3. No Warranty
|
||||||
|
|
||||||
|
The Extension is provided "as is" and with all faults, and the developer expressly disclaims all warranties of any kind, whether express, implied, statutory, or otherwise, including but not limited to warranties of merchantability, fitness for a particular purpose, non-infringement, and any warranties arising out of course of dealing or usage of trade. No advice or information, whether oral or written, obtained from the developer or elsewhere will create any warranty not expressly stated in this Terms.
|
||||||
|
|
||||||
|
## 4. Limitation of Liability
|
||||||
|
|
||||||
|
In no event will the developer be liable to you or any third party for any direct, indirect, incidental, special, consequential, or punitive damages, including lost profit, lost revenue, loss of data, or other damages arising out of or in connection with your use of the Extension, even if the developer has been advised of the possibility of such damages. The foregoing limitation of liability shall apply to the fullest extent permitted by law in the applicable jurisdiction.
|
||||||
|
|
||||||
|
## 5. Modification of Terms
|
||||||
|
|
||||||
|
The developer reserves the right to modify these Terms at any time. You are advised to review these Terms periodically for any changes. Changes to these Terms are effective when they are posted on the appropriate location within or associated with the Extension.
|
||||||
|
|
||||||
|
## 6. General Provisions
|
||||||
|
|
||||||
|
If any provision of these Terms is held to be invalid or unenforceable, that provision will be enforced to the maximum extent permissible, and the other provisions of these Terms will remain in full force and effect. These Terms constitute the entire agreement between you and the developer regarding the use of the Extension.
|
||||||
|
|
||||||
|
## 7. Contact Information
|
||||||
|
|
||||||
|
If you have any questions about these Terms, please contact the developer at [developer's contact information].
|
||||||
120
transport_rpcs.py
Normal file
120
transport_rpcs.py
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
"""
|
||||||
|
Nostr-transport RPC handlers for the aiolabs/events extension.
|
||||||
|
|
||||||
|
Each handler is registered with `lnbits.core.services.nostr_transport.
|
||||||
|
dispatcher.register_rpc` in `events_start()`. The dispatcher resolves
|
||||||
|
the caller's Nostr pubkey to an LNbits Account → wallet (`AUTH_WALLET`)
|
||||||
|
and passes a `WalletTypeInfo` as the first argument; handlers verify
|
||||||
|
event-level ownership on top.
|
||||||
|
|
||||||
|
Errors raise `PermissionError` / `ValueError` so the dispatcher maps
|
||||||
|
them into `{status: "ERROR", error: <msg>}` responses; any other
|
||||||
|
exception falls through to a generic "Internal error" reply.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from lnbits.core.crud import get_user
|
||||||
|
from lnbits.core.models import WalletTypeInfo
|
||||||
|
from lnbits.core.services.nostr_transport.models import NostrRpcRequest
|
||||||
|
|
||||||
|
from .crud import get_event, get_ticket, get_tickets_by_event, update_ticket
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_events_ticket_register(
|
||||||
|
auth: WalletTypeInfo,
|
||||||
|
request: NostrRpcRequest,
|
||||||
|
) -> dict:
|
||||||
|
"""Mark a ticket as registered at the door (organizer flow).
|
||||||
|
|
||||||
|
The Nostr-transport dispatcher already verified the caller signed
|
||||||
|
the kind-21000 RPC event and bound them to `auth.wallet`. This
|
||||||
|
handler adds the event-level check: the ticket's event must be
|
||||||
|
owned by one of the caller's wallets.
|
||||||
|
|
||||||
|
Idempotence mirrors the HTTP endpoint: scanning the same ticket
|
||||||
|
twice fails with "Ticket already registered". The buyer-side flow
|
||||||
|
(notifications etc.) reuses whatever the legacy register endpoint
|
||||||
|
does — we just flip the flag + timestamp.
|
||||||
|
"""
|
||||||
|
body = request.body or {}
|
||||||
|
event_id = body.get("event_id")
|
||||||
|
ticket_id = body.get("ticket_id")
|
||||||
|
if not event_id or not ticket_id:
|
||||||
|
raise ValueError("event_id and ticket_id are required")
|
||||||
|
|
||||||
|
ticket = await get_ticket(ticket_id)
|
||||||
|
if not ticket or ticket.event != event_id:
|
||||||
|
raise ValueError("Ticket does not exist on this event")
|
||||||
|
if not ticket.paid:
|
||||||
|
raise PermissionError("Ticket not paid for")
|
||||||
|
if ticket.registered:
|
||||||
|
raise PermissionError("Ticket already registered")
|
||||||
|
|
||||||
|
event = await get_event(event_id)
|
||||||
|
if not event:
|
||||||
|
raise ValueError("Event does not exist")
|
||||||
|
|
||||||
|
user = await get_user(auth.wallet.user)
|
||||||
|
owned_wallet_ids = user.wallet_ids if user else [auth.wallet.id]
|
||||||
|
if event.wallet not in owned_wallet_ids:
|
||||||
|
raise PermissionError("You do not own this event")
|
||||||
|
|
||||||
|
ticket.registered = True
|
||||||
|
ticket.reg_timestamp = datetime.now(timezone.utc)
|
||||||
|
await update_ticket(ticket)
|
||||||
|
return ticket.dict()
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_events_list_event_tickets(
|
||||||
|
auth: WalletTypeInfo,
|
||||||
|
request: NostrRpcRequest,
|
||||||
|
) -> dict:
|
||||||
|
"""Return paid + registered counts plus the per-ticket roster for
|
||||||
|
one calendar event, organizer-only.
|
||||||
|
|
||||||
|
Backs the door scanner's counts strip and "All scanned" tab so the
|
||||||
|
UI reads authoritative state from the backend instead of relying
|
||||||
|
on per-device localStorage (which diverges the moment a second
|
||||||
|
organizer scans, or the operator switches devices).
|
||||||
|
|
||||||
|
The roster only includes paid tickets — proposed/unpaid rows are
|
||||||
|
irrelevant at the door.
|
||||||
|
"""
|
||||||
|
body = request.body or {}
|
||||||
|
event_id = body.get("event_id")
|
||||||
|
if not event_id:
|
||||||
|
raise ValueError("event_id is required")
|
||||||
|
|
||||||
|
event = await get_event(event_id)
|
||||||
|
if not event:
|
||||||
|
raise ValueError("Event does not exist")
|
||||||
|
|
||||||
|
user = await get_user(auth.wallet.user)
|
||||||
|
owned_wallet_ids = user.wallet_ids if user else [auth.wallet.id]
|
||||||
|
if event.wallet not in owned_wallet_ids:
|
||||||
|
raise PermissionError("You do not own this event")
|
||||||
|
|
||||||
|
tickets = await get_tickets_by_event(event_id)
|
||||||
|
paid_tickets = [t for t in tickets if t.paid]
|
||||||
|
registered_count = sum(1 for t in paid_tickets if t.registered)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"event_id": event_id,
|
||||||
|
"sold": len(paid_tickets),
|
||||||
|
"registered": registered_count,
|
||||||
|
"remaining": len(paid_tickets) - registered_count,
|
||||||
|
"tickets": [
|
||||||
|
{
|
||||||
|
"id": t.id,
|
||||||
|
"name": t.name,
|
||||||
|
"registered": t.registered,
|
||||||
|
"registered_at": (
|
||||||
|
t.reg_timestamp.isoformat() if t.reg_timestamp else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for t in paid_tickets
|
||||||
|
],
|
||||||
|
}
|
||||||
120
views.py
120
views.py
|
|
@ -1,106 +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 Depends, Request
|
events_generic_router = APIRouter()
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from starlette.exceptions import HTTPException
|
|
||||||
from starlette.responses import HTMLResponse
|
|
||||||
|
|
||||||
from lnbits.core.models import User
|
events_generic_router.add_api_route(
|
||||||
from lnbits.decorators import check_user_exists
|
"/",
|
||||||
|
methods=["GET"],
|
||||||
|
endpoint=index,
|
||||||
|
dependencies=[Depends(check_account_id_exists)],
|
||||||
|
)
|
||||||
|
|
||||||
from . import events_ext, events_renderer
|
events_generic_router.add_api_route(
|
||||||
from .crud import get_event, get_ticket
|
"/{event_id}", methods=["GET"], endpoint=index_public
|
||||||
|
)
|
||||||
|
|
||||||
templates = Jinja2Templates(directory="templates")
|
events_generic_router.add_api_route(
|
||||||
|
"/ticket/{ticket_id}", methods=["GET"], endpoint=index_public
|
||||||
|
)
|
||||||
|
|
||||||
|
events_generic_router.add_api_route(
|
||||||
@events_ext.get("/", response_class=HTMLResponse)
|
"/register/{event_id}", methods=["GET"], endpoint=index_public
|
||||||
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,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
|
||||||
974
views_api.py
974
views_api.py
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue