Compare commits

..

38 commits

Author SHA1 Message Date
ef5d2dcfcf feat: wire Nostr subscription sync into extension lifecycle
Some checks failed
lint.yml / feat: wire Nostr subscription sync into extension lifecycle (pull_request) Failing after 0s
lint.yml / feat: wire Nostr subscription sync into extension lifecycle (push) Failing after 0s
Add background task that subscribes to kind 31922/31923 events
from relays and processes them into the local database. Starts
15s after NostrClient connects (sequenced after publish client).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:30:00 +02:00
e937883564 feat: add NIP-52 event sync from Nostr relays
Subscribe to kind 31922/31923 events and upsert into local DB:
- New events discovered from relays are auto-approved
- Existing events are updated if incoming version is newer
- Deduplication via event ID and d-tag correlation
- Events from Nostr have empty wallet (not ticketed locally)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:29:14 +02:00
1bddb99132 feat: upgrade NostrClient to bidirectional (publish + subscribe)
Add receive queue, subscription management, and event deduplication
to support incoming NIP-52 calendar events from relays.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:28:21 +02:00
4d91426e82 refactor: consolidate create and propose endpoints into single POST /events
Some checks failed
lint.yml / refactor: consolidate create and propose endpoints into single POST /events (pull_request) Failing after 0s
Remove separate /events/propose endpoint. POST /events now uses
invoice key (any user) and determines approval status based on:
- LNbits admin → auto-approved
- auto_approve setting → auto-approved
- Otherwise → proposed (requires admin approval)

Separate PUT /events/{id} for updates (admin key, event owner).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:24:10 +02:00
b4d7653988 fix: check auto_approve setting in propose endpoint
Some checks failed
lint.yml / fix: check auto_approve setting in propose endpoint (pull_request) Failing after 0s
The propose endpoint always set status to 'proposed' regardless of
the auto_approve setting. Now checks the setting and auto-approves
(+ publishes to Nostr) when enabled.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:16:43 +02:00
29045163a3 feat: add location and categories fields, simplify event creation
Some checks failed
lint.yml / feat: add location and categories fields, simplify event creation (pull_request) Failing after 0s
- Add location (text) and categories (JSON list) to Event model
- Make most CreateEvent fields optional: only title + start date required
- Default end_date to start_date, closing_date to end_date
- Categories stored as JSON text, parsed via validator
- NIP-52 publisher includes location tag and t tags for categories
- Migration m011 adds location and categories columns

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:05:25 +02:00
d69ec7dda2 feat: add admin-toggleable auto-approve setting
Some checks failed
lint.yml / feat: add admin-toggleable auto-approve setting (pull_request) Failing after 0s
- Extension settings table with auto_approve boolean
- GET/PUT /api/v1/settings endpoints (LNbits admin only)
- Settings card in admin UI with toggle
- When auto_approve is enabled, non-admin events skip approval

Closes aiolabs/events#11

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 17:59:59 +02:00
2db0102857 feat: publish NIP-52 events on approve/create/update/cancel/delete
Some checks failed
lint.yml / feat: publish NIP-52 events on approve/create/update/cancel/delete (pull_request) Failing after 0s
- On approve: publish kind 31922 calendar event to Nostr
- On admin create (auto-approved): publish immediately
- On update (approved event): republish (kind 31922 is replaceable)
- On cancel/delete: publish kind 5 delete event
- All Nostr calls are wrapped in try/except for graceful degradation
- Event creator's Account keypair used for signing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 17:24:17 +02:00
e8fcecac40 feat: wire NostrClient into events extension lifecycle
Start a publish-only NostrClient as a background task (10s delay
for nostrclient readiness). Graceful degradation: if nostrclient
is unavailable, events extension continues without Nostr publishing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 17:21:28 +02:00
5013709be7 feat: add NIP-52 calendar event builder and publisher
- build_nip52_event(): Event model → kind 31922 NostrEvent with
  d, title, start, end, image tags
- build_nip52_delete_event(): kind 5 delete with 'a' tag per NIP-09
- sign_nostr_event(): Schnorr signing via coincurve
- publish_event_to_nostr(): build + sign + publish, returns event
  for metadata storage. Graceful failure (returns None).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 17:14:22 +02:00
f76e21e960 feat: add Nostr event tracking columns to events table
Migration m009 adds nostr_event_id and nostr_event_created_at to
track published NIP-52 calendar events. Enables correlation between
LNbits events and their Nostr representations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 17:13:36 +02:00
f965cf07c9 feat: add publish-only NostrClient and NostrEvent model
Stripped-down Nostr client that connects to nostrclient's internal
WebSocket for publishing NIP-52 calendar events. No subscription
capabilities — publish queue only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 17:11:55 +02:00
1ad99aa3d6 fix: hide approve/reject buttons for non-admin users
Some checks failed
lint.yml / fix: hide approve/reject buttons for non-admin users (pull_request) Failing after 0s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 11:45:08 +02:00
920125aaee feat: auto-propose events from non-admin users
Some checks failed
lint.yml / feat: auto-propose events from non-admin users (pull_request) Failing after 0s
Events created by non-admin users via POST /events are now set to
'proposed' status, requiring LNbits admin approval. Admin-created
events are auto-approved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 11:39:01 +02:00
ba97205592 feat: separate admin view into own events and all users' events
Some checks failed
lint.yml / feat: separate admin view into own events and all users' events (pull_request) Failing after 0s
Admin sees two tables: "Events" (own wallet events) and "All Users'
Events" (events from other users' wallets, admin only). Non-admin
users only see their own events table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 11:34:59 +02:00
c1e66fbf7f fix: use check_admin for approval endpoints, not require_admin_key
Some checks failed
lint.yml / fix: use check_admin for approval endpoints, not require_admin_key (pull_request) Failing after 0s
require_admin_key only checks that the API key is a wallet admin key,
which ANY user has. check_admin verifies the user is a LNbits admin
(super_user or lnbits_admin_users). JS updated to omit API key on
admin endpoints, relying on session cookie auth instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 11:27:21 +02:00
7843da21d8 feat: add admin endpoint to view all events across wallets
Some checks failed
lint.yml / feat: add admin endpoint to view all events across wallets (pull_request) Failing after 0s
- GET /api/v1/events/all — returns all events regardless of wallet (admin key)
- Admin UI tries /events/all first, falls back to own wallet events
- Approved events from other users now visible in admin events table

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 11:19:21 +02:00
b467826622 fix: fetch pending events separately from admin's own events
Some checks failed
lint.yml / fix: fetch pending events separately from admin's own events (pull_request) Failing after 0s
Pending events from other users' wallets weren't visible because
getEvents() only returns events scoped to the admin's wallets.
Add separate getPendingEvents() that calls /events/pending endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 11:16:29 +02:00
d740cb1f97 fix: close self-closing q-badge tag in status column
Some checks failed
lint.yml / fix: close self-closing q-badge tag in status column (pull_request) Failing after 0s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 11:11:13 +02:00
3425097a5c fix: close remaining self-closing q-btn tags in pending approvals
Some checks failed
lint.yml / fix: close remaining self-closing q-btn tags in pending approvals (pull_request) Failing after 0s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 11:09:39 +02:00
cdfcee39ae fix: use explicit closing tags for Vue custom elements
Some checks failed
lint.yml / fix: use explicit closing tags for Vue custom elements (pull_request) Failing after 0s
Self-closing tags on custom elements (q-icon, q-badge) cause
Vue compiler-30 (missing end tag) errors in HTML-parsed templates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 10:48:02 +02:00
bdd49f8612 fix: use v-text bindings instead of raw template tags in pending UI
Some checks failed
lint.yml / fix: use v-text bindings instead of raw template tags in pending UI (pull_request) Failing after 0s
Avoids Jinja/Vue template delimiter conflicts that cause Vue
compiler-30 errors (missing end tag from unescaped > in expressions).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 10:41:41 +02:00
702ab70559 feat: add pending approvals UI to admin panel
Some checks failed
lint.yml / feat: add pending approvals UI to admin panel (pull_request) Failing after 0s
- Separate "Pending Approvals" card with approve/reject buttons
  (appears only when proposed events exist)
