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
34 changed files with 2946 additions and 2932 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> # Events - <small>[LNbits](https://github.com/lnbits/lnbits) extension</small>
<small>For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions)</small> <small>For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions)</small>
## Sell tickets for events and use the built-in scanner for registering attendees ## 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. 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\ 4. Use the built-in ticket scanner to validate registered, and paid, attendees\
![ticket scanner](https://i.imgur.com/zrm9202.jpg) ![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 .crud import db
from .tasks import wait_for_paid_invoices from .tasks import wait_for_paid_invoices
from .views import events_generic_router from .views import events_generic_router
from .views_api import events_api_router, tickets_api_router from .views_api import events_api_router
events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"]) events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"])
events_ext.include_router(events_generic_router) events_ext.include_router(events_generic_router)
events_ext.include_router(events_api_router) events_ext.include_router(events_api_router)
events_ext.include_router(tickets_api_router)
events_static_files = [ events_static_files = [
{ {
@ -22,9 +21,7 @@ events_static_files = [
scheduled_tasks: list[asyncio.Task] = [] scheduled_tasks: list[asyncio.Task] = []
# Module-level NostrClient — None when nostrclient is unavailable. Set by the # Module-level NostrClient — None when nostrclient is unavailable
# bootstrap task in events_start() and read via dynamic attribute lookup
# from nostr_hooks.publish_or_delete_nostr_event.
nostr_client = None nostr_client = None
@ -55,8 +52,8 @@ def events_start():
nostr_client = NostrClient() nostr_client = NostrClient()
logger.info("[EVENTS] Starting NostrClient for NIP-52 sync") logger.info("[EVENTS] Starting NostrClient for NIP-52 sync")
await nostr_client.run_forever() await nostr_client.run_forever()
except Exception as exc: except Exception as e:
logger.warning(f"[EVENTS] NostrClient failed to start: {exc}") logger.warning(f"[EVENTS] NostrClient failed to start: {e}")
logger.info("[EVENTS] Events will work without Nostr sync") logger.info("[EVENTS] Events will work without Nostr sync")
task2 = create_permanent_unique_task("ext_events_nostr", _start_nostr_client) task2 = create_permanent_unique_task("ext_events_nostr", _start_nostr_client)
@ -72,8 +69,8 @@ def events_start():
from .nostr_sync import wait_for_nostr_events from .nostr_sync import wait_for_nostr_events
await wait_for_nostr_events(nostr_client) await wait_for_nostr_events(nostr_client)
except Exception as exc: except Exception as e:
logger.error(f"[EVENTS] Nostr sync task failed: {exc}") logger.error(f"[EVENTS] Nostr sync task failed: {e}")
task3 = create_permanent_unique_task( task3 = create_permanent_unique_task(
"ext_events_nostr_sync", _sync_nostr_events "ext_events_nostr_sync", _sync_nostr_events

View file

@ -1,12 +1,9 @@
{ {
"id": "events",
"version": "1.3.0-aio.1",
"name": "Events", "name": "Events",
"repo": "https://git.atitlan.io/aiolabs/events",
"short_description": "Sell and register event tickets", "short_description": "Sell and register event tickets",
"description": "",
"tile": "/events/static/image/events.png", "tile": "/events/static/image/events.png",
"min_lnbits_version": "1.4.1", "lnbits": "1.1.0",
"min_lnbits_version": "1.3.0",
"contributors": [ "contributors": [
{ {
"name": "talvasconcelos", "name": "talvasconcelos",
@ -14,7 +11,7 @@
"role": "Developer" "role": "Developer"
}, },
{ {
"name": "dni", "name": "DNI",
"uri": "https://github.com/dni", "uri": "https://github.com/dni",
"role": "Developer" "role": "Developer"
}, },
@ -32,11 +29,6 @@
"name": "motorina0", "name": "motorina0",
"uri": "https://github.com/motorina0", "uri": "https://github.com/motorina0",
"role": "Developer" "role": "Developer"
},
{
"name": "padreug",
"uri": "https://git.atitlan.io/padreug",
"role": "Developer (aio fork: approval workflow + NIP-52 Nostr sync)"
} }
], ],
"images": [ "images": [
@ -59,9 +51,5 @@
], ],
"description_md": "https://raw.githubusercontent.com/lnbits/events/main/description.md", "description_md": "https://raw.githubusercontent.com/lnbits/events/main/description.md",
"terms_and_conditions_md": "https://raw.githubusercontent.com/lnbits/events/main/toc.md", "terms_and_conditions_md": "https://raw.githubusercontent.com/lnbits/events/main/toc.md",
"license": "MIT", "license": "MIT"
"paid_features": "",
"tags": ["Fun & Social", "Ticketing"],
"donate": "",
"hidden": false
} }

82
crud.py
View file

@ -1,51 +1,56 @@
import json import json
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional
from lnbits.db import Database from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
from .models import CreateEvent, Event, EventsSettings, Ticket, TicketExtra from .models import CreateEvent, Event, EventsSettings, Ticket, TicketExtra
db = Database("ext_events")
def _parse_ticket_row(row) -> dict: def _parse_ticket_row(row) -> dict:
"""Normalize a ticket row before constructing a Ticket model. """
Parse a database row into a dict suitable for Ticket model creation.
- Empty-string sentinels in name/email (used because the DB columns are Handles:
NOT NULL but the Pydantic field is Optional when user_id is set) are - Empty string to None conversion for name/email
converted back to None. - JSON string to dict conversion for extra field
- The `extra` JSON column may come back as a string when the row is
fetched without a model= argument; parse it so Pydantic can build
TicketExtra from a dict.
""" """
ticket_data = dict(row) ticket_data = dict(row)
# Convert empty strings back to None for the model
if ticket_data.get("name") == "": if ticket_data.get("name") == "":
ticket_data["name"] = None ticket_data["name"] = None
if ticket_data.get("email") == "": if ticket_data.get("email") == "":
ticket_data["email"] = None 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") extra = ticket_data.get("extra")
if isinstance(extra, str): if isinstance(extra, str):
ticket_data["extra"] = json.loads(extra) if extra else {} ticket_data["extra"] = json.loads(extra)
return ticket_data return ticket_data
db = Database("ext_events")
async def create_ticket( async def create_ticket(
payment_hash: str, payment_hash: str,
wallet: str, wallet: str,
event: str, event: str,
name: str | None = None, name: Optional[str] = None,
email: str | None = None, email: Optional[str] = None,
user_id: str | None = None, user_id: Optional[str] = None,
extra: dict | None = None, extra: Optional[dict] = None,
) -> Ticket: ) -> Ticket:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
# name/email columns are NOT NULL in the schema, so we store "" when only # TODO: Check if this empty string workaround is still needed.
# user_id is supplied. _parse_ticket_row reverses this on read. # 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: if user_id:
db_name = "" db_name = ""
db_email = "" db_email = ""
@ -53,6 +58,9 @@ async def create_ticket(
db_name = name or "" db_name = name or ""
db_email = email 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( db_ticket = Ticket(
id=payment_hash, id=payment_hash,
wallet=wallet, wallet=wallet,
@ -66,8 +74,11 @@ async def create_ticket(
time=now, time=now,
extra=TicketExtra(**extra) if extra else TicketExtra(), extra=TicketExtra(**extra) if extra else TicketExtra(),
) )
await db.insert("events.ticket", db_ticket) 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( return Ticket(
id=payment_hash, id=payment_hash,
wallet=wallet, wallet=wallet,
@ -84,22 +95,30 @@ async def create_ticket(
async def update_ticket(ticket: Ticket) -> Ticket: async def update_ticket(ticket: Ticket) -> Ticket:
# Create a new Ticket object with corrected values for database constraints
ticket_dict = ticket.dict() ticket_dict = ticket.dict()
# Convert None values to empty strings for database constraints
if ticket_dict.get("name") is None: if ticket_dict.get("name") is None:
ticket_dict["name"] = "" ticket_dict["name"] = ""
if ticket_dict.get("email") is None: if ticket_dict.get("email") is None:
ticket_dict["email"] = "" 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 return ticket
async def get_ticket(payment_hash: str) -> Ticket | None: async def get_ticket(payment_hash: str) -> Optional[Ticket]:
row = await db.fetchone( row = await db.fetchone(
"SELECT * FROM events.ticket WHERE id = :id", "SELECT * FROM events.ticket WHERE id = :id",
{"id": payment_hash}, {"id": payment_hash},
) )
if not row: if not row:
return None return None
return Ticket(**_parse_ticket_row(row)) return Ticket(**_parse_ticket_row(row))
@ -108,15 +127,17 @@ async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]:
wallet_ids = [wallet_ids] wallet_ids = [wallet_ids]
q = ",".join([f"'{wallet_id}'" for wallet_id in 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})") rows = await db.fetchall(f"SELECT * FROM events.ticket WHERE wallet IN ({q})")
return [Ticket(**_parse_ticket_row(row)) for row in rows] return [Ticket(**_parse_ticket_row(row)) for row in rows]
async def get_tickets_by_user_id(user_id: str) -> list[Ticket]: 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( rows = await db.fetchall(
"SELECT * FROM events.ticket WHERE user_id = :user_id ORDER BY time DESC", "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] return [Ticket(**_parse_ticket_row(row)) for row in rows]
@ -143,9 +164,10 @@ async def purge_unpaid_tickets(event_id: str) -> None:
async def create_event(data: CreateEvent) -> Event: async def create_event(data: CreateEvent) -> Event:
event_id = urlsafe_short_hash() 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: if not data.event_end_date:
data.event_end_date = data.event_start_date data.event_end_date = data.event_start_date
# Default closing date to end date if not provided
if not data.closing_date: if not data.closing_date:
data.closing_date = data.event_end_date data.closing_date = data.event_end_date
event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict()) event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict())
@ -177,7 +199,7 @@ async def get_events(wallet_ids: str | list[str]) -> list[Event]:
async def get_all_events() -> 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( return await db.fetchall(
"SELECT * FROM events.events ORDER BY time DESC", "SELECT * FROM events.events ORDER BY time DESC",
model=Event, model=Event,
@ -185,7 +207,7 @@ async def get_all_events() -> list[Event]:
async def get_public_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( return await db.fetchall(
""" """
SELECT * FROM events.events SELECT * FROM events.events
@ -197,7 +219,7 @@ async def get_public_events() -> list[Event]:
async def get_pending_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( return await db.fetchall(
"SELECT * FROM events.events WHERE status = 'proposed' ORDER BY time DESC", "SELECT * FROM events.events WHERE status = 'proposed' ORDER BY time DESC",
model=Event, model=Event,
@ -205,7 +227,7 @@ async def get_pending_events() -> list[Event]:
async def get_settings() -> EventsSettings: 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") row = await db.fetchone("SELECT * FROM events.settings WHERE id = 1")
if row: if row:
return EventsSettings(**dict(row)) return EventsSettings(**dict(row))
@ -213,8 +235,13 @@ async def get_settings() -> EventsSettings:
async def update_settings(settings: EventsSettings) -> EventsSettings: async def update_settings(settings: EventsSettings) -> EventsSettings:
"""Update extension settings."""
await db.execute( 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}, {"auto_approve": settings.auto_approve},
) )
return settings return settings
@ -229,4 +256,5 @@ async def get_event_tickets(event_id: str) -> list[Ticket]:
"SELECT * FROM events.ticket WHERE event = :event", "SELECT * FROM events.ticket WHERE event = :event",
{"event": event_id}, {"event": event_id},
) )
return [Ticket(**_parse_ticket_row(row)) for row in rows] 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 Events includes a shareable ticket scanner, which can be used to register attendees.
- 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.

View file

@ -162,98 +162,84 @@ async def m005_add_image_banner(db):
await db.execute("ALTER TABLE events.events ADD COLUMN banner TEXT;") await db.execute("ALTER TABLE events.events ADD COLUMN banner TEXT;")
async def _alter_add_column_safe(db, sql: str) -> None: async def m006_add_user_id_support(db):
"""ALTER TABLE ADD COLUMN that swallows duplicate-column errors.
Earlier aiolabs/events forks added some of these columns under different
migration names (e.g. our former m007). Skipping the error keeps the
migration log monotonic for both fresh installs and pre-rebase upgrades.
""" """
try: Add user_id column to tickets table to support LNbits user-id as identifier
await db.execute(sql) Make name and email optional when user_id is provided
except Exception as exc: """
msg = str(exc).lower() await db.execute("ALTER TABLE events.ticket ADD COLUMN user_id TEXT;")
if "duplicate column" in msg or "already exists" in msg:
return
raise
# 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 m006_add_extra_fields(db): async def m007_add_extra_fields(db):
""" """
Add a canceled and 'extra' column to events and ticket tables Add a canceled and 'extra' column to events and ticket tables
to support promo codes and ticket metadata. to support promo codes and ticket metadata.
""" """
await _alter_add_column_safe( # Add canceled and 'extra' columns to events table
db, # SQLite requires separate ALTER TABLE statements for each column
"ALTER TABLE events.events ADD COLUMN canceled BOOLEAN NOT NULL DEFAULT FALSE", await db.execute(
"ALTER TABLE events.events ADD COLUMN canceled BOOLEAN NOT NULL DEFAULT FALSE;"
) )
await _alter_add_column_safe(db, "ALTER TABLE events.events ADD COLUMN extra TEXT") await db.execute(
await _alter_add_column_safe(db, "ALTER TABLE events.ticket ADD COLUMN extra TEXT") "ALTER TABLE events.events ADD COLUMN extra TEXT;"
async def m007_add_user_id_support(db):
"""
Add user_id column to ticket table so a ticket can 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"
) )
# Add 'extra' column to ticket table
await db.execute("ALTER TABLE events.ticket ADD COLUMN extra TEXT;")
async def m008_add_event_status(db): async def m008_add_event_status(db):
""" """
Add status column to events table for the proposal/approval workflow. Add status column to events table for proposal/approval workflow.
Values: 'proposed', 'approved', 'rejected'. Existing rows default to Values: 'proposed', 'approved', 'rejected'.
'approved' so they stay visible after upgrade. Default 'approved' for backward compatibility with existing events.
""" """
await _alter_add_column_safe( await db.execute(
db, "ALTER TABLE events.events ADD COLUMN status TEXT NOT NULL DEFAULT 'approved';"
"ALTER TABLE events.events ADD COLUMN status TEXT NOT NULL DEFAULT 'approved'",
) )
async def m009_add_nostr_columns(db): async def m009_add_nostr_columns(db):
""" """
Track the most recent NIP-52 calendar event we published for this event Add columns to track published NIP-52 Nostr calendar events.
(used for replaceable updates and NIP-09 deletes).
""" """
await _alter_add_column_safe( await db.execute(
db, "ALTER TABLE events.events ADD COLUMN nostr_event_id TEXT" "ALTER TABLE events.events ADD COLUMN nostr_event_id TEXT;"
) )
await _alter_add_column_safe( await db.execute(
db, "ALTER TABLE events.events ADD COLUMN nostr_event_created_at INTEGER" "ALTER TABLE events.events ADD COLUMN nostr_event_created_at INTEGER;"
) )
async def m010_add_events_settings(db): async def m010_add_events_settings(db):
""" """
Create the extension settings singleton row used by the admin UI to Create extension settings table for admin-configurable options.
toggle e.g. auto_approve.
""" """
await db.execute( await db.execute(
""" """
CREATE TABLE IF NOT EXISTS events.settings ( CREATE TABLE IF NOT EXISTS events.settings (
id INTEGER PRIMARY KEY DEFAULT 1, id INTEGER PRIMARY KEY DEFAULT 1,
auto_approve BOOLEAN NOT NULL DEFAULT FALSE auto_approve BOOLEAN NOT NULL DEFAULT FALSE
) );
""" """
) )
await db.execute( await db.execute(
"INSERT INTO events.settings (id, auto_approve) " "INSERT OR IGNORE INTO events.settings (id, auto_approve) VALUES (1, FALSE);"
"SELECT 1, FALSE WHERE NOT EXISTS "
"(SELECT 1 FROM events.settings WHERE id = 1)"
) )
async def m011_add_location_and_categories(db): async def m011_add_location_and_categories(db):
""" """
Add NIP-52 calendar metadata (location and a JSON-encoded category list). Add location and categories columns for NIP-52 calendar event support.
""" """
await _alter_add_column_safe( await db.execute(
db, "ALTER TABLE events.events ADD COLUMN location TEXT" "ALTER TABLE events.events ADD COLUMN location TEXT;"
) )
await _alter_add_column_safe( await db.execute(
db, "ALTER TABLE events.events ADD COLUMN categories TEXT" "ALTER TABLE events.events ADD COLUMN categories TEXT;"
) )

100
models.py
View file

@ -1,6 +1,8 @@
import json import json
from datetime import datetime from datetime import datetime
from typing import Optional
from fastapi import Query
from pydantic import BaseModel, EmailStr, Field, root_validator, validator from pydantic import BaseModel, EmailStr, Field, root_validator, validator
@ -27,22 +29,43 @@ class EventExtra(BaseModel):
class CreateEvent(BaseModel): class CreateEvent(BaseModel):
wallet: str | None = None # filled from caller's wallet if absent wallet: Optional[str] = None
name: str # title (required) name: str # title (required)
info: str = "" # description (optional) info: str = "" # description (optional, visible by default)
closing_date: str | None = None # defaults to event_end_date closing_date: Optional[str] = None # defaults to event_end_date or event_start_date
event_start_date: str # required event_start_date: str # required
event_end_date: str | None = None # defaults to event_start_date event_end_date: Optional[str] = None # defaults to event_start_date
currency: str = "sat" currency: str = "sat"
amount_tickets: int = 0 # 0 = unlimited / not ticketed amount_tickets: int = 0 # 0 = unlimited / not ticketed
price_per_ticket: float = 0 # 0 = free price_per_ticket: float = 0 # 0 = free
banner: str | None = None banner: Optional[str] = None # image URL (optional, visible by default)
location: str | None = None # venue/address (NIP-52 'location' tag) location: Optional[str] = None # venue/address (optional, visible by default)
categories: list[str] = Field(default_factory=list) # NIP-52 't' tags categories: list[str] = Field(default_factory=list) # NIP-52 't' tags
extra: EventExtra = Field(default_factory=EventExtra) extra: EventExtra = Field(default_factory=EventExtra)
status: str = "approved" # proposed, approved, rejected 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): class Event(BaseModel):
id: str id: str
wallet: str wallet: str
@ -61,7 +84,7 @@ class Event(BaseModel):
location: str | None = None location: str | None = None
categories: list[str] = Field(default_factory=list) categories: list[str] = Field(default_factory=list)
extra: EventExtra = Field(default_factory=EventExtra) extra: EventExtra = Field(default_factory=EventExtra)
status: str = "approved" status: str = "approved" # proposed, approved, rejected
nostr_event_id: str | None = None nostr_event_id: str | None = None
nostr_event_created_at: int | None = None nostr_event_created_at: int | None = None
@ -72,30 +95,10 @@ class Event(BaseModel):
return v or [] 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
banner: str | None
location: str | None = None
categories: list[str] = Field(default_factory=list)
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): class EventsSettings(BaseModel):
"""Extension-level settings for the events extension.""" """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): class TicketExtra(BaseModel):
@ -105,50 +108,15 @@ class TicketExtra(BaseModel):
refunded: 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
@root_validator
def validate_identifiers(cls, values):
name = values.get("name")
email = values.get("email")
user_id = values.get("user_id")
if not user_id and not (name and email):
raise ValueError(
"Either user_id or both name and email must be provided"
)
if user_id and (name or email):
raise ValueError("Cannot provide both user_id and name/email")
return values
class Ticket(BaseModel): class Ticket(BaseModel):
id: str id: str
wallet: str wallet: str
event: str event: str
name: str | None = None name: Optional[str] = None
email: str | None = None email: Optional[str] = None
user_id: str | None = None user_id: Optional[str] = None
registered: bool registered: bool
paid: bool paid: bool
time: datetime time: datetime
reg_timestamp: datetime reg_timestamp: datetime
extra: TicketExtra = Field(default_factory=TicketExtra) extra: TicketExtra = Field(default_factory=TicketExtra)
class PublicTicket(BaseModel):
event: str
name: str | None = None
registered: bool
paid: bool
time: datetime
reg_timestamp: datetime
class TicketPaymentRequest(BaseModel):
payment_hash: str
payment_request: str

View file

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

View file

@ -10,11 +10,13 @@ import asyncio
import json import json
from asyncio import Queue from asyncio import Queue
from collections import OrderedDict 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.helpers import encrypt_internal_message, urlsafe_short_hash
from lnbits.settings import settings from lnbits.settings import settings
from loguru import logger
from websocket import WebSocketApp
from .event import NostrEvent from .event import NostrEvent
@ -25,7 +27,7 @@ class NostrClient:
def __init__(self): def __init__(self):
self.receive_event_queue: Queue = Queue() self.receive_event_queue: Queue = Queue()
self.send_req_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.subscription_id = "events-" + urlsafe_short_hash()[:32]
self.running = False self.running = False
self._seen_events: OrderedDict[str, None] = OrderedDict() self._seen_events: OrderedDict[str, None] = OrderedDict()
@ -119,7 +121,7 @@ class NostrClient:
"""Subscribe to events matching the given filters.""" """Subscribe to events matching the given filters."""
self.subscription_id = "events-" + urlsafe_short_hash()[:32] self.subscription_id = "events-" + urlsafe_short_hash()[:32]
await self.send_req_queue.put( await self.send_req_queue.put(
["REQ", self.subscription_id, *filters] ["REQ", self.subscription_id] + filters
) )
logger.info( logger.info(
f"[EVENTS] Subscribed to NIP-52 events " f"[EVENTS] Subscribed to NIP-52 events "

View file

@ -1,43 +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`.
Pulls the wallet owner's pubkey/prvkey to sign with the user's identity.
Failures are logged and swallowed so a Nostr outage doesn't break the
HTTP flow that triggered the publish.
"""
try:
from lnbits.core.crud.users import get_account
from lnbits.core.crud.wallets import get_wallet
from . import nostr_client
wallet_obj = await get_wallet(event.wallet)
if not wallet_obj:
return
account = await get_account(wallet_obj.user)
if not account or not account.pubkey or not account.prvkey:
return
nostr_event = await publish_event_to_nostr(
nostr_client, event, account.pubkey, account.prvkey, 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

@ -9,6 +9,7 @@ Reference: https://github.com/nostr-protocol/nips/blob/master/52.md
""" """
import time import time
from typing import Optional
import coincurve import coincurve
from loguru import logger from loguru import logger
@ -88,7 +89,7 @@ async def publish_event_to_nostr(
account_pubkey: str, account_pubkey: str,
account_prvkey: str, account_prvkey: str,
delete: bool = False, delete: bool = False,
) -> NostrEvent | None: ) -> Optional[NostrEvent]:
""" """
Build, sign, and publish a NIP-52 calendar event (or delete event). Build, sign, and publish a NIP-52 calendar event (or delete event).

View file

@ -7,14 +7,13 @@ discovery — events published by other LNbits instances or Nostr
clients appear in the local events listing. clients appear in the local events listing.
""" """
import asyncio
import json import json
from datetime import datetime, timezone from datetime import datetime, timezone
from loguru import logger from loguru import logger
from .crud import db, get_event, update_event from .crud import create_event, db, get_event, update_event
from .models import Event from .models import CreateEvent, Event
from .nostr.nostr_client import NostrClient 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) await update_event(existing)
logger.info(f"[EVENTS] Updated event from Nostr: {title}") logger.info(f"[EVENTS] Updated event from Nostr: {title}")
else: else:
# Create new event from Nostr — discovered events are auto-approved # Create new event from Nostr
# (they're already public on relays). Use the d-tag as the event ID # Events discovered from Nostr are auto-approved (they're already public)
# for replaceable-event correlation. 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( new_event = Event(
id=d_tag, id=d_tag,
wallet="", wallet="",
@ -153,3 +165,6 @@ async def wait_for_nostr_events(nostr_client: NostrClient):
except Exception as e: except Exception as e:
logger.error(f"[EVENTS] Nostr sync error: {e}") logger.error(f"[EVENTS] Nostr sync error: {e}")
await asyncio.sleep(30) await asyncio.sleep(30)
import asyncio # noqa: E402

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" } urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/events" }
dependencies = [ "lnbits>1" ] dependencies = [ "lnbits>1" ]
[dependency-groups] [tool.poetry]
dev = [ package-mode = false
[tool.uv]
dev-dependencies = [
"black", "black",
"pytest-asyncio", "pytest-asyncio",
"pytest", "pytest",
@ -17,9 +20,6 @@ dev = [
"ruff", "ruff",
] ]
[tool.poetry]
package-mode = false
[tool.mypy] [tool.mypy]
exclude = "(nostr/*)" exclude = "(nostr/*)"
plugins = ["pydantic.mypy"] plugins = ["pydantic.mypy"]

View file

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

View file

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

View file

@ -1,16 +1,24 @@
window.PageEvents = { const mapEvents = function (obj) {
template: '#page-events', 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() { data() {
return { return {
events: [], events: [],
tickets: [],
currencies: [],
pendingEvents: [],
allUserEvents: [], allUserEvents: [],
pendingEvents: [],
isAdmin: false, isAdmin: false,
settings: { settings: {
auto_approve: false auto_approve: false
}, },
tickets: [],
currencies: [],
eventsTable: { eventsTable: {
columns: [ columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'}, {name: 'id', align: 'left', label: 'ID', field: 'id'},
@ -104,7 +112,6 @@ window.PageEvents = {
formDialog: { formDialog: {
show: false, show: false,
data: { data: {
currency: 'sats',
extra: { extra: {
promo_codes: [] promo_codes: []
} }
@ -113,20 +120,90 @@ window.PageEvents = {
} }
}, },
methods: { methods: {
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() { getTickets() {
LNbits.api LNbits.api
.request( .request(
'GET', 'GET',
'/events/api/v1/tickets?all_wallets=true', '/events/api/v1/tickets?all_wallets=true',
this.g.user.wallets[0].adminkey this.g.user.wallets[0].inkey
) )
.then(response => { .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) { deleteTicket(ticketId) {
const tickets = _.findWhere(this.tickets, {id: ticketId}) const tickets = _.findWhere(this.tickets, {id: ticketId})
const wallet = _.findWhere(this.g.user.wallets, {id: tickets.wallet})
LNbits.utils LNbits.utils
.confirmDialog('Are you sure you want to delete this ticket') .confirmDialog('Are you sure you want to delete this ticket')
@ -135,20 +212,23 @@ window.PageEvents = {
.request( .request(
'DELETE', 'DELETE',
'/events/api/v1/tickets/' + ticketId, '/events/api/v1/tickets/' + ticketId,
wallet.adminkey _.findWhere(this.g.user.wallets, {id: tickets.wallet}).inkey
) )
.then(response => { .then(response => {
this.tickets = _.reject(this.tickets, function (obj) { this.tickets = _.reject(this.tickets, function (obj) {
return obj.id == ticketId return obj.id == ticketId
}) })
}) })
.catch(LNbits.utils.notifyApiError) .catch(function (error) {
LNbits.utils.notifyApiError(error)
})
}) })
}, },
exportticketsCSV() { exportticketsCSV() {
LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets) LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets)
}, },
getEvents() { getEvents() {
// Always fetch own events
LNbits.api LNbits.api
.request( .request(
'GET', 'GET',
@ -156,88 +236,47 @@ window.PageEvents = {
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
.then(response => { .then(response => {
this.events = response.data this.events = response.data.map(obj => {
return mapEvents(obj)
})
this.checkCanceledEvents() this.checkCanceledEvents()
}) })
// Admin probe: a 200 from /all means we're an LNbits admin. // Admin: also fetch all users' events
LNbits.api LNbits.api
.request('GET', '/events/api/v1/events/all') .request(
'GET',
'/events/api/v1/events/all'
)
.then(response => { .then(response => {
this.isAdmin = true this.isAdmin = true
// Exclude own events (already in this.events)
const ownWalletIds = this.g.user.wallets.map(w => w.id) const ownWalletIds = this.g.user.wallets.map(w => w.id)
this.allUserEvents = response.data.filter( this.allUserEvents = response.data
e => !ownWalletIds.includes(e.wallet) .filter(obj => !ownWalletIds.includes(obj.wallet))
) .map(obj => mapEvents(obj))
}) })
.catch(() => { .catch(() => {
this.isAdmin = false this.isAdmin = false
this.allUserEvents = [] 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() { getPendingEvents() {
LNbits.api LNbits.api
.request('GET', '/events/api/v1/events/pending') .request(
'GET',
'/events/api/v1/events/pending'
)
.then(response => { .then(response => {
this.pendingEvents = response.data this.pendingEvents = response.data.map(obj => {
return mapEvents(obj)
})
}) })
.catch(() => { .catch(() => {
// Not an admin or no pending events
this.pendingEvents = [] 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)
})
},
sendEventData() { sendEventData() {
const wallet = _.findWhere(this.g.user.wallets, { const wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet id: this.formDialog.data.wallet
@ -261,7 +300,6 @@ window.PageEvents = {
this.formDialog.data = {...data} this.formDialog.data = {...data}
} else { } else {
this.formDialog.data = { this.formDialog.data = {
currency: 'sats',
extra: { extra: {
conditional: false, conditional: false,
min_tickets: 1, min_tickets: 1,
@ -284,7 +322,7 @@ window.PageEvents = {
LNbits.api LNbits.api
.request('POST', '/events/api/v1/events', wallet.adminkey, data) .request('POST', '/events/api/v1/events', wallet.adminkey, data)
.then(response => { .then(response => {
this.events.push(response.data) this.events.push(mapEvents(response.data))
this.resetEventDialog() this.resetEventDialog()
}) })
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
@ -305,7 +343,7 @@ window.PageEvents = {
this.events = _.reject(this.events, function (obj) { this.events = _.reject(this.events, function (obj) {
return obj.id == data.id return obj.id == data.id
}) })
this.events.push(response.data) this.events.push(mapEvents(response.data))
this.resetEventDialog() this.resetEventDialog()
}) })
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
@ -327,7 +365,7 @@ window.PageEvents = {
return obj.id == eventsId return obj.id == eventsId
}) })
}) })
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError(error))
}) })
}, },
exporteventsCSV() { exporteventsCSV() {
@ -351,7 +389,9 @@ window.PageEvents = {
message: `Event ${ev.name} has been canceled and refunds have been issued.`, message: `Event ${ev.name} has been canceled and refunds have been issued.`,
icon: null icon: null
}) })
this.events = this.events.map(e => (e.id === ev.id ? data : e)) this.events = this.events.map(e =>
e.id === ev.id ? mapEvents(data) : e
)
} }
}) })
} }
@ -360,13 +400,9 @@ window.PageEvents = {
if (this.g.user.wallets.length) { if (this.g.user.wallets.length) {
this.getTickets() this.getTickets()
this.getEvents() this.getEvents()
this.getSettings()
this.getPendingEvents() this.getPendingEvents()
if (this.g.allowedCurrencies && this.g.allowedCurrencies.length > 0) { this.getSettings()
this.currencies = ['sats', ...this.g.allowedCurrencies] this.currencies = await LNbits.api.getCurrencies()
} else {
this.currencies = ['sats', ...this.g.currencies]
}
} }
} }
} })

View file

@ -1,657 +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-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>
<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 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 ellipsis q-my-none">
<span v-text="SITE_TITLE"></span>
Events extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="Info"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<h5 class="text-subtitle1 q-my-none">
Events: Sell and register ticket waves for an event
</h5>
<p>
Events allows you to make a wave of tickets for an event,
each ticket is in the form of a unique QRcode, which the
user presents at registration. Events comes with a shareable
ticket scanner, which can be used to register attendees.<br />
<small>
Created by,
<a class="text-secondary" href="https://github.com/benarc"
>Ben Arc</a
>
</small>
</p>
</q-card-section>
</q-card>
<q-btn
flat
label="Swagger API"
type="a"
href="../docs#/events"
></q-btn>
</q-expansion-item>
</q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendEventData" class="q-gutter-md">
<div class="row">
<div class="col">
<q-input
filled
dense
v-model.trim="formDialog.data.name"
type="name"
label="Title of event "
></q-input>
</div>
<div class="col q-pl-sm">
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select>
</div>
</div>
<q-input
filled
dense
v-model.trim="formDialog.data.info"
type="textarea"
label="Info about the event"
hint="Markdown supported"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.banner"
type="url"
label="Image URL"
hint="Optional banner image to display on the event page"
></q-input>
<div class="row q-mt-lg">
<div class="col-4">Ticket closing date</div>
<div class="col-8">
<q-input
filled
dense
v-model.trim="formDialog.data.closing_date"
type="date"
></q-input>
</div>
</div>
<div class="row">
<div class="col-4">Event begins</div>
<div class="col-8">
<q-input
filled
dense
v-model.trim="formDialog.data.event_start_date"
type="date"
></q-input>
</div>
</div>
<div class="row">
<div class="col-4">Event ends</div>
<div class="col-8">
<q-input
filled
dense
v-model.trim="formDialog.data.event_end_date"
type="date"
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="col">
<q-select
filled
dense
v-model="formDialog.data.currency"
type="text"
label="Unit"
:options="currencies"
></q-select>
</div>
<div class="col">
<q-input
filled
dense
v-model.number="formDialog.data.amount_tickets"
type="number"
label="Amount of tickets "
></q-input>
</div>
<div class="col">
<q-input
filled
dense
v-model.number="formDialog.data.price_per_ticket"
type="number"
:label="'Price (' + formDialog.data.currency + ') *'"
:step="formDialog.data.currency != 'sats' ? '0.01' : '1'"
:mask="formDialog.data.currency != 'sats' ? '#.##' : '#'"
fill-mask="0"
reverse-fill-mask
:disable="formDialog.data.currency == null"
></q-input>
</div>
</div>
<q-expansion-item
group="advanced"
icon="settings"
label="Advanced options"
>
<div class="row q-mt-lg">
<div class="text-subtitle1 q-mb-md">Conditional Events</div>
<div class="text-caption">
Make this event conditional if
<strong>minimum tickets</strong> are sold. User will be asked to
provide a Lightning Address or LNURL pay for refunds.
</div>
<div class="col-8">
<q-toggle
v-model="formDialog.data.extra.conditional"
label="Conditional Event"
left-label
></q-toggle>
</div>
<div class="col-4">
<q-input
filled
dense
v-model.number="formDialog.data.extra.min_tickets"
type="number"
label="Minimum Tickets"
:disable="!formDialog.data.extra.conditional"
></q-input>
</div>
</div>
<q-separator class="q-my-md"></q-separator>
<div class="text-subtitle1 q-mb-md">Promo Codes</div>
<div class="text-caption">
Allow users to enter a promo code for discounts.
</div>
<div
v-for="(code, index) in formDialog.data.extra.promo_codes"
:key="index"
class="row q-col-gutter-sm q-mt-md"
>
<q-input
class="col-8"
filled
dense
v-model.trim="formDialog.data.extra.promo_codes[index].code"
type="text"
label="Promo Code"
>
<template v-slot:before>
<q-checkbox
left-label
v-model="formDialog.data.extra.promo_codes[index].active"
checked-icon="radio_button_checked"
unchecked-icon="radio_button_unchecked"
></q-checkbox>
<q-tooltip>
<span
v-text="
formDialog.data.extra.promo_codes[index].active
? 'Active'
: 'Inactive'
"
></span>
</q-tooltip>
</template>
</q-input>
<q-input
class="col-4"
filled
dense
v-model.number="
formDialog.data.extra.promo_codes[index].discount_percent
"
type="number"
label="Discount (%)"
min="0"
max="100"
>
<template v-slot:after>
<q-btn
round
dense
flat
icon="delete"
@click="formDialog.data.extra.promo_codes.splice(index, 1)"
></q-btn>
</template>
</q-input>
</div>
<div class="col-12 q-mt-md">
<q-btn
@click="
formDialog.data.extra.promo_codes.push({
code: '',
discount_percent: 0,
active: true
})
"
>Add Promo Code</q-btn
>
</div>
</q-expansion-item>
<div class="row q-mt-lg">
<q-btn
v-if="formDialog.data.id"
unelevated
color="primary"
type="submit"
>Update Event</q-btn
>
<q-btn
v-else
unelevated
color="primary"
:disable="
formDialog.data.wallet == null ||
formDialog.data.name == null ||
formDialog.data.info == null ||
formDialog.data.closing_date == null ||
formDialog.data.event_start_date == null ||
formDialog.data.event_end_date == null ||
formDialog.data.amount_tickets == null ||
formDialog.data.price_per_ticket == null
"
type="submit"
>Create Event</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
</template>

View file

@ -1,18 +1,28 @@
window.PageEventsRegister = { const mapEvents = function (obj) {
template: '#page-events-register', 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() { data() {
return { return {
tickets: [], tickets: [],
ticketsTable: { ticketsTable: {
columns: [ columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'name', align: 'left', label: 'Name', field: 'name'}, {name: 'name', align: 'left', label: 'Name', field: 'name'},
{name: 'email', align: 'left', label: 'Email', field: 'email'},
{ {
name: 'id', name: 'registered',
align: 'left', align: 'left',
label: 'ID', label: 'Registered',
field: 'id', field: 'registered'
format: val => this.shortId(val)
} }
], ],
pagination: { pagination: {
@ -22,20 +32,12 @@ window.PageEventsRegister = {
sendCamera: { sendCamera: {
show: false, show: false,
camera: 'auto' camera: 'auto'
}, }
lastScan: null
} }
}, },
methods: { methods: {
storageKey() { hoverEmail(tmp) {
return `events_scanned_${this.eventId}` this.tickets.data.emailtemp = tmp
},
loadScannedTickets() {
this.tickets = Quasar.LocalStorage.getItem(this.storageKey()) || []
},
saveScannedTicket(ticket) {
this.tickets.unshift(ticket)
Quasar.LocalStorage.set(this.storageKey(), this.tickets)
}, },
closeCamera() { closeCamera() {
this.sendCamera.show = false this.sendCamera.show = false
@ -43,32 +45,34 @@ window.PageEventsRegister = {
showCamera() { showCamera() {
this.sendCamera.show = true this.sendCamera.show = true
}, },
shortId(id) {
return id ? `${id.slice(0, 6)}...${id.slice(-4)}` : ''
},
decodeQR(res) { decodeQR(res) {
this.sendCamera.show = false this.sendCamera.show = false
const value = res[0].rawValue.split('//')[1] const value = res[0].rawValue.split('//')[1]
LNbits.api LNbits.api
.request('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 => { .then(response => {
this.saveScannedTicket(response.data) this.tickets = response.data.map(obj => {
this.lastScan = {success: true, ticket: response.data} return mapEvents(obj)
Quasar.Notify.create({type: 'positive', message: 'Registered!'}) })
})
.catch(error => {
this.lastScan = {
success: false,
ticketId: value,
error:
error.response?.data?.detail || error.message || 'Unknown error'
}
LNbits.utils.notifyApiError(error)
}) })
.catch(LNbits.utils.notifyApiError)
} }
}, },
created() { created() {
this.eventId = this.$route.params.id this.getEventTickets()
this.loadScannedTickets()
} }
} })

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,
ticketName: null
}
},
methods: {
printWindow() {
window.print()
}
},
async created() {
this.ticketId = this.$route.params.id
try {
const {data} = await LNbits.api.request(
'GET',
`/events/api/v1/tickets/${this.ticketId}`
)
this.ticketName = data.ticket_name
} catch (error) {
LNbits.utils.notifyApiError(error)
}
}
}

View file

@ -1,27 +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>
<br />
<h5 class="q-my-none">
Bookmark, print or screenshot this page,<br />
and present it for registration!
</h5>
<br />
<lnbits-qrcode
:value="`ticket://${ticketId}`"
:options="{width: 500}"
></lnbits-qrcode>
<br />
<q-btn @click="printWindow" color="grey" class="q-ml-auto">
<q-icon left size="3em" name="print"></q-icon> Print</q-btn
>
</center>
</q-card-section>
</q-card>
</div>
</div>
</template>

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

@ -5,24 +5,8 @@ from lnbits.tasks import register_invoice_listener
from loguru import logger from loguru import logger
from .crud import get_ticket from .crud import get_ticket
from .models import Ticket
from .services import set_ticket_paid from .services import set_ticket_paid
payment_listeners: dict[str, list[asyncio.Queue[Ticket]]] = {}
def register_payment_listener(payment_hash, queue: asyncio.Queue[Ticket]) -> None:
if payment_hash not in payment_listeners:
payment_listeners[payment_hash] = []
payment_listeners[payment_hash].append(queue)
def deregister_payment_listener(payment_hash, queue: asyncio.Queue[Ticket]) -> None:
if payment_hash in payment_listeners:
payment_listeners[payment_hash].remove(queue)
if not payment_listeners[payment_hash]:
del payment_listeners[payment_hash]
async def wait_for_paid_invoices(): async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue() invoice_queue = asyncio.Queue()
@ -37,12 +21,17 @@ async def on_invoice_paid(payment: Payment) -> None:
if not payment.extra or "events" != payment.extra.get("tag"): if not payment.extra or "events" != payment.extra.get("tag"):
return return
# 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 has_name_email and not has_user_id:
logger.warning(f"Ticket {payment.payment_hash} missing name/email or user_id.")
return
ticket = await get_ticket(payment.payment_hash) ticket = await get_ticket(payment.payment_hash)
if not ticket: if not ticket:
logger.warning(f"Ticket for payment {payment.payment_hash} not found.") logger.warning(f"Ticket for payment {payment.payment_hash} not found.")
return return
ticket = await set_ticket_paid(ticket) await set_ticket_paid(ticket)
if payment_listeners.get(payment.payment_hash):
for paid_ticket_queue in payment_listeners[payment.payment_hash]:
paid_ticket_queue.put_nowait(ticket)

View file

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

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 datetime import date, datetime
from lnbits.core.views.generic import index, index_public from http import HTTPStatus
from lnbits.decorators import check_account_id_exists
from fastapi import APIRouter, Depends, Request
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse
from .crud import get_event, get_ticket, purge_unpaid_tickets, update_event
from .services import refund_tickets
events_generic_router = APIRouter() events_generic_router = APIRouter()
events_generic_router.add_api_route(
"/",
methods=["GET"],
endpoint=index,
dependencies=[Depends(check_account_id_exists)],
)
events_generic_router.add_api_route( def events_renderer():
"/{event_id}", methods=["GET"], endpoint=index_public 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( @events_generic_router.get("/", response_class=HTMLResponse)
"/register/{event_id}", methods=["GET"], endpoint=index_public async def index(request: Request, user: User = Depends(check_user_exists)):
) return events_renderer().TemplateResponse(
"events/index.html", {"request": request, "user": user.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,
},
)

View file

@ -1,17 +1,8 @@
import asyncio
from datetime import datetime, timezone from datetime import datetime, timezone
from http import HTTPStatus from http import HTTPStatus
from typing import Any
from fastapi import ( from fastapi import APIRouter, Depends, Query
APIRouter, from lnbits.core.crud import get_standalone_payment, get_user
Depends,
HTTPException,
Query,
WebSocket,
WebSocketDisconnect,
)
from lnbits.core.crud import get_user
from lnbits.core.models import Account, WalletTypeInfo from lnbits.core.models import Account, WalletTypeInfo
from lnbits.core.services import create_invoice from lnbits.core.services import create_invoice
from lnbits.decorators import ( from lnbits.decorators import (
@ -23,6 +14,7 @@ from lnbits.utils.exchange_rates import (
fiat_amount_as_satoshis, fiat_amount_as_satoshis,
get_fiat_rate_satoshis, get_fiat_rate_satoshis,
) )
from starlette.exceptions import HTTPException
from .crud import ( from .crud import (
create_event, create_event,
@ -30,7 +22,6 @@ from .crud import (
delete_event, delete_event,
delete_event_tickets, delete_event_tickets,
delete_ticket, delete_ticket,
get_all_events,
get_event, get_event,
get_event_tickets, get_event_tickets,
get_events, get_events,
@ -40,139 +31,88 @@ from .crud import (
get_ticket, get_ticket,
get_tickets, get_tickets,
get_tickets_by_user_id, get_tickets_by_user_id,
purge_unpaid_tickets, # TODO: consider exposing purge_unpaid_tickets via an admin endpoint
update_event, update_event,
update_settings, update_settings,
update_ticket, update_ticket,
) )
from .models import ( from .models import CreateEvent, CreateTicket, EventsSettings, Ticket
CreateEvent, from .nostr_publisher import publish_event_to_nostr
CreateTicket, from .services import refund_tickets, set_ticket_paid
Event,
EventsSettings,
PublicEvent,
PublicTicket,
Ticket,
TicketPaymentRequest,
)
from .nostr_hooks import publish_or_delete_nostr_event
from .services import refund_tickets
from .tasks import deregister_payment_listener, register_payment_listener
events_api_router = APIRouter(prefix="/api/v1/events") events_api_router = APIRouter()
tickets_api_router = APIRouter(prefix="/api/v1/tickets")
# Literal-prefix routes (/public, /all, /pending, /settings) MUST be declared async def _publish_or_delete_nostr_event(event, delete=False):
# before any "/{event_id}" route or FastAPI matches them as a path parameter. """Publish (or delete) a NIP-52 calendar event using the creator's keypair."""
try:
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.crud.users import get_account
from . import nostr_client
wallet_obj = await get_wallet(event.wallet)
if not wallet_obj:
return
account = await get_account(wallet_obj.user)
if not account or not account.pubkey or not account.prvkey:
return
nostr_event = await publish_event_to_nostr(
nostr_client, event, account.pubkey, account.prvkey, 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 e:
logger.warning(f"[EVENTS] Nostr publish failed: {e}")
@events_api_router.get("") @events_api_router.get("/api/v1/events")
async def api_events( async def api_events(
all_wallets: bool = Query(False), all_wallets: bool = Query(False),
wallet: WalletTypeInfo = Depends(require_invoice_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> list[Event]: ):
wallet_ids = [wallet.wallet.id] wallet_ids = [wallet.wallet.id]
if all_wallets: if all_wallets:
user = await get_user(wallet.wallet.user) user = await get_user(wallet.wallet.user)
wallet_ids = user.wallet_ids if user else [] wallet_ids = user.wallet_ids if user else []
return await get_events(wallet_ids)
return [event.dict() for event in await get_events(wallet_ids)]
@events_api_router.get("/public") @events_api_router.get("/api/v1/events/public")
async def api_events_public() -> list[Event]: async def api_events_public():
"""Approved, non-canceled events for an anonymous public listing.""" """
return await get_public_events() Retrieve approved, non-canceled events for public display.
No authentication required.
"""
events = await get_public_events()
return [event.dict() for event in events]
@events_api_router.get("/all") @events_api_router.get("/api/v1/events/all")
async def api_events_all( async def api_events_all(
admin: Account = Depends(check_admin), admin: Account = Depends(check_admin),
) -> list[Event]: ):
"""All events across all wallets. LNbits admin only.""" """Get all events across all wallets. LNbits admin only."""
return await get_all_events() from .crud import get_all_events
events = await get_all_events()
return [event.dict() for event in events]
@events_api_router.get("/pending") @events_api_router.post("/api/v1/events")
async def api_events_pending(
admin: Account = Depends(check_admin),
) -> list[Event]:
"""Proposed events awaiting admin approval. LNbits admin only."""
return await get_pending_events()
@events_api_router.get("/settings")
async def api_get_settings(
admin: Account = Depends(check_admin),
) -> EventsSettings:
return await get_settings()
@events_api_router.put("/settings")
async def api_update_settings(
data: EventsSettings,
admin: Account = Depends(check_admin),
) -> EventsSettings:
return await update_settings(data)
@events_api_router.get("/{event_id}", response_model=PublicEvent)
async def api_get_event(event_id: str) -> Event:
"""Public event detail used by display.vue.
For approved events we run the upstream sold-out / closing-window /
conditional gates. For non-approved events (proposed / rejected) we
return the trimmed PublicEvent with status set so the SFC can render
the pending-approval banner without a separate request.
"""
event = await get_event(event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
if event.status != "approved":
# Proposed/rejected events are not yet ticketable; skip ticket gates.
return event
await purge_unpaid_tickets(event_id)
# closing_date is filled in by create_event (defaults to end_date or
# start_date) but the field is typed Optional, so guard for the typechecker.
closing_date = (
event.closing_date or event.event_end_date or event.event_start_date
)
is_window_open = datetime.now(timezone.utc) < datetime.strptime(
closing_date, "%Y-%m-%d"
).replace(tzinfo=timezone.utc)
is_min_tickets_met = (
event.sold >= event.extra.min_tickets if event.extra.conditional else True
)
if event.amount_tickets < 1:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
if event.extra.conditional and not is_min_tickets_met and not is_window_open:
event.canceled = True
await update_event(event)
await refund_tickets(event_id)
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.")
if not is_window_open:
raise HTTPException(
status_code=HTTPStatus.GONE, detail="Ticket closing date has passed."
)
return event
@events_api_router.post("")
async def api_event_create( async def api_event_create(
data: CreateEvent, data: CreateEvent,
wallet: WalletTypeInfo = Depends(require_invoice_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> Event: ):
"""Create a new event. """
Create a new event. Any authenticated user can create events.
Anyone with a wallet invoice key can submit. Non-LNbits-admins land in Admin-created events are auto-approved. Non-admin events require
`proposed` status unless `auto_approve` is enabled in extension settings. approval unless auto_approve is enabled in extension settings.
""" """
if not data.wallet: if not data.wallet:
data.wallet = wallet.wallet.id data.wallet = wallet.wallet.id
@ -190,23 +130,26 @@ async def api_event_create(
event = await create_event(data) event = await create_event(data)
# Publish to Nostr if approved
if event.status == "approved": if event.status == "approved":
await publish_or_delete_nostr_event(event) await _publish_or_delete_nostr_event(event)
return event return event.dict()
@events_api_router.put("/{event_id}") @events_api_router.put("/api/v1/events/{event_id}")
async def api_event_update( async def api_event_update(
event_id: str, event_id: str,
data: CreateEvent, data: CreateEvent,
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Event: ):
"""Update an existing event. Requires admin key (event owner)."""
event = await get_event(event_id) event = await get_event(event_id)
if not event: if not event:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
) )
if event.wallet != wallet.wallet.id: if event.wallet != wallet.wallet.id:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="Not your event." status_code=HTTPStatus.FORBIDDEN, detail="Not your event."
@ -215,59 +158,77 @@ async def api_event_update(
setattr(event, k, v) setattr(event, k, v)
event = await update_event(event) event = await update_event(event)
# Re-publish the replaceable NIP-52 event if we already announced it. # Republish to Nostr if event is approved (kind 31922 is replaceable)
if event.status == "approved" and event.nostr_event_id: if event.status == "approved" and event.nostr_event_id:
await publish_or_delete_nostr_event(event) await _publish_or_delete_nostr_event(event)
return event return event.dict()
@events_api_router.put("/{event_id}/cancel") @events_api_router.put("/api/v1/events/{event_id}/cancel")
async def api_event_cancel( async def api_event_cancel(
event_id: str, event_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Event: ):
event = await get_event(event_id) event = await get_event(event_id)
if not event: if not event:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
) )
if event.wallet != wallet.wallet.id: if event.wallet != wallet.wallet.id:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.") raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.")
event.canceled = True event.canceled = True
event = await update_event(event) event = await update_event(event)
await refund_tickets(event.id) await refund_tickets(event.id)
# Delete NIP-52 event from Nostr if it was published
if event.nostr_event_id: if event.nostr_event_id:
await publish_or_delete_nostr_event(event, delete=True) await _publish_or_delete_nostr_event(event, delete=True)
return event return event.dict()
@events_api_router.delete("/{event_id}") @events_api_router.delete("/api/v1/events/{event_id}")
async def api_form_delete( async def api_form_delete(
event_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) event_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> None: ):
event = await get_event(event_id) event = await get_event(event_id)
if not event: if not event:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
) )
if event.wallet != wallet.wallet.id: if event.wallet != wallet.wallet.id:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.") raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.")
# Delete NIP-52 event from Nostr if it was published
if event.nostr_event_id: if event.nostr_event_id:
await publish_or_delete_nostr_event(event, delete=True) await _publish_or_delete_nostr_event(event, delete=True)
await delete_event(event_id) await delete_event(event_id)
await delete_event_tickets(event_id) await delete_event_tickets(event_id)
return "", HTTPStatus.NO_CONTENT
@events_api_router.put("/{event_id}/approve") #########Event Approval##########
@events_api_router.get("/api/v1/events/pending")
async def api_events_pending(
admin: Account = Depends(check_admin),
):
"""Get all proposed events awaiting approval. LNbits admin only."""
events = await get_pending_events()
return [event.dict() for event in events]
@events_api_router.put("/api/v1/events/{event_id}/approve")
async def api_event_approve( async def api_event_approve(
event_id: str, event_id: str,
admin: Account = Depends(check_admin), admin: Account = Depends(check_admin),
) -> Event: ):
"""Approve a proposed event. LNbits admin only."""
event = await get_event(event_id) event = await get_event(event_id)
if not event: if not event:
raise HTTPException( raise HTTPException(
@ -280,15 +241,19 @@ async def api_event_approve(
) )
event.status = "approved" event.status = "approved"
event = await update_event(event) event = await update_event(event)
await publish_or_delete_nostr_event(event)
return event # Publish NIP-52 calendar event to Nostr
await _publish_or_delete_nostr_event(event)
return event.dict()
@events_api_router.put("/{event_id}/reject") @events_api_router.put("/api/v1/events/{event_id}/reject")
async def api_event_reject( async def api_event_reject(
event_id: str, event_id: str,
admin: Account = Depends(check_admin), admin: Account = Depends(check_admin),
) -> Event: ):
"""Reject a proposed event. LNbits admin only."""
event = await get_event(event_id) event = await get_event(event_id)
if not event: if not event:
raise HTTPException( raise HTTPException(
@ -300,95 +265,128 @@ async def api_event_reject(
detail=f"Event is already {event.status}.", detail=f"Event is already {event.status}.",
) )
event.status = "rejected" event.status = "rejected"
return await update_event(event) event = await update_event(event)
return event.dict()
@events_api_router.get( #########Settings##########
"/{event_id}/tickets",
response_model=list[PublicTicket],
)
async def api_event_tickets(event_id: str) -> list[Ticket]:
return await get_event_tickets(event_id)
@tickets_api_router.get("") @events_api_router.get("/api/v1/settings")
async def api_get_settings(
admin: Account = Depends(check_admin),
) -> EventsSettings:
"""Get extension settings. LNbits admin only."""
return await get_settings()
@events_api_router.put("/api/v1/settings")
async def api_update_settings(
data: EventsSettings,
admin: Account = Depends(check_admin),
) -> EventsSettings:
"""Update extension settings. LNbits admin only."""
return await update_settings(data)
#########Tickets##########
@events_api_router.get("/api/v1/tickets")
async def api_tickets( async def api_tickets(
all_wallets: bool = Query(False), all_wallets: bool = Query(False),
key_info: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> list[Ticket]: ) -> list[Ticket]:
wallet_ids = [key_info.wallet.id] wallet_ids = [wallet.wallet.id]
if all_wallets: if all_wallets:
user = await get_user(key_info.wallet.user) user = await get_user(wallet.wallet.user)
wallet_ids = user.wallet_ids if user else [] wallet_ids = user.wallet_ids if user else []
return await get_tickets(wallet_ids) return await get_tickets(wallet_ids)
@tickets_api_router.get("/user/{user_id}") @events_api_router.get("/api/v1/tickets/user/{user_id}")
async def api_tickets_by_user_id(user_id: str) -> list[Ticket]: async def api_tickets_by_user_id(user_id: str) -> list[Ticket]:
"""Tickets bound to an LNbits user_id (used by external integrations). """Get all tickets for a specific user by their user_id"""
Declared before /{ticket_id} so FastAPI matches the literal `/user/`
prefix instead of treating "user" as a ticket id.
"""
return await get_tickets_by_user_id(user_id) return await get_tickets_by_user_id(user_id)
@tickets_api_router.get("/{ticket_id}", response_model=PublicTicket) @events_api_router.post("/api/v1/tickets/{event_id}")
async def api_get_ticket(ticket_id: str) -> Ticket: async def api_ticket_create(event_id: str, data: CreateTicket):
ticket = await get_ticket(ticket_id) if data.user_id:
if not ticket: return await api_ticket_make_ticket_with_user_id(event_id, data.user_id)
raise HTTPException( else:
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist." promo_code = data.promo_code.upper() if data.promo_code else None
refund_address = data.refund_address
return await api_ticket_make_ticket(
event_id, data.name, data.email, promo_code, refund_address
) )
event = await get_event(ticket.event)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
return ticket
@tickets_api_router.post("/{event_id}") async def api_ticket_make_ticket_with_user_id(event_id: str, user_id: str):
async def api_ticket_create(
event_id: str, data: CreateTicket
) -> TicketPaymentRequest:
event = await get_event(event_id) event = await get_event(event_id)
if not event: if not event:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
) )
if event.status != "approved":
raise HTTPException(
status_code=HTTPStatus.GONE,
detail="Event is not yet open for tickets.",
)
if event.canceled:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.")
if event.amount_tickets > 0 and event.sold >= event.amount_tickets:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
if data.user_id:
return await _create_user_id_ticket(event, data.user_id)
return await _create_named_ticket(event, data)
async def _create_named_ticket(
event: Event, data: CreateTicket
) -> TicketPaymentRequest:
name = data.name
email = data.email
promo_code = data.promo_code.upper() if data.promo_code else None
refund_address = data.refund_address
price = event.price_per_ticket price = event.price_per_ticket
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email} extra = {"tag": "events", "user_id": user_id}
if event.currency != "sats":
price = await fiat_amount_as_satoshis(event.price_per_ticket, event.currency)
extra["fiat"] = True
extra["currency"] = event.currency
extra["fiatAmount"] = event.price_per_ticket
extra["rate"] = await get_fiat_rate_satoshis(event.currency)
try:
payment = await create_invoice(
wallet_id=event.wallet,
amount=price,
memo=f"{event_id}",
extra=extra,
)
await create_ticket(
payment_hash=payment.payment_hash,
wallet=event.wallet,
event=event.id,
user_id=user_id,
)
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
) from exc
return {"payment_hash": payment.payment_hash, "payment_request": payment.bolt11}
@events_api_router.get("/api/v1/tickets/{event_id}/user/{user_id}")
async def api_ticket_make_ticket_user_id(event_id: str, user_id: str):
return await api_ticket_make_ticket_with_user_id(event_id, user_id)
@events_api_router.get("/api/v1/tickets/{event_id}/{name}/{email}")
async def api_ticket_make_ticket(
event_id, name, email, promo_code=None, refund_address=None
):
event = await get_event(event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
price = event.price_per_ticket
extra = {"tag": "events", "name": name, "email": email}
if promo_code: if promo_code:
# check if promo_code exists in event.extra.promo_codes
if promo_code not in [pc.code for pc in event.extra.promo_codes]: if promo_code not in [pc.code for pc in event.extra.promo_codes]:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code." status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code."
) )
# get the promocode
promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code) promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code)
extra["promo_code"] = promo.code extra["promo_code"] = promo.code
price = event.price_per_ticket * (1 - promo.discount_percent / 100) price = event.price_per_ticket * (1 - promo.discount_percent / 100)
@ -398,107 +396,87 @@ async def _create_named_ticket(
extra["currency"] = event.currency extra["currency"] = event.currency
extra["fiatAmount"] = price extra["fiatAmount"] = price
extra["rate"] = await get_fiat_rate_satoshis(event.currency) extra["rate"] = await get_fiat_rate_satoshis(event.currency)
price = await fiat_amount_as_satoshis(price, event.currency) price = await fiat_amount_as_satoshis(price, event.currency)
payment = await create_invoice(
wallet_id=event.wallet,
amount=price,
memo=f"{event.id}",
extra=extra,
)
await create_ticket(
payment_hash=payment.payment_hash,
wallet=event.wallet,
event=event.id,
name=name,
email=email,
extra={
"applied_promo_code": promo_code,
"refund_address": refund_address,
"sats_paid": int(price),
},
)
return TicketPaymentRequest(
payment_hash=payment.payment_hash, payment_request=payment.bolt11
)
async def _create_user_id_ticket(
event: Event, user_id: str
) -> TicketPaymentRequest:
price = event.price_per_ticket
extra: dict[str, Any] = {"tag": "events", "user_id": user_id}
if event.currency != "sats":
price = await fiat_amount_as_satoshis(event.price_per_ticket, event.currency)
extra["fiat"] = True
extra["currency"] = event.currency
extra["fiatAmount"] = event.price_per_ticket
extra["rate"] = await get_fiat_rate_satoshis(event.currency)
payment = await create_invoice(
wallet_id=event.wallet,
amount=price,
memo=f"{event.id}",
extra=extra,
)
await create_ticket(
payment_hash=payment.payment_hash,
wallet=event.wallet,
event=event.id,
user_id=user_id,
)
return TicketPaymentRequest(
payment_hash=payment.payment_hash, payment_request=payment.bolt11
)
@tickets_api_router.websocket("/ws/{payment_hash}")
async def websocket_endpoint(payment_hash: str, websocket: WebSocket) -> None:
await websocket.accept()
queue: asyncio.Queue[Ticket] = asyncio.Queue()
register_payment_listener(payment_hash, queue)
disconnect_task: asyncio.Task | None = None
payment_task: asyncio.Task | None = None
try: try:
ticket = await get_ticket(payment_hash) payment = await create_invoice(
if ticket and ticket.paid: wallet_id=event.wallet,
await websocket.send_json({"paid": True}) amount=price,
return memo=f"{event_id}",
extra=extra,
while True: )
disconnect_task = asyncio.create_task(websocket.receive_text()) await create_ticket(
payment_task = asyncio.create_task(queue.get()) payment_hash=payment.payment_hash,
done, pending = await asyncio.wait( wallet=event.wallet,
{disconnect_task, payment_task}, return_when=asyncio.FIRST_COMPLETED event=event.id,
) name=name,
email=email,
for task in pending: extra={
task.cancel() "applied_promo_code": promo_code,
"refund_address": refund_address,
if disconnect_task in done: "sats_paid": int(price),
try: },
disconnect_task.result() )
except WebSocketDisconnect: except Exception as exc:
pass raise HTTPException(
break status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
) from exc
ticket = payment_task.result() return {"payment_hash": payment.payment_hash, "payment_request": payment.bolt11}
await websocket.send_json({"paid": ticket.paid})
if ticket.paid:
break
finally:
for pending_task in (disconnect_task, payment_task):
if pending_task and not pending_task.done():
pending_task.cancel()
deregister_payment_listener(payment_hash, queue)
@tickets_api_router.delete("/{ticket_id}") @events_api_router.post("/api/v1/tickets/{event_id}/{payment_hash}")
async def api_ticket_send_ticket(event_id, payment_hash):
event = await get_event(event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Event could not be fetched.",
)
ticket = await get_ticket(payment_hash)
if not ticket:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Ticket could not be fetched.",
)
payment = await get_standalone_payment(payment_hash, incoming=True)
assert payment
if ticket.extra.applied_promo_code:
promo = next(
(
pc
for pc in event.extra.promo_codes
if pc.code == ticket.extra.applied_promo_code
),
None,
)
if promo:
event.price_per_ticket *= 1 - promo.discount_percent / 100
price = (
event.price_per_ticket * 1000
if event.currency == "sats"
else await fiat_amount_as_satoshis(event.price_per_ticket, event.currency)
* 1000
)
# check if price is equal to payment.amount
lower_bound = price * 0.99 # 1% decrease
if not payment.pending and abs(payment.amount) >= lower_bound: # allow 1% error
ticket.extra.sats_paid = int(payment.amount / 1000)
await set_ticket_paid(ticket)
return {"paid": True, "ticket_id": ticket.id}
return {"paid": False}
@events_api_router.delete("/api/v1/tickets/{ticket_id}")
async def api_ticket_delete( async def api_ticket_delete(
ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) ticket_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
) -> None: ):
ticket = await get_ticket(ticket_id) ticket = await get_ticket(ticket_id)
if not ticket: if not ticket:
raise HTTPException( raise HTTPException(
@ -511,8 +489,14 @@ async def api_ticket_delete(
await delete_ticket(ticket_id) await delete_ticket(ticket_id)
@tickets_api_router.put("/register/{ticket_id}") @events_api_router.get("/api/v1/eventtickets/{event_id}")
async def api_event_register_ticket(ticket_id) -> Ticket: async def api_event_tickets(event_id: str) -> list[Ticket]:
return await get_event_tickets(event_id)
# TODO: PUT, updates db! @tal
@events_api_router.get("/api/v1/register/ticket/{ticket_id}")
async def api_event_register_ticket(ticket_id) -> list[Ticket]:
ticket = await get_ticket(ticket_id) ticket = await get_ticket(ticket_id)
if not ticket: if not ticket:
@ -532,5 +516,5 @@ async def api_event_register_ticket(ticket_id) -> Ticket:
ticket.registered = True ticket.registered = True
ticket.reg_timestamp = datetime.now(timezone.utc) ticket.reg_timestamp = datetime.now(timezone.utc)
ticket = await update_ticket(ticket) await update_ticket(ticket)
return ticket return await get_event_tickets(ticket.event)