- Status badge column in events table (green/orange/red)
- Inline approve/reject buttons on proposed events in table
- Following castle extension's approval UI pattern

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 10:37:48 +02:00
32ea79a137 fix: make wallet optional in CreateEvent for propose endpoint
Some checks failed
lint.yml / fix: make wallet optional in CreateEvent for propose endpoint (pull_request) Failing after 0s
The propose endpoint sets wallet from the authenticated user's
invoice key. Making wallet optional in the model allows the
request body to omit it. The admin create endpoint falls back
to the auth wallet if not provided.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 10:32:16 +02:00
41e64adfde fix: resolve lint errors in views_api.py
Some checks failed
lint.yml / fix: resolve lint errors in views_api.py (pull_request) Failing after 0s
- Remove unused purge_unpaid_tickets import (add TODO comment)
- Break long line in ticket GET endpoint signature

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:08:23 +02:00
eb474b1390 fix: make promo_code and refund_address optional query params
Some checks failed
lint.yml / fix: make promo_code and refund_address optional query params (pull_request) Failing after 0s
These were required query params on the GET ticket endpoint,
causing 400 errors when not provided.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:04:46 +02:00
a41348df94 feat: add event proposal and approval API endpoints
- POST /api/v1/events/propose — submit event for approval (invoice key)
- GET /api/v1/events/pending — list proposed events (admin key)
- PUT /api/v1/events/{id}/approve — approve proposed event (admin key)
- PUT /api/v1/events/{id}/reject — reject proposed event (admin key)
- GET /api/v1/events/public — now returns only approved, non-canceled events

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:04:25 +02:00
0c782e6239 feat: add CRUD functions for public and pending event queries
- get_public_events(): returns approved, non-canceled events
- get_pending_events(): returns proposed events for admin review

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:02:18 +02:00
1dcff37df5 feat: add status field to Event model for approval workflow
Add 'status' column (proposed/approved/rejected) to the events
table with default 'approved' for backward compatibility. Existing
events are unaffected.

Migration m008 adds the column.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:01:50 +02:00
68e6e3d02e fix: Parse JSON extra field when reading tickets from database
Some checks failed
lint / lint (push) Has been cancelled
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
The previous fix used db.insert() which serializes the extra field to JSON.
However, the read functions (get_ticket, get_tickets, etc.) use fetchone/fetchall
without a model parameter, so the extra field comes back as a JSON string.

Added _parse_ticket_row() helper that:
- Converts empty strings to None for name/email (existing logic)
- Parses extra field from JSON string if needed (new)

The isinstance(extra, str) check ensures compatibility with both:
- SQLite: returns JSON as string
- PostgreSQL/CockroachDB: may return native JSONB as dict

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:03:34 +01:00
a77145e08e fix: Use db.insert() for ticket creation to fix SQLite serialization
Some checks failed
lint / lint (push) Waiting to run
lint / lint (pull_request) Has been cancelled
The previous implementation used db.execute() with a raw dict, which
failed on SQLite because the 'extra' field (TicketExtra model) was
passed as a Python dict that SQLite cannot serialize.

Using db.insert() with the Pydantic model ensures proper JSON
serialization of the extra field across all database backends
(SQLite, PostgreSQL, CockroachDB).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:48:46 +01:00
c49abdb53f Fix SQLite migration syntax error in m007
Some checks failed
lint / lint (push) Has been cancelled
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
SQLite doesn't support adding multiple columns in a single ALTER TABLE
statement. Split into separate statements for each column.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 17:07:34 +01:00
4fb6d90fcd Adds public events endpoint and user tickets
Some checks are pending
lint / lint (push) Waiting to run
/ release (push) Waiting to run
/ pullrequest (push) Blocked by required conditions
Adds a public events endpoint that allows read-only access to all events.
Improves ticket management by adding support for user IDs as an identifier, alongside name and email.
This simplifies ticket creation for authenticated users and enhances security.
Also introduces an API endpoint to fetch tickets by user ID.
2025-12-31 12:54:12 +01:00
Tiago Vasconcelos
a9ac6dcfc1 feat: add promo codes and conditional events (#40)
* add extra column
* add conditional events
* refunds
* conditional events working
* adding promo codes
* promo codes logic

---------

Co-authored-by: dni  <office@dnilabs.com>
2025-12-31 12:52:32 +01:00
arbadacarba
44f2cb5a62 Fix typos (#39) 2025-12-31 12:49:29 +01:00
33977c53d6 Imports Optional type hint
Imports the `Optional` type hint from the `typing` module into `crud.py` and `models.py`.

This provides more explicit type annotations where values can be `None`.
2025-11-04 01:42:14 +01:00
7cc622fc44 Merge remote-tracking branch 'upstream/main' 2025-11-03 23:13:22 +01:00
c669da5822 Adds public events endpoint and user tickets
Adds a public events endpoint that allows read-only access to all events.
Improves ticket management by adding support for user IDs as an identifier, alongside name and email.
This simplifies ticket creation for authenticated users and enhances security.
Also introduces an API endpoint to fetch tickets by user ID.
2025-11-03 23:05:31 +01:00
39 changed files with 3035 additions and 4486 deletions

56
API_DOCUMENTATION.md Normal file
View file

@ -0,0 +1,56 @@
# Events API Documentation
## Public Events Endpoint
### GET `/api/v1/events/public`
Retrieve all events in the database with read-only access. No authentication required.
**Authentication:** None required (public endpoint)
**Headers:**
```
None required
```
**Query Parameters:**
- None
**Response:**
```json
[
{
"id": "event_id",
"wallet": "wallet_id",
"name": "Event Name",
"info": "Event description",
"closing_date": "2024-12-31",
"event_start_date": "2024-12-01",
"event_end_date": "2024-12-02",
"currency": "sat",
"amount_tickets": 100,
"price_per_ticket": 1000.0,
"time": "2024-01-01T00:00:00Z",
"sold": 0,
"banner": null
}
]
```
**Example Usage:**
```bash
curl http://your-lnbits-instance/events/api/v1/events/public
```
**Notes:**
- This endpoint allows read-only access to all events in the database
- No authentication required (truly public endpoint)
- Returns events ordered by creation time (newest first)
- Suitable for public event listings or read-only integrations
## Comparison with Existing Endpoints
| Endpoint | Authentication | Scope | Use Case |
|----------|---------------|-------|----------|
| `/api/v1/events` | Invoice Key | User's wallets only | Private event management |
| `/api/v1/events/public` | None | All events | Public event browsing |

View file

@ -1,20 +1,10 @@
<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: MIT](https://img.shields.io/badge/License-MIT-success?logo=open-source-initiative&logoColor=white)](./LICENSE)
[![Built for LNbits](https://img.shields.io/badge/Built%20for-LNbits-4D4DFF?logo=lightning&logoColor=white)](https://github.com/lnbits/lnbits)
# 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>
## Sell tickets for events and use the built-in scanner for registering attendees
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 alows you to make tickets for an event. Each ticket is in the form of a unique QR code. After registering, and paying for ticket, the user gets a QR code to present at registration/entrance.
Events includes a shareable ticket scanner, which can be used to register attendees.
@ -43,10 +33,3 @@ 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\
![ticket scanner](https://i.imgur.com/zrm9202.jpg)
## Powered by LNbits
[LNbits](https://lnbits.com) is a free and open-source lightning accounts system.
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)

View file

@ -6,12 +6,11 @@ from loguru import logger
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
from .views_api import events_api_router
events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"])
events_ext.include_router(events_generic_router)
events_ext.include_router(events_api_router)
events_ext.include_router(tickets_api_router)
events_static_files = [
{
@ -22,9 +21,7 @@ events_static_files = [
scheduled_tasks: list[asyncio.Task] = []
# Module-level NostrClient — None when nostrclient is unavailable. Set by the
# bootstrap task in events_start() and read via dynamic attribute lookup
# from nostr_hooks.publish_or_delete_nostr_event.
# Module-level NostrClient — None when nostrclient is unavailable
nostr_client = None
@ -46,38 +43,6 @@ def events_start():
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
@ -87,8 +52,8 @@ def events_start():
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}")
except Exception as e:
logger.warning(f"[EVENTS] NostrClient failed to start: {e}")
logger.info("[EVENTS] Events will work without Nostr sync")
task2 = create_permanent_unique_task("ext_events_nostr", _start_nostr_client)
@ -104,10 +69,12 @@ def events_start():
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}")
except Exception as e:
logger.error(f"[EVENTS] Nostr sync task failed: {e}")
task3 = create_permanent_unique_task("ext_events_nostr_sync", _sync_nostr_events)
task3 = create_permanent_unique_task(
"ext_events_nostr_sync", _sync_nostr_events
)
scheduled_tasks.append(task3)

View file

@ -1,12 +1,9 @@
{
"id": "events",
"version": "1.6.1-aio.7",
"name": "Events",
"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",
"lnbits": "1.1.0",
"min_lnbits_version": "1.3.0",
"contributors": [
{
"name": "talvasconcelos",
@ -14,7 +11,7 @@
"role": "Developer"
},
{
"name": "dni",
"name": "DNI",
"uri": "https://github.com/dni",
"role": "Developer"
},
@ -32,11 +29,6 @@
"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": [
@ -59,9 +51,5 @@
],
"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
"license": "MIT"
}

121
crud.py
View file

@ -1,62 +1,56 @@
import json
from datetime import datetime, timedelta, timezone
from typing import Optional
from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash
from .models import CreateEvent, Event, EventsSettings, Ticket, TicketExtra
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.
"""
Parse a database row into a dict suitable for Ticket model creation.
Handles:
- Empty string to None conversion for name/email
- JSON string to dict conversion for extra field
"""
ticket_data = dict(row)
# Convert empty strings back to None for the model
if ticket_data.get("name") == "":
ticket_data["name"] = None
if ticket_data.get("email") == "":
ticket_data["email"] = None
# Parse extra field from JSON string if needed
# (db.insert() serializes to JSON, but manual fetchone/fetchall returns string)
extra = ticket_data.get("extra")
if isinstance(extra, str):
ticket_data["extra"] = json.loads(extra) if extra else {}
ticket_data["extra"] = json.loads(extra)
return ticket_data
db = Database("ext_events")
async def create_ticket(
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,
name: Optional[str] = None,
email: Optional[str] = None,
user_id: Optional[str] = None,
extra: Optional[dict] = None,
) -> Ticket:
"""Persist one ticket row.
`payment_hash` is the LNbits invoice hash shared across all rows
of a multi-ticket purchase. `ticket_id` is the row primary key /
scannable id; defaults to `payment_hash` for single-ticket
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.
# TODO: Check if this empty string workaround is still needed.
# This converts None to empty strings for database storage because:
# 1. Database may have NOT NULL constraints on name/email columns
# 2. When user_id is provided, name/email are not used (mutually exclusive)
# 3. The get_ticket() functions convert empty strings back to None when reading
# Consider using nullable columns instead of this empty string pattern.
if user_id:
db_name = ""
db_email = ""
@ -64,8 +58,11 @@ async def create_ticket(
db_name = name or ""
db_email = email or ""
# Create ticket with database-compatible values for insertion
# Using db.insert() ensures proper serialization of the extra field (TicketExtra)
# across all database backends (SQLite, PostgreSQL, CockroachDB)
db_ticket = Ticket(
id=row_id,
id=payment_hash,
wallet=wallet,
event=event,
name=db_name,
@ -76,12 +73,14 @@ async def create_ticket(
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 with original name/email values (not empty strings)
# This maintains consistency with how get_ticket() converts empty strings back to None
return Ticket(
id=row_id,
id=payment_hash,
wallet=wallet,
event=event,
name=name,
@ -92,42 +91,34 @@ async def create_ticket(
reg_timestamp=now,
time=now,
extra=TicketExtra(**extra) if extra else TicketExtra(),
payment_hash=payment_hash,
)
async def update_ticket(ticket: Ticket) -> Ticket:
# Create a new Ticket object with corrected values for database constraints
ticket_dict = ticket.dict()
# Convert None values to empty strings for database constraints
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))
# Create a new Ticket object with the corrected values
corrected_ticket = Ticket(**ticket_dict)
await db.update("events.ticket", corrected_ticket)
return ticket
async def get_tickets_by_payment_hash(payment_hash: str) -> list[Ticket]:
"""All ticket rows sharing the given LNbits invoice payment_hash.
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_ticket(payment_hash: str) -> Ticket | None:
async def get_ticket(payment_hash: str) -> Optional[Ticket]:
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))
@ -136,24 +127,17 @@ async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]:
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]
async def get_tickets_by_event(event_id: str) -> list[Ticket]:
"""All ticket rows for the given calendar event id."""
rows = await db.fetchall(
"SELECT * FROM events.ticket WHERE event = :event_id",
{"event_id": event_id},
)
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."""
"""Get all tickets for a specific user by their user_id"""
rows = await db.fetchall(
"SELECT * FROM events.ticket WHERE user_id = :user_id ORDER BY time DESC",
{"user_id": user_id},
{"user_id": user_id}
)
return [Ticket(**_parse_ticket_row(row)) for row in rows]
@ -180,9 +164,10 @@ async def purge_unpaid_tickets(event_id: str) -> None:
async def create_event(data: CreateEvent) -> Event:
event_id = urlsafe_short_hash()
# Default end_date to start_date and closing_date to end_date when omitted.
# Default end date to start date if not provided
if not data.event_end_date:
data.event_end_date = data.event_start_date
# Default closing date to end date if not provided
if not data.closing_date:
data.closing_date = data.event_end_date
event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict())
@ -214,7 +199,7 @@ async def get_events(wallet_ids: str | list[str]) -> list[Event]:
async def get_all_events() -> list[Event]:
"""All events, no wallet filter. Admin-only callers."""
"""Get all events from the database without wallet filtering."""
return await db.fetchall(
"SELECT * FROM events.events ORDER BY time DESC",
model=Event,
@ -222,7 +207,7 @@ async def get_all_events() -> list[Event]:
async def get_public_events() -> list[Event]:
"""Approved, non-canceled events for the public listing."""
"""Get approved, non-canceled events for public display."""
return await db.fetchall(
"""
SELECT * FROM events.events
@ -234,7 +219,7 @@ async def get_public_events() -> list[Event]:
async def get_pending_events() -> list[Event]:
"""Proposed events awaiting admin approval."""
"""Get proposed events awaiting admin approval."""
return await db.fetchall(
"SELECT * FROM events.events WHERE status = 'proposed' ORDER BY time DESC",
model=Event,
@ -242,7 +227,7 @@ async def get_pending_events() -> list[Event]:
async def get_settings() -> EventsSettings:
"""Singleton settings row, seeded by m010."""
"""Get extension settings (single row, always exists after migration)."""
row = await db.fetchone("SELECT * FROM events.settings WHERE id = 1")
if row:
return EventsSettings(**dict(row))
@ -250,8 +235,13 @@ async def get_settings() -> EventsSettings:
async def update_settings(settings: EventsSettings) -> EventsSettings:
"""Update extension settings."""
await db.execute(
"UPDATE events.settings SET auto_approve = :auto_approve WHERE id = 1",
"""
UPDATE events.settings
SET auto_approve = :auto_approve
WHERE id = 1
""",
{"auto_approve": settings.auto_approve},
)
return settings
@ -266,4 +256,5 @@ async def get_event_tickets(event_id: str) -> list[Ticket]:
"SELECT * FROM events.ticket WHERE event = :event",
{"event": event_id},
)
return [Ticket(**_parse_ticket_row(row)) for row in rows]

View file

@ -1,10 +1,5 @@
Sell tickets for events and manage attendee registration with a built-in QR scanner.
Sell tickets for events and use the built-in scanner for registering attendants
Its features include:
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.
- 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.
Events includes a shareable ticket scanner, which can be used to register attendees.

View file

@ -162,36 +162,84 @@ async def m005_add_image_banner(db):
await db.execute("ALTER TABLE events.events ADD COLUMN banner TEXT;")
async def m006_add_extra_fields(db):
async def m006_add_user_id_support(db):
"""
Add user_id column to tickets table to support LNbits user-id as identifier
Make name and email optional when user_id is provided
"""
await db.execute("ALTER TABLE events.ticket ADD COLUMN user_id TEXT;")
# Since SQLite doesn't support changing column constraints directly,
# we'll work around this by allowing the application logic to handle
# the validation that either (name AND email) OR user_id is provided
# The database will continue to expect name and email as NOT NULL
# but we'll insert empty strings for user_id tickets
async def m007_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
# SQLite requires separate ALTER TABLE statements for each column
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;")
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):
async def m008_add_event_status(db):
"""
Add an allow_fiat column so event owners can explicitly enable fiat checkout.
Add status column to events table for proposal/approval workflow.
Values: 'proposed', 'approved', 'rejected'.
Default 'approved' for backward compatibility with existing events.
"""
await db.execute("""
ALTER TABLE events.events
ADD COLUMN allow_fiat BOOLEAN NOT NULL DEFAULT FALSE;
""")
await db.execute(
"ALTER TABLE events.events ADD COLUMN status TEXT NOT NULL DEFAULT 'approved';"
)
async def m008_add_fiat_currency(db):
async def m009_add_nostr_columns(db):
"""
Add a fiat_currency column for sat-denominated events using fiat checkout.
Add columns to track published NIP-52 Nostr calendar events.
"""
await db.execute("""
ALTER TABLE events.events
ADD COLUMN fiat_currency TEXT NOT NULL DEFAULT 'GBP';
""")
await db.execute(
"ALTER TABLE events.events ADD COLUMN nostr_event_id TEXT;"
)
await db.execute(
"ALTER TABLE events.events ADD COLUMN nostr_event_created_at INTEGER;"
)
async def m010_add_events_settings(db):
"""
Create extension settings table for admin-configurable options.
"""
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 OR IGNORE INTO events.settings (id, auto_approve) VALUES (1, FALSE);"
)
async def m011_add_location_and_categories(db):
"""
Add location and categories columns for NIP-52 calendar event support.
"""
await db.execute(
"ALTER TABLE events.events ADD COLUMN location TEXT;"
)
await db.execute(
"ALTER TABLE events.events ADD COLUMN categories TEXT;"
)

View file

@ -1,130 +0,0 @@
"""
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 = ''"
)

142
models.py
View file

@ -1,6 +1,8 @@
import json
from datetime import datetime
from typing import Optional
from fastapi import Query
from pydantic import BaseModel, EmailStr, Field, root_validator, validator
@ -24,33 +26,46 @@ 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):
wallet: str | None = None # filled from caller's wallet if absent
wallet: Optional[str] = None
name: str # title (required)
info: str = "" # description (optional)
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_end_date: str | None = None # same format as event_start_date
info: str = "" # description (optional, visible by default)
closing_date: Optional[str] = None # defaults to event_end_date or event_start_date
event_start_date: str # required
event_end_date: Optional[str] = None # defaults to event_start_date
currency: str = "sat"
allow_fiat: bool = False
fiat_currency: str = "GBP"
amount_tickets: int = 0 # 0 = unlimited / not ticketed
price_per_ticket: float = 0 # 0 = free
banner: str | None = None
location: str | None = None # venue/address (NIP-52 'location' tag)
banner: Optional[str] = None # image URL (optional, visible by default)
location: Optional[str] = None # venue/address (optional, visible by default)
categories: list[str] = Field(default_factory=list) # NIP-52 't' tags
extra: EventExtra = Field(default_factory=EventExtra)
status: str = "approved" # proposed, approved, rejected
class CreateTicket(BaseModel):
name: Optional[str] = None
email: Optional[EmailStr] = None
user_id: Optional[str] = None
promo_code: Optional[str] = None
refund_address: Optional[str] = None
@root_validator
def validate_identifiers(cls, values):
# Ensure either (name AND email) OR user_id is provided
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 Event(BaseModel):
id: str
wallet: str
@ -61,8 +76,6 @@ class Event(BaseModel):
event_start_date: str
event_end_date: str | None = None
currency: str = "sat"
allow_fiat: bool = False
fiat_currency: str = "GBP"
amount_tickets: int = 0
price_per_ticket: float = 0
time: datetime
@ -71,7 +84,7 @@ class Event(BaseModel):
location: str | None = None
categories: list[str] = Field(default_factory=list)
extra: EventExtra = Field(default_factory=EventExtra)
status: str = "approved"
status: str = "approved" # proposed, approved, rejected
nostr_event_id: str | None = None
nostr_event_created_at: int | None = None
@ -82,113 +95,28 @@ class Event(BaseModel):
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
auto_approve: bool = False # Skip approval for all 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):
id: str
wallet: str
event: str
name: str | None = None
email: str | None = None
user_id: str | None = None
name: Optional[str] = None
email: Optional[str] = None
user_id: Optional[str] = None
registered: bool
paid: bool
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)

View file

@ -1,5 +1,6 @@
import hashlib
import json
from typing import List, Optional
from pydantic import BaseModel
@ -9,11 +10,11 @@ class NostrEvent(BaseModel):
pubkey: str
created_at: int
kind: int
tags: list[list[str]] = []
tags: List[List[str]] = []
content: str = ""
sig: str | None = None
sig: Optional[str] = None
def serialize(self) -> list:
def serialize(self) -> List:
return [0, self.pubkey, self.created_at, self.kind, self.tags, self.content]
def serialize_json(self) -> str:

View file

@ -10,11 +10,13 @@ import asyncio
import json
from asyncio import Queue
from collections import OrderedDict
from typing import Optional
from loguru import logger
from websocket import WebSocketApp
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
@ -25,7 +27,7 @@ class NostrClient:
def __init__(self):
self.receive_event_queue: Queue = Queue()
self.send_req_queue: Queue = Queue()
self.ws: WebSocketApp | None = None
self.ws: Optional[WebSocketApp] = None
self.subscription_id = "events-" + urlsafe_short_hash()[:32]
self.running = False
self._seen_events: OrderedDict[str, None] = OrderedDict()
@ -39,7 +41,8 @@ class NostrClient:
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}"
f"ws://localhost:{settings.port}"
f"/nostrclient/api/v1/{relay_endpoint}"
)
logger.info("[EVENTS] Connecting to nostrclient WebSocket...")
@ -57,8 +60,12 @@ class NostrClient:
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"))
logger.warning(
f"[EVENTS] WebSocket closed: {status_code} {message}"
)
self.receive_event_queue.put_nowait(
ValueError("WebSocket closed")
)
ws = WebSocketApp(
ws_url,
@ -113,7 +120,9 @@ class NostrClient:
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])
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]}...)"

View file

@ -1,48 +0,0 @@
"""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}")

View file

@ -1,126 +1,54 @@
"""
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.
Builds kind 31922 (date-based) calendar events from the Event model,
signs them with the event creator's Account keypair, and publishes
via the NostrClient to nostrclient relays.
Reference: https://github.com/nostr-protocol/nips/blob/master/52.md
"""
import time
from datetime import datetime, timezone
from typing import Optional
from lnbits.core.signers import NostrSigner
import coincurve
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.
Convert an Event model to a NIP-52 kind 31922 (date-based) calendar event.
Time-based (kind 31923) if event_start_date carries an HH:MM, otherwise
date-based (kind 31922). Tags:
d - event.id
Tags:
d - event.id (addressable identifier)
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.
start - event.event_start_date (ISO date string)
end - event.event_end_date (optional)
image - event.banner (optional)
Content: event.info (description)
"""
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],
["start", event.event_start_date],
]
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)])
tags.append(["end", event.event_end_date])
if event.banner:
tags.append(["image", event.banner])
if event.location:
tags.append(["location", event.location])
for cat in event.categories or []:
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,
created_at=int(time.time()),
kind=31922,
tags=tags,
content=event.info or "",
)
@ -132,17 +60,15 @@ 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.
Uses an 'a' tag to reference the parameterized replaceable event
(kind 31922) per NIP-09.
"""
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}"],
["a", f"31922:{pubkey}:{event.id}"],
],
content="Event canceled",
)
@ -150,20 +76,23 @@ def build_nip52_delete_event(event: Event, pubkey: str) -> NostrEvent:
return nostr_event
def sign_nostr_event(nostr_event: NostrEvent, private_key_hex: str) -> None:
"""Sign a NostrEvent in-place using Schnorr signature."""
privkey = coincurve.PrivateKey(bytes.fromhex(private_key_hex))
sig = privkey.sign_schnorr(bytes.fromhex(nostr_event.id))
nostr_event.sig = sig.hex()
async def publish_event_to_nostr(
nostr_client,
event: Event,
signer: NostrSigner,
account_pubkey: str,
account_prvkey: str,
delete: bool = False,
) -> NostrEvent | None:
) -> Optional[NostrEvent]:
"""
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:
@ -172,25 +101,11 @@ async def publish_event_to_nostr(
try:
if delete:
nostr_event = build_nip52_delete_event(event, signer.pubkey)
nostr_event = build_nip52_delete_event(event, account_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"]
nostr_event = build_nip52_event(event, account_pubkey)
sign_nostr_event(nostr_event, account_prvkey)
await nostr_client.publish_nostr_event(nostr_event)
logger.info(

View file

@ -7,14 +7,13 @@ 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 .crud import create_event, db, get_event, update_event
from .models import CreateEvent, Event
from .nostr.nostr_client import NostrClient
@ -102,9 +101,22 @@ async def _handle_calendar_event(nostr_client: NostrClient, event_data: dict):
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.
# Create new event from Nostr
# Events discovered from Nostr are auto-approved (they're already public)
event = CreateEvent(
wallet="", # No wallet — discovered from Nostr, not ticketed locally
name=title,
info=description,
event_start_date=start,
event_end_date=end,
banner=image,
location=location,
categories=categories,
status="approved",
)
# Use the d-tag as the event ID for correlation
from lnbits.db import Database
new_event = Event(
id=d_tag,
wallet="",
@ -137,11 +149,9 @@ async def wait_for_nostr_events(nostr_client: NostrClient):
while True:
try:
# Subscribe to NIP-52 calendar events
await nostr_client.subscribe(
[
await nostr_client.subscribe([
{"kinds": [31922, 31923]},
]
)
])
# Process incoming events
while True:
@ -155,3 +165,6 @@ async def wait_for_nostr_events(nostr_client: NostrClient):
except Exception as e:
logger.error(f"[EVENTS] Nostr sync error: {e}")
await asyncio.sleep(30)
import asyncio # noqa: E402

View file

@ -1,34 +0,0 @@
"""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)

View file

@ -7,8 +7,11 @@ 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 = [
[tool.poetry]
package-mode = false
[tool.uv]
dev-dependencies = [
"black",
"pytest-asyncio",
"pytest",
@ -17,9 +20,6 @@ dev = [
"ruff",
]
[tool.poetry]
package-mode = false
[tool.mypy]
exclude = "(nostr/*)"
plugins = ["pydantic.mypy"]

View file

@ -1,16 +1,3 @@
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
@ -21,36 +8,13 @@ from .crud import (
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
from .models import Ticket
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)
@ -60,107 +24,9 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket:
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.

View file

@ -1,9 +1,8 @@
window.PageEventsDisplay = {
template: '#page-events-display',
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
data() {
return {
eventErrorLabel: '',
event: null,
paymentReq: null,
redirectUrl: null,
formDialog: {
@ -11,9 +10,7 @@ window.PageEventsDisplay = {
data: {
name: '',
email: '',
refund: '',
nostr_identifier: '',
payment_method: 'lightning'
refund: ''
}
},
ticketLink: {
@ -25,71 +22,35 @@ window.PageEventsDisplay = {
receive: {
show: false,
status: 'pending',
paymentReq: null,
isFiat: false
},
paymentDismissMsg: null,
paymentWebsocket: null
paymentReq: null
}
}
},
async created() {
this.eventId = this.$route.params.id
this.event = await this.getEvent()
this.info = event_info
this.info = this.info.substring(1, this.info.length - 1)
this.banner = event_banner
this.extra = event_extra
this.hasPromoCodes = has_promoCodes
},
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)
return LNbits.utils.convertMarkdown(this.info)
}
},
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
}
const checker = this.receive.paymentChecker
dismissMsg()
clearInterval(paymentChecker)
setTimeout(() => {}, 10000)
},
nameValidation(val) {
const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g
@ -102,17 +63,42 @@ window.PageEventsDisplay = {
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
Invoice() {
axios
.post(`/events/api/v1/tickets/${event_id}`, {
name: this.formDialog.data.name,
email: this.formDialog.data.email,
promo_code: this.formDialog.data.promo_code || null
})
.then(response => {
this.paymentReq = response.data.payment_request
this.paymentCheck = response.data.payment_hash
dismissMsg = Quasar.Notify.create({
timeout: 0,
message: 'Waiting for payment...'
})
this.receive = {
show: true,
status: 'pending',
paymentReq: this.paymentReq
}
this.paymentReq = null
paymentChecker = setInterval(() => {
axios
.post(`/events/api/v1/tickets/${event_id}/${this.paymentCheck}`, {
event: event_id,
event_name: event_name,
name: this.formDialog.data.name,
email: this.formDialog.data.email
})
.then(res => {
if (res.data.paid) {
clearInterval(paymentChecker)
dismissMsg()
this.formDialog.data.name = ''
this.formDialog.data.email = ''
this.formDialog.data.refund = ''
this.formDialog.data.nostr_identifier = ''
this.formDialog.data.payment_method = 'lightning'
Quasar.Notify.create({
type: 'positive',
message: 'Sent, thank you!',
@ -121,85 +107,24 @@ window.PageEventsDisplay = {
this.receive = {
show: false,
status: 'complete',
paymentReq: null,
isFiat: false
paymentReq: null
}
this.ticketLink = {
show: true,
data: {
link: `/events/ticket/${paymentHash}`
link: `/events/ticket/${res.data.ticket_id}`
}
}
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
setTimeout(() => {
window.location.href = `/events/ticket/${res.data.ticket_id}`
}, 5000)
}
)
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()
.catch(LNbits.utils.notifyApiError)
}, 2000)
})
.catch(LNbits.utils.notifyApiError)
}
}
ws.onerror = error => {
console.error('WebSocket error:', error)
}
ws.onclose = () => {
if (this.paymentWebsocket === ws) {
this.paymentWebsocket = null
}
}
}
}
}
})

View file

@ -1,215 +0,0 @@
<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> &mdash; 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> &mdash; 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>

View file

@ -1,64 +1,24 @@
window.PageEvents = {
template: '#page-events',
const mapEvents = function (obj) {
obj.date = LNbits.utils.formatTimestamp(obj.time)
obj.fsat = new Intl.NumberFormat(window.g.locale).format(obj.price_per_ticket)
obj.displayUrl = ['/events/', obj.id].join('')
return obj
}
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
data() {
return {
events: [],
tickets: [],
resendingTicketEmails: [],
currencies: [],
pendingEvents: [],
allUserEvents: [],
pendingEvents: [],
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'}
]
},
tickets: [],
currencies: [],
eventsTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
@ -97,7 +57,7 @@ window.PageEvents = {
align: 'left',
label: 'Price',
field: row => {
if (this.isFiatCurrency(row.currency)) {
if (row.currency != 'sats') {
return LNbits.utils.formatCurrency(
row.price_per_ticket.toFixed(2),
row.currency
@ -152,36 +112,98 @@ window.PageEvents = {
formDialog: {
show: false,
data: {
currency: 'sats',
allow_fiat: false,
fiat_currency: 'GBP',
extra: {
promo_codes: [],
notification_subject: '',
notification_body: ''
promo_codes: []
}
}
}
}
},
methods: {
isFiatCurrency(currency) {
return !['sat', 'sats'].includes((currency || '').toLowerCase())
getSettings() {
LNbits.api
.request('GET', '/events/api/v1/settings')
.then(response => {
this.settings = response.data
})
.catch(() => {
// Not admin or settings not available
})
},
saveSettings() {
LNbits.api
.request('PUT', '/events/api/v1/settings', null, this.settings)
.then(() => {
this.$q.notify({
type: 'positive',
message: 'Settings saved'
})
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
},
approveEvent(eventId) {
LNbits.utils
.confirmDialog('Approve this event?')
.onOk(() => {
LNbits.api
.request(
'PUT',
'/events/api/v1/events/' + eventId + '/approve'
)
.then(() => {
this.$q.notify({
type: 'positive',
message: 'Event approved'
})
this.getEvents()
this.getPendingEvents()
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
})
},
rejectEvent(eventId) {
LNbits.utils
.confirmDialog('Reject this event?')
.onOk(() => {
LNbits.api
.request(
'PUT',
'/events/api/v1/events/' + eventId + '/reject'
)
.then(() => {
this.$q.notify({
type: 'positive',
message: 'Event rejected'
})
this.getEvents()
this.getPendingEvents()
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
})
},
getTickets() {
LNbits.api
.request(
'GET',
'/events/api/v1/tickets?all_wallets=true',
this.g.user.wallets[0].adminkey
this.g.user.wallets[0].inkey
)
.then(response => {
this.tickets = response.data.filter(e => e.paid)
this.tickets = response.data
.map(function (obj) {
return mapEvents(obj)
})
.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')
@ -190,49 +212,23 @@ window.PageEvents = {
.request(
'DELETE',
'/events/api/v1/tickets/' + ticketId,
wallet.adminkey
_.findWhere(this.g.user.wallets, {id: tickets.wallet}).inkey
)
.then(response => {
this.tickets = _.reject(this.tickets, function (obj) {
return obj.id == ticketId
})
})
.catch(LNbits.utils.notifyApiError)
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
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() {
// Always fetch own events
LNbits.api
.request(
'GET',
@ -240,190 +236,56 @@ window.PageEvents = {
this.g.user.wallets[0].inkey
)
.then(response => {
this.events = response.data
this.events = response.data.map(obj => {
return mapEvents(obj)
})
this.checkCanceledEvents()
})
// Admin probe: a 200 from /all means we're an LNbits admin.
// Admin: also fetch all users' events
LNbits.api
.request('GET', '/events/api/v1/events/all')
.request(
'GET',
'/events/api/v1/events/all'
)
.then(response => {
this.isAdmin = true
// Exclude own events (already in this.events)
const ownWalletIds = this.g.user.wallets.map(w => w.id)
this.allUserEvents = response.data.filter(
e => !ownWalletIds.includes(e.wallet)
)
this.allUserEvents = response.data
.filter(obj => !ownWalletIds.includes(obj.wallet))
.map(obj => mapEvents(obj))
})
.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')
.request(
'GET',
'/events/api/v1/events/pending'
)
.then(response => {
this.pendingEvents = response.data
this.pendingEvents = response.data.map(obj => {
return mapEvents(obj)
})
})
.catch(() => {
// Not an admin or no pending events
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) {
const data = this.formDialog.data
if (data.extra && !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'
}
.filter(code => code.trim() !== '')
.map(code => code.trim().toUpperCase())
}
if (data.id) {
@ -435,32 +297,13 @@ window.PageEvents = {
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
}
this.formDialog.data = {...data}
} 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: ''
promo_codes: []
}
}
}
@ -469,15 +312,8 @@ window.PageEvents = {
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: ''
promo_codes: []
}
}
},
@ -486,7 +322,7 @@ window.PageEvents = {
LNbits.api
.request('POST', '/events/api/v1/events', wallet.adminkey, data)
.then(response => {
this.events.push(response.data)
this.events.push(mapEvents(response.data))
this.resetEventDialog()
})
.catch(LNbits.utils.notifyApiError)
@ -507,7 +343,7 @@ window.PageEvents = {
this.events = _.reject(this.events, function (obj) {
return obj.id == data.id
})
this.events.push(response.data)
this.events.push(mapEvents(response.data))
this.resetEventDialog()
})
.catch(LNbits.utils.notifyApiError)
@ -529,7 +365,7 @@ window.PageEvents = {
return obj.id == eventsId
})
})
.catch(LNbits.utils.notifyApiError)
.catch(LNbits.utils.notifyApiError(error))
})
},
exporteventsCSV() {
@ -553,7 +389,9 @@ window.PageEvents = {
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))
this.events = this.events.map(e =>
e.id === ev.id ? mapEvents(data) : e
)
}
})
}
@ -562,13 +400,9 @@ window.PageEvents = {
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]
this.getSettings()
this.currencies = await LNbits.api.getCurrencies()
}
}
}
}
})

View file

@ -1,783 +0,0 @@
<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>
&mdash;
<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 &bull;
<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>

View file

@ -1,18 +1,28 @@
window.PageEventsRegister = {
template: '#page-events-register',
const mapEvents = function (obj) {
obj.date = Quasar.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
obj.displayUrl = ['/events/', obj.id].join('')
return obj
}
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
data() {
return {
tickets: [],
ticketsTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'name', align: 'left', label: 'Name', field: 'name'},
{name: 'email', align: 'left', label: 'Email', field: 'email'},
{
name: 'id',
name: 'registered',
align: 'left',
label: 'ID',
field: 'id',
format: val => this.shortId(val)
label: 'Registered',
field: 'registered'
}
],
pagination: {
@ -22,20 +32,12 @@ window.PageEventsRegister = {
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)
hoverEmail(tmp) {
this.tickets.data.emailtemp = tmp
},
closeCamera() {
this.sendCamera.show = false
@ -43,32 +45,34 @@ window.PageEventsRegister = {
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}`)
.request('GET', `/events/api/v1/register/ticket/${value}`)
.then(() => {
Quasar.Notify.create({
type: 'positive',
message: 'Registered!'
})
setTimeout(() => {
window.location.reload()
}, 2000)
})
.catch(LNbits.utils.notifyApiError)
},
getEventTickets() {
LNbits.api
.request('GET', `/events/api/v1/eventtickets/${event_id}`)
.then(response => {
this.saveScannedTicket(response.data)
this.lastScan = {success: true, ticket: response.data}
Quasar.Notify.create({type: 'positive', message: 'Registered!'})
this.tickets = response.data.map(obj => {
return mapEvents(obj)
})
.catch(error => {
this.lastScan = {
success: false,
ticketId: value,
error:
error.response?.data?.detail || error.message || 'Unknown error'
}
LNbits.utils.notifyApiError(error)
})
.catch(LNbits.utils.notifyApiError)
}
},
created() {
this.eventId = this.$route.params.id
this.loadScannedTickets()
this.getEventTickets()
}
}
})

View file

@ -1,86 +0,0 @@
<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>

View file

@ -1,26 +0,0 @@
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)
}
}
}

View file

@ -1,39 +0,0 @@
<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>

View file

@ -1,26 +0,0 @@
[
{
"path": "/events/",
"name": "PageEvents",
"template": "/events/static/js/index.vue",
"component": "/events/static/js/index.js"
},
{
"path": "/events/:id",
"name": "PageEventsDisplay",
"template": "/events/static/js/display.vue",
"component": "/events/static/js/display.js"
},
{
"path": "/events/ticket/:id",
"name": "PageEventsTicket",
"template": "/events/static/js/ticket.vue",
"component": "/events/static/js/ticket.js"
},
{
"path": "/events/register/:id",
"name": "PageEventsRegister",
"template": "/events/static/js/register.vue",
"component": "/events/static/js/register.js"
}
]

View file

@ -4,24 +4,8 @@ from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener
from loguru import logger
from .crud import get_ticket, get_tickets_by_payment_hash
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]
from .crud import get_ticket
from .services import set_ticket_paid
async def wait_for_paid_invoices():
@ -37,32 +21,17 @@ async def on_invoice_paid(payment: Payment) -> None:
if not payment.extra or "events" != payment.extra.get("tag"):
return
# Multi-ticket purchases land as N rows sharing this payment_hash;
# each one needs to be marked paid + counted against capacity, and
# each gets its own buyer notification (mostly a no-op when all
# rows are owned by the same buyer, but cheap and consistent).
tickets = await get_tickets_by_payment_hash(payment.payment_hash)
if not tickets:
# Backstop for any legacy row created before the payment_hash
# column was populated by the migration backfill.
legacy = await get_ticket(payment.payment_hash)
if legacy:
tickets = [legacy]
# Check if ticket has either name/email or user_id
has_name_email = payment.extra.get("name") and payment.extra.get("email")
has_user_id = payment.extra.get("user_id")
if not tickets:
logger.warning(f"No tickets for payment {payment.payment_hash}.")
if not has_name_email and not has_user_id:
logger.warning(f"Ticket {payment.payment_hash} missing name/email or user_id.")
return
paid_tickets: list[Ticket] = []
for ticket in tickets:
paid_tickets.append(await set_ticket_paid(ticket))
ticket = await get_ticket(payment.payment_hash)
if not ticket:
logger.warning(f"Ticket for payment {payment.payment_hash} not found.")
return
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])
await set_ticket_paid(ticket)

View file

@ -0,0 +1,25 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="Info"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<h5 class="text-subtitle1 q-my-none">
Events: Sell and register ticket waves for an event
</h5>
<p>
Events allows you to make a wave of tickets for an event, each ticket is
in the form of a unique QRcode, which the user presents at registration.
Events comes with a shareable ticket scanner, which can be used to
register attendees.<br />
<small>
Created by,
<a class="text-secondary" href="https://github.com/benarc">Ben Arc</a>
</small>
</p>
</q-card-section>
</q-card>
<q-btn flat label="Swagger API" type="a" href="../docs#/events"></q-btn>
</q-expansion-item>

View file

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

View file

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

603
templates/events/index.html Normal file
View file

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

View file

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

View file

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

108
tests/test_api.py Normal file
View file

@ -0,0 +1,108 @@
import pytest
from fastapi.testclient import TestClient
from unittest.mock import AsyncMock, patch
from ..views_api import events_api_router
from ..models import Event
from datetime import datetime, timezone
@pytest.mark.asyncio
async def test_api_events_public():
"""Test the new public events API endpoint"""
from fastapi import FastAPI
app = FastAPI()
app.include_router(events_api_router)
# Mock the database
with patch('events.crud.get_all_events') as mock_get_all_events:
# Create mock events
mock_events = [
Event(
id="test_event_1",
wallet="test_wallet_1",
name="Test Event 1",
info="Test event description",
closing_date="2024-12-31",
event_start_date="2024-12-01",
event_end_date="2024-12-02",
currency="sat",
amount_tickets=100,
price_per_ticket=1000.0,
time=datetime.now(timezone.utc),
sold=0,
banner=None
),
Event(
id="test_event_2",
wallet="test_wallet_2",
name="Test Event 2",
info="Another test event",
closing_date="2024-12-31",
event_start_date="2024-12-03",
event_end_date="2024-12-04",
currency="sat",
amount_tickets=50,
price_per_ticket=500.0,
time=datetime.now(timezone.utc),
sold=0,
banner=None
)
]
mock_get_all_events.return_value = mock_events
client = TestClient(app)
# Test the endpoint without any authentication
response = client.get("/api/v1/events/public")
# Verify the response
assert response.status_code == 200
data = response.json()
assert len(data) == 2
assert data[0]["id"] == "test_event_1"
assert data[1]["id"] == "test_event_2"
assert data[0]["name"] == "Test Event 1"
assert data[1]["name"] == "Test Event 2"
@pytest.mark.asyncio
async def test_get_all_events_crud():
"""Test the get_all_events CRUD function"""
from events.crud import get_all_events
with patch('events.crud.db.fetchall') as mock_fetchall:
# Mock database response
mock_events = [
{
"id": "test_event_1",
"wallet": "test_wallet_1",
"name": "Test Event 1",
"info": "Test event description",
"closing_date": "2024-12-31",
"event_start_date": "2024-12-01",
"event_end_date": "2024-12-02",
"currency": "sat",
"amount_tickets": 100,
"price_per_ticket": 1000.0,
"time": datetime.now(timezone.utc),
"sold": 0,
"banner": None
}
]
mock_fetchall.return_value = mock_events
events = await get_all_events()
# Verify the function was called with correct parameters
mock_fetchall.assert_called_once_with(
"SELECT * FROM events.events ORDER BY time DESC",
model=Event,
)
# Verify the result
assert len(events) == 1
assert events[0]["id"] == "test_event_1"

View file

@ -1,32 +0,0 @@
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))

View file

@ -1,120 +0,0 @@
"""
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
],
}

2142
uv.lock generated

File diff suppressed because it is too large Load diff

151
views.py
View file

@ -1,24 +1,139 @@
from fastapi import APIRouter, Depends
from lnbits.core.views.generic import index, index_public
from lnbits.decorators import check_account_id_exists
from datetime import date, datetime
from http import HTTPStatus
from fastapi import APIRouter, Depends, Request
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse
from .crud import get_event, get_ticket, purge_unpaid_tickets, update_event
from .services import refund_tickets
events_generic_router = APIRouter()
events_generic_router.add_api_route(
"/",
methods=["GET"],
endpoint=index,
dependencies=[Depends(check_account_id_exists)],
)
events_generic_router.add_api_route(
"/{event_id}", methods=["GET"], endpoint=index_public
)
def events_renderer():
return template_renderer(["events/templates"])
events_generic_router.add_api_route(
"/ticket/{ticket_id}", methods=["GET"], endpoint=index_public
)
events_generic_router.add_api_route(
"/register/{event_id}", methods=["GET"], endpoint=index_public
)
@events_generic_router.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return events_renderer().TemplateResponse(
"events/index.html", {"request": request, "user": user.json()}
)
@events_generic_router.get("/{event_id}", response_class=HTMLResponse)
async def display(request: Request, event_id):
event = await get_event(event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
await purge_unpaid_tickets(event_id)
is_window_open = (
date.today() < datetime.strptime(event.closing_date, "%Y-%m-%d").date()
)
is_min_tickets_met = (
event.sold >= event.extra.min_tickets if event.extra.conditional else True
)
if event.amount_tickets < 1:
return events_renderer().TemplateResponse(
"events/error.html",
{
"request": request,
"event_name": event.name,
"event_error": "Sorry, tickets are sold out :(",
},
)
if event.extra.conditional and not is_min_tickets_met and not is_window_open:
event.canceled = True
await update_event(event)
await refund_tickets(event_id)
return events_renderer().TemplateResponse(
"events/error.html",
{
"request": request,
"event_name": event.name,
"event_error": "Sorry, event was cancelled.",
},
)
if not is_window_open:
return events_renderer().TemplateResponse(
"events/error.html",
{
"request": request,
"event_name": event.name,
"event_error": "Sorry, ticket closing date has passed :(",
},
)
if len(event.extra.promo_codes) > 0:
has_promo_codes = True
else:
has_promo_codes = False
event.extra.promo_codes = []
return events_renderer().TemplateResponse(
"events/display.html",
{
"request": request,
"event_id": event_id,
"event_name": event.name,
"event_info": event.info,
"event_price": event.price_per_ticket,
"event_banner": event.banner,
"event_extra": event.extra.json(),
"has_promo_codes": has_promo_codes,
},
)
@events_generic_router.get("/ticket/{ticket_id}", response_class=HTMLResponse)
async def ticket(request: Request, ticket_id):
ticket = await get_ticket(ticket_id)
if not ticket:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
)
event = await get_event(ticket.event)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
return events_renderer().TemplateResponse(
"events/ticket.html",
{
"request": request,
"ticket_id": ticket_id,
"ticket_name": event.name,
"ticket_info": event.info,
},
)
@events_generic_router.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,
},
)

File diff suppressed because it is too large Load diff