Compare commits

...
Sign in to create a new pull request.

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
20 changed files with 1904 additions and 118 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

@ -2,9 +2,9 @@
<small>For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions)</small> <small>For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions)</small>
## Sell tickets for events and use the built-in scanner for registering attendants ## Sell tickets for events and use the built-in scanner for registering attendees
Events alows you to make tickets for an event. Each ticket is in the form of a uniqque QR code. After registering, and paying for ticket, the user gets a QR code to present at registration/entrance. Events 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.

View file

@ -21,6 +21,9 @@ events_static_files = [
scheduled_tasks: list[asyncio.Task] = [] scheduled_tasks: list[asyncio.Task] = []
# Module-level NostrClient — None when nostrclient is unavailable
nostr_client = None
def events_stop(): def events_stop():
for task in scheduled_tasks: for task in scheduled_tasks:
@ -29,12 +32,50 @@ def events_stop():
except Exception as ex: except Exception as ex:
logger.warning(ex) logger.warning(ex)
global nostr_client
if nostr_client:
asyncio.get_event_loop().create_task(nostr_client.stop())
def events_start(): def events_start():
from lnbits.tasks import create_permanent_unique_task from lnbits.tasks import create_permanent_unique_task
task = create_permanent_unique_task("ext_events", wait_for_paid_invoices) task1 = create_permanent_unique_task("ext_events", wait_for_paid_invoices)
scheduled_tasks.append(task) scheduled_tasks.append(task1)
async def _start_nostr_client():
global nostr_client
await asyncio.sleep(10) # Wait for nostrclient to be ready
try:
from .nostr.nostr_client import NostrClient
nostr_client = NostrClient()
logger.info("[EVENTS] Starting NostrClient for NIP-52 sync")
await nostr_client.run_forever()
except Exception as e:
logger.warning(f"[EVENTS] NostrClient failed to start: {e}")
logger.info("[EVENTS] Events will work without Nostr sync")
task2 = create_permanent_unique_task("ext_events_nostr", _start_nostr_client)
scheduled_tasks.append(task2)
async def _sync_nostr_events():
global nostr_client
await asyncio.sleep(15) # Wait for NostrClient to connect
if not nostr_client:
logger.info("[EVENTS] No NostrClient, skipping Nostr sync")
return
try:
from .nostr_sync import wait_for_nostr_events
await wait_for_nostr_events(nostr_client)
except Exception as e:
logger.error(f"[EVENTS] Nostr sync task failed: {e}")
task3 = create_permanent_unique_task(
"ext_events_nostr_sync", _sync_nostr_events
)
scheduled_tasks.append(task3)
__all__ = ["db", "events_ext", "events_start", "events_static_files", "events_stop"] __all__ = ["db", "events_ext", "events_start", "events_static_files", "events_stop"]

175
crud.py
View file

@ -1,54 +1,145 @@
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, Ticket from .models import CreateEvent, Event, EventsSettings, Ticket, TicketExtra
def _parse_ticket_row(row) -> dict:
"""
Parse a database row into a dict suitable for Ticket model creation.
Handles:
- Empty string to None conversion for name/email
- JSON string to dict conversion for extra field
"""
ticket_data = dict(row)
# Convert empty strings back to None for the model
if ticket_data.get("name") == "":
ticket_data["name"] = None
if ticket_data.get("email") == "":
ticket_data["email"] = None
# Parse extra field from JSON string if needed
# (db.insert() serializes to JSON, but manual fetchone/fetchall returns string)
extra = ticket_data.get("extra")
if isinstance(extra, str):
ticket_data["extra"] = json.loads(extra)
return ticket_data
db = Database("ext_events") db = Database("ext_events")
async def create_ticket( async def create_ticket(
payment_hash: str, wallet: str, event: str, name: str, email: str payment_hash: str,
wallet: str,
event: str,
name: Optional[str] = None,
email: Optional[str] = None,
user_id: Optional[str] = None,
extra: Optional[dict] = None,
) -> Ticket: ) -> Ticket:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
ticket = Ticket(
# TODO: Check if this empty string workaround is still needed.
# This converts None to empty strings for database storage because:
# 1. Database may have NOT NULL constraints on name/email columns
# 2. When user_id is provided, name/email are not used (mutually exclusive)
# 3. The get_ticket() functions convert empty strings back to None when reading
# Consider using nullable columns instead of this empty string pattern.
if user_id:
db_name = ""
db_email = ""
else:
db_name = name or ""
db_email = email or ""
# Create ticket with database-compatible values for insertion
# Using db.insert() ensures proper serialization of the extra field (TicketExtra)
# across all database backends (SQLite, PostgreSQL, CockroachDB)
db_ticket = Ticket(
id=payment_hash,
wallet=wallet,
event=event,
name=db_name,
email=db_email,
user_id=user_id,
registered=False,
paid=False,
reg_timestamp=now,
time=now,
extra=TicketExtra(**extra) if extra else TicketExtra(),
)
await db.insert("events.ticket", db_ticket)
# Return ticket with original name/email values (not empty strings)
# This maintains consistency with how get_ticket() converts empty strings back to None
return Ticket(
id=payment_hash, id=payment_hash,
wallet=wallet, wallet=wallet,
event=event, event=event,
name=name, name=name,
email=email, email=email,
user_id=user_id,
registered=False, registered=False,
paid=False, paid=False,
reg_timestamp=now, reg_timestamp=now,
time=now, time=now,
extra=TicketExtra(**extra) if extra else TicketExtra(),
) )
await db.insert("events.ticket", ticket)
return ticket
async def update_ticket(ticket: Ticket) -> Ticket: async def update_ticket(ticket: Ticket) -> Ticket:
await db.update("events.ticket", ticket) # Create a new Ticket object with corrected values for database constraints
ticket_dict = ticket.dict()
# Convert None values to empty strings for database constraints
if ticket_dict.get("name") is None:
ticket_dict["name"] = ""
if ticket_dict.get("email") is None:
ticket_dict["email"] = ""
# 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]:
return 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},
Ticket,
) )
if not row:
return None
return Ticket(**_parse_ticket_row(row))
async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]: async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]:
if isinstance(wallet_ids, str): if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids] wallet_ids = [wallet_ids]
q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids]) q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids])
return await db.fetchall( rows = await db.fetchall(f"SELECT * FROM events.ticket WHERE wallet IN ({q})")
f"SELECT * FROM events.ticket WHERE wallet IN ({q})",
model=Ticket, return [Ticket(**_parse_ticket_row(row)) for row in rows]
async def get_tickets_by_user_id(user_id: str) -> list[Ticket]:
"""Get all tickets for a specific user by their user_id"""
rows = await db.fetchall(
"SELECT * FROM events.ticket WHERE user_id = :user_id ORDER BY time DESC",
{"user_id": user_id}
) )
return [Ticket(**_parse_ticket_row(row)) for row in rows]
async def delete_ticket(payment_hash: str) -> None: async def delete_ticket(payment_hash: str) -> None:
await db.execute("DELETE FROM events.ticket WHERE id = :id", {"id": payment_hash}) await db.execute("DELETE FROM events.ticket WHERE id = :id", {"id": payment_hash})
@ -73,6 +164,12 @@ 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 if not provided
if not data.event_end_date:
data.event_end_date = data.event_start_date
# Default closing date to end date if not provided
if not data.closing_date:
data.closing_date = data.event_end_date
event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict()) event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict())
await db.insert("events.events", event) await db.insert("events.events", event)
return event return event
@ -101,13 +198,63 @@ async def get_events(wallet_ids: str | list[str]) -> list[Event]:
) )
async def get_all_events() -> list[Event]:
"""Get all events from the database without wallet filtering."""
return await db.fetchall(
"SELECT * FROM events.events ORDER BY time DESC",
model=Event,
)
async def get_public_events() -> list[Event]:
"""Get approved, non-canceled events for public display."""
return await db.fetchall(
"""
SELECT * FROM events.events
WHERE status = 'approved' AND canceled = FALSE
ORDER BY event_start_date ASC
""",
model=Event,
)
async def get_pending_events() -> list[Event]:
"""Get proposed events awaiting admin approval."""
return await db.fetchall(
"SELECT * FROM events.events WHERE status = 'proposed' ORDER BY time DESC",
model=Event,
)
async def get_settings() -> EventsSettings:
"""Get extension settings (single row, always exists after migration)."""
row = await db.fetchone("SELECT * FROM events.settings WHERE id = 1")
if row:
return EventsSettings(**dict(row))
return EventsSettings()
async def update_settings(settings: EventsSettings) -> EventsSettings:
"""Update extension settings."""
await db.execute(
"""
UPDATE events.settings
SET auto_approve = :auto_approve
WHERE id = 1
""",
{"auto_approve": settings.auto_approve},
)
return settings
async def delete_event(event_id: str) -> None: async def delete_event(event_id: str) -> None:
await db.execute("DELETE FROM events.events WHERE id = :id", {"id": event_id}) await db.execute("DELETE FROM events.events WHERE id = :id", {"id": event_id})
async def get_event_tickets(event_id: str) -> list[Ticket]: async def get_event_tickets(event_id: str) -> list[Ticket]:
return await db.fetchall( rows = await db.fetchall(
"SELECT * FROM events.ticket WHERE event = :event", "SELECT * FROM events.ticket WHERE event = :event",
{"event": event_id}, {"event": event_id},
Ticket,
) )
return [Ticket(**_parse_ticket_row(row)) for row in rows]

View file

@ -160,3 +160,86 @@ async def m005_add_image_banner(db):
Add a column to allow an image banner for the event Add a column to allow an image banner for the event
""" """
await db.execute("ALTER TABLE events.events ADD COLUMN banner TEXT;") await db.execute("ALTER TABLE events.events ADD COLUMN banner TEXT;")
async def m006_add_user_id_support(db):
"""
Add user_id column to tickets table to support LNbits user-id as identifier
Make name and email optional when user_id is provided
"""
await db.execute("ALTER TABLE events.ticket ADD COLUMN user_id TEXT;")
# Since SQLite doesn't support changing column constraints directly,
# we'll work around this by allowing the application logic to handle
# the validation that either (name AND email) OR user_id is provided
# The database will continue to expect name and email as NOT NULL
# but we'll insert empty strings for user_id tickets
async def m007_add_extra_fields(db):
"""
Add a canceled and 'extra' column to events and ticket tables
to support promo codes and ticket metadata.
"""
# Add canceled and 'extra' columns to events table
# SQLite requires separate ALTER TABLE statements for each column
await db.execute(
"ALTER TABLE events.events ADD COLUMN canceled BOOLEAN NOT NULL DEFAULT FALSE;"
)
await db.execute(
"ALTER TABLE events.events ADD COLUMN extra TEXT;"
)
# Add 'extra' column to ticket table
await db.execute("ALTER TABLE events.ticket ADD COLUMN extra TEXT;")
async def m008_add_event_status(db):
"""
Add status column to events table for proposal/approval workflow.
Values: 'proposed', 'approved', 'rejected'.
Default 'approved' for backward compatibility with existing events.
"""
await db.execute(
"ALTER TABLE events.events ADD COLUMN status TEXT NOT NULL DEFAULT 'approved';"
)
async def m009_add_nostr_columns(db):
"""
Add columns to track published NIP-52 Nostr calendar events.
"""
await db.execute(
"ALTER TABLE events.events ADD COLUMN nostr_event_id TEXT;"
)
await db.execute(
"ALTER TABLE events.events ADD COLUMN nostr_event_created_at INTEGER;"
)
async def m010_add_events_settings(db):
"""
Create extension settings table for admin-configurable options.
"""
await db.execute(
"""
CREATE TABLE IF NOT EXISTS events.settings (
id INTEGER PRIMARY KEY DEFAULT 1,
auto_approve BOOLEAN NOT NULL DEFAULT FALSE
);
"""
)
await db.execute(
"INSERT OR IGNORE INTO events.settings (id, auto_approve) VALUES (1, FALSE);"
)
async def m011_add_location_and_categories(db):
"""
Add location and categories columns for NIP-52 calendar event support.
"""
await db.execute(
"ALTER TABLE events.events ADD COLUMN location TEXT;"
)
await db.execute(
"ALTER TABLE events.events ADD COLUMN categories TEXT;"
)

112
models.py
View file

@ -1,50 +1,122 @@
import json
from datetime import datetime from datetime import datetime
from typing import Optional
from fastapi import Query from fastapi import Query
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr, Field, root_validator, validator
class PromoCode(BaseModel):
code: str
discount_percent: float = 0.0
active: bool = True
# make the promo code uppercase
@validator("code")
def uppercase_code(cls, v):
return v.upper()
@validator("discount_percent")
def validate_discount_percent(cls, v):
assert 0 <= v <= 100, "Discount must be between 0 and 100."
return v
class EventExtra(BaseModel):
promo_codes: list[PromoCode] = Field(default_factory=list)
conditional: bool = False
min_tickets: int = 1
class CreateEvent(BaseModel): class CreateEvent(BaseModel):
wallet: str wallet: Optional[str] = None
name: str name: str # title (required)
info: str info: str = "" # description (optional, visible by default)
closing_date: str closing_date: Optional[str] = None # defaults to event_end_date or event_start_date
event_start_date: str event_start_date: str # required
event_end_date: str event_end_date: Optional[str] = None # defaults to event_start_date
currency: str = "sat" currency: str = "sat"
amount_tickets: int = Query(..., ge=0) amount_tickets: int = 0 # 0 = unlimited / not ticketed
price_per_ticket: float = Query(..., ge=0) price_per_ticket: float = 0 # 0 = free
banner: str | None = None banner: Optional[str] = None # image URL (optional, visible by default)
location: Optional[str] = None # venue/address (optional, visible by default)
categories: list[str] = Field(default_factory=list) # NIP-52 't' tags
extra: EventExtra = Field(default_factory=EventExtra)
status: str = "approved" # proposed, approved, rejected
class CreateTicket(BaseModel): class CreateTicket(BaseModel):
name: str name: Optional[str] = None
email: EmailStr 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
name: str name: str
info: str info: str = ""
closing_date: str closing_date: str | None = None
canceled: bool = False
event_start_date: str event_start_date: str
event_end_date: str event_end_date: str | None = None
currency: str currency: str = "sat"
amount_tickets: int amount_tickets: int = 0
price_per_ticket: float price_per_ticket: float = 0
time: datetime time: datetime
sold: int = 0 sold: int = 0
banner: str | None = None banner: str | None = None
location: str | None = None
categories: list[str] = Field(default_factory=list)
extra: EventExtra = Field(default_factory=EventExtra)
status: str = "approved" # proposed, approved, rejected
nostr_event_id: str | None = None
nostr_event_created_at: int | None = None
@validator("categories", pre=True)
def parse_categories(cls, v):
if isinstance(v, str):
return json.loads(v) if v else []
return v or []
class EventsSettings(BaseModel):
"""Extension-level settings for the events extension."""
auto_approve: bool = False # Skip approval for all users
class TicketExtra(BaseModel):
applied_promo_code: str | None = None
sats_paid: int | None = None
refund_address: str | None = None
refunded: bool = False
class Ticket(BaseModel): class Ticket(BaseModel):
id: str id: str
wallet: str wallet: str
event: str event: str
name: str name: Optional[str] = None
email: str email: Optional[str] = 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)

0
nostr/__init__.py Normal file
View file

27
nostr/event.py Normal file
View file

@ -0,0 +1,27 @@
import hashlib
import json
from typing import List, Optional
from pydantic import BaseModel
class NostrEvent(BaseModel):
id: str = ""
pubkey: str
created_at: int
kind: int
tags: List[List[str]] = []
content: str = ""
sig: Optional[str] = None
def serialize(self) -> List:
return [0, self.pubkey, self.created_at, self.kind, self.tags, self.content]
def serialize_json(self) -> str:
e = self.serialize()
return json.dumps(e, separators=(",", ":"), ensure_ascii=False)
@property
def event_id(self) -> str:
data = self.serialize_json()
return hashlib.sha256(data.encode()).hexdigest()

144
nostr/nostr_client.py Normal file
View file

@ -0,0 +1,144 @@
"""
Bidirectional Nostr client for the events extension.
Connects to the nostrclient extension's internal WebSocket to publish
and subscribe to NIP-52 calendar events. Based on nostrmarket's
NostrClient pattern.
"""
import asyncio
import json
from asyncio import Queue
from collections import OrderedDict
from typing import Optional
from loguru import logger
from websocket import WebSocketApp
from lnbits.helpers import encrypt_internal_message, urlsafe_short_hash
from lnbits.settings import settings
from .event import NostrEvent
MAX_SEEN_EVENTS = 500
class NostrClient:
def __init__(self):
self.receive_event_queue: Queue = Queue()
self.send_req_queue: Queue = Queue()
self.ws: Optional[WebSocketApp] = None
self.subscription_id = "events-" + urlsafe_short_hash()[:32]
self.running = False
self._seen_events: OrderedDict[str, None] = OrderedDict()
@property
def is_websocket_connected(self):
if not self.ws:
return False
return self.ws.keep_running
async def connect(self) -> WebSocketApp:
relay_endpoint = encrypt_internal_message("relay", urlsafe=True)
ws_url = (
f"ws://localhost:{settings.port}"
f"/nostrclient/api/v1/{relay_endpoint}"
)
logger.info("[EVENTS] Connecting to nostrclient WebSocket...")
def on_open(_):
logger.info("[EVENTS] Connected to nostrclient WebSocket")
def on_message(_, message):
try:
self.receive_event_queue.put_nowait(message)
except Exception as e:
logger.error(f"[EVENTS] Failed to queue message: {e}")
def on_error(_, error):
logger.warning(f"[EVENTS] WebSocket error: {error}")
def on_close(_, status_code, message):
logger.warning(
f"[EVENTS] WebSocket closed: {status_code} {message}"
)
self.receive_event_queue.put_nowait(
ValueError("WebSocket closed")
)
ws = WebSocketApp(
ws_url,
on_message=on_message,
on_open=on_open,
on_close=on_close,
on_error=on_error,
)
from threading import Thread
wst = Thread(target=ws.run_forever)
wst.daemon = True
wst.start()
return ws
async def run_forever(self):
self.running = True
while self.running:
try:
if not self.is_websocket_connected:
self.ws = await self.connect()
await asyncio.sleep(5)
req = await self.send_req_queue.get()
assert self.ws
self.ws.send(json.dumps(req))
except Exception as ex:
logger.warning(f"[EVENTS] NostrClient error: {ex}")
await asyncio.sleep(60)
def is_duplicate_event(self, event_id: str) -> bool:
"""Check if an event has been seen recently."""
if event_id in self._seen_events:
return True
self._seen_events[event_id] = None
if len(self._seen_events) > MAX_SEEN_EVENTS:
self._seen_events.popitem(last=False)
return False
async def get_event(self):
"""Get next event from the receive queue."""
value = await self.receive_event_queue.get()
if isinstance(value, ValueError):
raise value
return value
async def publish_nostr_event(self, e: NostrEvent):
await self.send_req_queue.put(["EVENT", e.dict()])
async def subscribe(self, filters: list[dict]):
"""Subscribe to events matching the given filters."""
self.subscription_id = "events-" + urlsafe_short_hash()[:32]
await self.send_req_queue.put(
["REQ", self.subscription_id] + filters
)
logger.info(
f"[EVENTS] Subscribed to NIP-52 events "
f"(sub: {self.subscription_id[:20]}...)"
)
async def unsubscribe(self):
"""Unsubscribe from current subscription."""
await self.send_req_queue.put(["CLOSE", self.subscription_id])
async def stop(self):
await self.unsubscribe()
self.running = False
await asyncio.sleep(2)
if self.ws:
try:
self.ws.close()
except Exception:
pass
self.ws = None

119
nostr_publisher.py Normal file
View file

@ -0,0 +1,119 @@
"""
NIP-52 calendar event publishing for the events extension.
Builds kind 31922 (date-based) calendar events from the Event model,
signs them with the event creator's Account keypair, and publishes
via the NostrClient to nostrclient relays.
Reference: https://github.com/nostr-protocol/nips/blob/master/52.md
"""
import time
from typing import Optional
import coincurve
from loguru import logger
from .models import Event
from .nostr.event import NostrEvent
def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
"""
Convert an Event model to a NIP-52 kind 31922 (date-based) calendar event.
Tags:
d - event.id (addressable identifier)
title - event.name
start - event.event_start_date (ISO date string)
end - event.event_end_date (optional)
image - event.banner (optional)
Content: event.info (description)
"""
tags = [
["d", event.id],
["title", event.name],
["start", event.event_start_date],
]
if event.event_end_date:
tags.append(["end", event.event_end_date])
if event.banner:
tags.append(["image", event.banner])
if event.location:
tags.append(["location", event.location])
for cat in (event.categories or []):
tags.append(["t", cat])
nostr_event = NostrEvent(
pubkey=pubkey,
created_at=int(time.time()),
kind=31922,
tags=tags,
content=event.info or "",
)
nostr_event.id = nostr_event.event_id
return nostr_event
def build_nip52_delete_event(event: Event, pubkey: str) -> NostrEvent:
"""
Build a kind 5 delete event for a published NIP-52 calendar event.
Uses an 'a' tag to reference the parameterized replaceable event
(kind 31922) per NIP-09.
"""
nostr_event = NostrEvent(
pubkey=pubkey,
created_at=int(time.time()),
kind=5,
tags=[
["a", f"31922:{pubkey}:{event.id}"],
],
content="Event canceled",
)
nostr_event.id = nostr_event.event_id
return nostr_event
def sign_nostr_event(nostr_event: NostrEvent, private_key_hex: str) -> None:
"""Sign a NostrEvent in-place using Schnorr signature."""
privkey = coincurve.PrivateKey(bytes.fromhex(private_key_hex))
sig = privkey.sign_schnorr(bytes.fromhex(nostr_event.id))
nostr_event.sig = sig.hex()
async def publish_event_to_nostr(
nostr_client,
event: Event,
account_pubkey: str,
account_prvkey: str,
delete: bool = False,
) -> Optional[NostrEvent]:
"""
Build, sign, and publish a NIP-52 calendar event (or delete event).
Returns the published NostrEvent for metadata storage, or None on failure.
"""
if not nostr_client:
logger.debug("[EVENTS] No NostrClient available, skipping publish")
return None
try:
if delete:
nostr_event = build_nip52_delete_event(event, account_pubkey)
else:
nostr_event = build_nip52_event(event, account_pubkey)
sign_nostr_event(nostr_event, account_prvkey)
await nostr_client.publish_nostr_event(nostr_event)
logger.info(
f"[EVENTS] Published NIP-52 {'delete' if delete else 'calendar'} "
f"event: {nostr_event.id[:16]}... (kind {nostr_event.kind})"
)
return nostr_event
except Exception as e:
logger.warning(f"[EVENTS] Failed to publish to Nostr: {e}")
return None

170
nostr_sync.py Normal file
View file

@ -0,0 +1,170 @@
"""
Bidirectional Nostr sync for the events extension.
Subscribes to NIP-52 calendar events (kind 31922/31923) from relays
and upserts them into the local database. Enables federated event
discovery events published by other LNbits instances or Nostr
clients appear in the local events listing.
"""
import json
from datetime import datetime, timezone
from loguru import logger
from .crud import create_event, db, get_event, update_event
from .models import CreateEvent, Event
from .nostr.nostr_client import NostrClient
async def process_nostr_message(nostr_client: NostrClient, message: str):
"""Process an incoming Nostr relay message."""
try:
data = json.loads(message)
except json.JSONDecodeError:
return
if not isinstance(data, list) or len(data) < 2:
return
msg_type = data[0]
if msg_type == "EVENT" and len(data) >= 3:
event_data = data[2]
await _handle_calendar_event(nostr_client, event_data)
elif msg_type == "EOSE":
logger.debug("[EVENTS] End of stored events from relay")
elif msg_type == "NOTICE":
logger.info(f"[EVENTS] Relay notice: {data[1]}")
async def _handle_calendar_event(nostr_client: NostrClient, event_data: dict):
"""Handle an incoming NIP-52 calendar event (kind 31922 or 31923)."""
kind = event_data.get("kind")
if kind not in (31922, 31923):
return
event_id = event_data.get("id", "")
if nostr_client.is_duplicate_event(event_id):
return
tags = {t[0]: t[1] for t in event_data.get("tags", []) if len(t) >= 2}
tag_lists = {}
for t in event_data.get("tags", []):
if len(t) >= 2:
tag_lists.setdefault(t[0], []).append(t[1])
d_tag = tags.get("d")
if not d_tag:
return
title = tags.get("title", "Untitled Event")
start = tags.get("start")
if not start:
return
end = tags.get("end")
description = event_data.get("content", "")
image = tags.get("image")
location = tags.get("location")
categories = tag_lists.get("t", [])
# Check if we already have this event (by d-tag as our event ID
# or by nostr_event_id)
existing = await get_event(d_tag)
if not existing:
# Check by nostr_event_id
existing = await db.fetchone(
"SELECT * FROM events.events WHERE nostr_event_id = :nid",
{"nid": event_id},
Event,
)
if existing:
# Update if the incoming event is newer
incoming_created_at = event_data.get("created_at", 0)
if (
existing.nostr_event_created_at
and incoming_created_at <= existing.nostr_event_created_at
):
return # We already have a newer version
existing.name = title
existing.info = description
existing.event_start_date = start
existing.event_end_date = end
existing.banner = image
existing.location = location
existing.categories = categories
existing.nostr_event_id = event_id
existing.nostr_event_created_at = incoming_created_at
await update_event(existing)
logger.info(f"[EVENTS] Updated event from Nostr: {title}")
else:
# Create new event from Nostr
# Events discovered from Nostr are auto-approved (they're already public)
event = CreateEvent(
wallet="", # No wallet — discovered from Nostr, not ticketed locally
name=title,
info=description,
event_start_date=start,
event_end_date=end,
banner=image,
location=location,
categories=categories,
status="approved",
)
# Use the d-tag as the event ID for correlation
from lnbits.db import Database
new_event = Event(
id=d_tag,
wallet="",
name=title,
info=description,
event_start_date=start,
event_end_date=end,
banner=image,
location=location,
categories=categories,
status="approved",
time=datetime.now(timezone.utc),
nostr_event_id=event_id,
nostr_event_created_at=event_data.get("created_at", 0),
)
try:
await db.insert("events.events", new_event)
logger.info(f"[EVENTS] Discovered event from Nostr: {title}")
except Exception as e:
# Likely duplicate key — skip
logger.debug(f"[EVENTS] Skipped duplicate event: {e}")
async def wait_for_nostr_events(nostr_client: NostrClient):
"""
Background task: subscribe to NIP-52 events and process them.
"""
logger.info("[EVENTS] Starting Nostr event sync...")
while True:
try:
# Subscribe to NIP-52 calendar events
await nostr_client.subscribe([
{"kinds": [31922, 31923]},
])
# Process incoming events
while True:
message = await nostr_client.get_event()
await process_nostr_message(nostr_client, message)
except ValueError:
# WebSocket closed — will reconnect
logger.warning("[EVENTS] Nostr connection lost, resubscribing...")
await asyncio.sleep(10)
except Exception as e:
logger.error(f"[EVENTS] Nostr sync error: {e}")
await asyncio.sleep(30)
import asyncio # noqa: E402

View file

@ -1,4 +1,13 @@
from .crud import get_event, update_event, update_ticket from lnurl import execute
from loguru import logger
from .crud import (
get_event,
get_event_tickets,
purge_unpaid_tickets,
update_event,
update_ticket,
)
from .models import Ticket from .models import Ticket
@ -16,3 +25,30 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket:
await update_event(event) await update_event(event)
return ticket return ticket
async def refund_tickets(event_id: str):
"""
Refund tickets for an event that has not met the minimum ticket requirement.
This function should be called when the event is closed and the minimum ticket
condition is not met.
"""
await purge_unpaid_tickets(event_id)
tickets = await get_event_tickets(event_id)
if not tickets:
return
for ticket in tickets:
if ticket.extra.refunded:
continue
if ticket.paid and ticket.extra.refund_address and ticket.extra.sats_paid:
try:
res = await execute(
ticket.extra.refund_address, str(ticket.extra.sats_paid)
)
if res:
ticket.extra.refunded = True
await update_ticket(ticket)
except Exception as e:
logger.error(f"Error refunding ticket {ticket.id}: {e}")

View file

@ -9,7 +9,8 @@ window.app = Vue.createApp({
show: false, show: false,
data: { data: {
name: '', name: '',
email: '' email: '',
refund: ''
} }
}, },
ticketLink: { ticketLink: {
@ -29,7 +30,8 @@ window.app = Vue.createApp({
this.info = event_info this.info = event_info
this.info = this.info.substring(1, this.info.length - 1) this.info = this.info.substring(1, this.info.length - 1)
this.banner = event_banner this.banner = event_banner
await this.purgeUnpaidTickets() this.extra = event_extra
this.hasPromoCodes = has_promoCodes
}, },
computed: { computed: {
formatDescription() { formatDescription() {
@ -41,6 +43,7 @@ window.app = Vue.createApp({
e.preventDefault() e.preventDefault()
this.formDialog.data.name = '' this.formDialog.data.name = ''
this.formDialog.data.email = '' this.formDialog.data.email = ''
this.formDialog.data.refund = ''
}, },
closeReceiveDialog() { closeReceiveDialog() {
@ -60,12 +63,12 @@ window.app = Vue.createApp({
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.'
}, },
Invoice() { Invoice() {
axios axios
.post(`/events/api/v1/tickets/${event_id}`, { .post(`/events/api/v1/tickets/${event_id}`, {
name: this.formDialog.data.name, name: this.formDialog.data.name,
email: this.formDialog.data.email email: this.formDialog.data.email,
promo_code: this.formDialog.data.promo_code || null
}) })
.then(response => { .then(response => {
this.paymentReq = response.data.payment_request this.paymentReq = response.data.payment_request
@ -122,13 +125,6 @@ window.app = Vue.createApp({
}, 2000) }, 2000)
}) })
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
},
async purgeUnpaidTickets() {
try {
await LNbits.api.request('GET', `/events/api/v1/purge/${event_id}`)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
} }
} }
}) })

View file

@ -1,9 +1,6 @@
const mapEvents = function (obj) { const mapEvents = function (obj) {
obj.date = Quasar.date.formatDate( obj.date = LNbits.utils.formatTimestamp(obj.time)
new Date(obj.time * 1000), obj.fsat = new Intl.NumberFormat(window.g.locale).format(obj.price_per_ticket)
'YYYY-MM-DD HH:mm'
)
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.price_per_ticket)
obj.displayUrl = ['/events/', obj.id].join('') obj.displayUrl = ['/events/', obj.id].join('')
return obj return obj
} }
@ -14,14 +11,18 @@ window.app = Vue.createApp({
data() { data() {
return { return {
events: [], events: [],
allUserEvents: [],
pendingEvents: [],
isAdmin: false,
settings: {
auto_approve: false
},
tickets: [], tickets: [],
currencies: [], currencies: [],
eventsTable: { eventsTable: {
columns: [ columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'}, {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: 'info', align: 'left', label: 'Info', field: 'info'},
{name: 'banner', align: 'left', label: 'Banner', field: 'banner'},
{ {
name: 'event_start_date', name: 'event_start_date',
align: 'left', align: 'left',
@ -40,6 +41,17 @@ window.app = Vue.createApp({
label: 'Ticket close', label: 'Ticket close',
field: 'closing_date' field: 'closing_date'
}, },
{
name: 'canceled',
align: 'left',
label: 'Canceled',
field: row => {
if (row.extra.conditional && row.canceled) {
return 'Yes'
}
return 'No'
}
},
{ {
name: 'price_per_ticket', name: 'price_per_ticket',
align: 'left', align: 'left',
@ -65,7 +77,10 @@ window.app = Vue.createApp({
align: 'left', align: 'left',
label: 'Sold', label: 'Sold',
field: 'sold' field: 'sold'
} },
{name: 'info', align: 'left', label: 'Info', field: 'info'},
{name: 'banner', align: 'left', label: 'Banner', field: 'banner'},
{name: 'status', align: 'left', label: 'Status', field: 'status'}
], ],
pagination: { pagination: {
rowsPerPage: 10 rowsPerPage: 10
@ -73,7 +88,6 @@ window.app = Vue.createApp({
}, },
ticketsTable: { ticketsTable: {
columns: [ columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'event', align: 'left', label: 'Event', field: 'event'}, {name: 'event', align: 'left', label: 'Event', field: 'event'},
{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: 'email', align: 'left', label: 'Email', field: 'email'},
@ -82,7 +96,14 @@ window.app = Vue.createApp({
align: 'left', align: 'left',
label: 'Registered', label: 'Registered',
field: 'registered' field: 'registered'
} },
{
name: 'promo_code',
align: 'left',
label: 'Promo Code',
field: row => row.extra.applied_promo_code || ''
},
{name: 'id', align: 'left', label: 'ID', field: 'id'}
], ],
pagination: { pagination: {
rowsPerPage: 10 rowsPerPage: 10
@ -90,11 +111,82 @@ window.app = Vue.createApp({
}, },
formDialog: { formDialog: {
show: false, show: false,
data: {} data: {
extra: {
promo_codes: []
}
}
} }
} }
}, },
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(
@ -136,6 +228,7 @@ window.app = Vue.createApp({
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',
@ -143,9 +236,45 @@ window.app = Vue.createApp({
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
.then(response => { .then(response => {
this.events = response.data.map(function (obj) { this.events = response.data.map(obj => {
return mapEvents(obj) return mapEvents(obj)
}) })
this.checkCanceledEvents()
})
// Admin: also fetch all users' events
LNbits.api
.request(
'GET',
'/events/api/v1/events/all'
)
.then(response => {
this.isAdmin = true
// Exclude own events (already in this.events)
const ownWalletIds = this.g.user.wallets.map(w => w.id)
this.allUserEvents = response.data
.filter(obj => !ownWalletIds.includes(obj.wallet))
.map(obj => mapEvents(obj))
})
.catch(() => {
this.isAdmin = false
this.allUserEvents = []
})
},
getPendingEvents() {
LNbits.api
.request(
'GET',
'/events/api/v1/events/pending'
)
.then(response => {
this.pendingEvents = response.data.map(obj => {
return mapEvents(obj)
})
})
.catch(() => {
// Not an admin or no pending events
this.pendingEvents = []
}) })
}, },
sendEventData() { sendEventData() {
@ -153,6 +282,11 @@ window.app = Vue.createApp({
id: this.formDialog.data.wallet id: this.formDialog.data.wallet
}) })
const data = this.formDialog.data const data = this.formDialog.data
if (data.extra && !data.extra.promo_codes) {
data.extra.promo_codes = data.extra.promo_codes
.filter(code => code.trim() !== '')
.map(code => code.trim().toUpperCase())
}
if (data.id) { if (data.id) {
this.updateEvent(wallet, data) this.updateEvent(wallet, data)
@ -161,20 +295,41 @@ window.app = Vue.createApp({
} }
}, },
openEventDialog(data = false) {
if (data && data.id) {
this.formDialog.data = {...data}
} else {
this.formDialog.data = {
extra: {
conditional: false,
min_tickets: 1,
promo_codes: []
}
}
}
this.formDialog.show = true
},
resetEventDialog() {
this.formDialog.show = false
this.formDialog.data = {
extra: {
promo_codes: []
}
}
},
createEvent(wallet, data) { createEvent(wallet, data) {
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(mapEvents(response.data)) this.events.push(mapEvents(response.data))
this.formDialog.show = false this.resetEventDialog()
this.formDialog.data = {}
}) })
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
}, },
updateformDialog(formId) { updateformDialog(formId) {
const link = _.findWhere(this.events, {id: formId}) const link = _.findWhere(this.events, {id: formId})
this.formDialog.data = {...link} this.openEventDialog(link)
this.formDialog.show = true
}, },
updateEvent(wallet, data) { updateEvent(wallet, data) {
LNbits.api LNbits.api
@ -189,8 +344,7 @@ window.app = Vue.createApp({
return obj.id == data.id return obj.id == data.id
}) })
this.events.push(mapEvents(response.data)) this.events.push(mapEvents(response.data))
this.formDialog.show = false this.resetEventDialog()
this.formDialog.data = {}
}) })
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
}, },
@ -216,12 +370,38 @@ window.app = Vue.createApp({
}, },
exporteventsCSV() { exporteventsCSV() {
LNbits.utils.exportCSV(this.eventsTable.columns, this.events) LNbits.utils.exportCSV(this.eventsTable.columns, this.events)
},
async checkCanceledEvents() {
const events = this.events
.filter(event => event.extra.conditional)
.filter(e => !e.canceled)
if (!events.length) return
const now = new Date()
events.forEach(async ev => {
if (new Date(ev.closing_date) < now && ev.sold < ev.extra.min_tickets) {
const {data} = await LNbits.api.request(
'PUT',
'/events/api/v1/events/' + ev.id + '/cancel',
_.findWhere(this.g.user.wallets, {id: ev.wallet}).adminkey
)
Quasar.Notify.create({
type: 'warning',
message: `Event ${ev.name} has been canceled and refunds have been issued.`,
icon: null
})
this.events = this.events.map(e =>
e.id === ev.id ? mapEvents(data) : e
)
}
})
} }
}, },
async created() { async created() {
if (this.g.user.wallets.length) { if (this.g.user.wallets.length) {
this.getTickets() this.getTickets()
this.getEvents() this.getEvents()
this.getPendingEvents()
this.getSettings()
this.currencies = await LNbits.api.getCurrencies() this.currencies = await LNbits.api.getCurrencies()
} }
} }

View file

@ -21,8 +21,12 @@ 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
if not payment.extra.get("name") or not payment.extra.get("email"): # Check if ticket has either name/email or user_id
logger.warning(f"Ticket {payment.payment_hash} missing name or email.") 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 return
ticket = await get_ticket(payment.payment_hash) ticket = await get_ticket(payment.payment_hash)

View file

@ -6,7 +6,7 @@
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h3 class="q-my-none q-pa-lg">{{ event_name }}</h3> <h3 class="q-my-none q-pa-lg">{{ event_name }}</h3>
<br /> <br />
<div v-html="formatDescription"></div> <div v-html="formatDescription" class="q-pa-md"></div>
<br /> <br />
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -30,7 +30,23 @@
:rules="[val => !!val || '* Required', val => emailValidation(val)]" :rules="[val => !!val || '* Required', val => emailValidation(val)]"
lazy-rules lazy-rules
></q-input> ></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"> <div class="row q-mt-lg">
<q-btn <q-btn
unelevated unelevated
@ -93,6 +109,8 @@
const event_name = '{{ event_name }}' const event_name = '{{ event_name }}'
const event_info = '{{ event_info | tojson }}' const event_info = '{{ event_info | tojson }}'
const event_banner = JSON.parse('{{ event_banner | tojson | safe }}') 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>
<script src="{{ static_url_for('events/static', path='js/display.js') }}"></script> <script src="{{ static_url_for('events/static', path='js/display.js') }}"></script>
{% endblock %} {% endblock %}

View file

@ -2,14 +2,84 @@
%} {% block page %} %} {% block page %}
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-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>
<q-card-section> <q-card-section>
<q-btn unelevated color="primary" @click="formDialog.show = true" <q-btn unelevated color="primary" @click="openEventDialog"
>New Event</q-btn >New Event</q-btn
> >
</q-card-section> </q-card-section>
</q-card> </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>
<q-card-section> <q-card-section>
<div class="row items-center no-wrap q-mb-md"> <div class="row items-center no-wrap q-mb-md">
@ -33,7 +103,7 @@
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr :props="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"> <q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.label"></span> <span v-text="col.label"></span>
</q-th> </q-th>
@ -43,6 +113,16 @@
</template> </template>
<template v-slot:body="props"> <template v-slot:body="props">
<q-tr :props="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-td auto-width>
<q-btn <q-btn
unelevated unelevated
@ -66,9 +146,36 @@
></q-btn> ></q-btn>
</q-td> </q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props"> <q-td v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.value"></span> <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>
<q-td auto-width> <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 <q-btn
flat flat
dense dense
@ -89,6 +196,94 @@
></q-btn> ></q-btn>
</q-td> </q-td>
</q-tr> </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> </template>
</q-table> </q-table>
</q-card-section> </q-card-section>
@ -224,7 +419,6 @@
></q-input> ></q-input>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-4">Event begins</div> <div class="col-4">Event begins</div>
<div class="col-8"> <div class="col-8">
@ -248,7 +442,6 @@
></q-input> ></q-input>
</div> </div>
</div> </div>
<div class="row q-col-gutter-sm"> <div class="row q-col-gutter-sm">
<div class="col"> <div class="col">
<q-select <q-select
@ -280,9 +473,101 @@
:mask="formDialog.data.currency != 'sats' ? '#.##' : '#'" :mask="formDialog.data.currency != 'sats' ? '#.##' : '#'"
fill-mask="0" fill-mask="0"
reverse-fill-mask reverse-fill-mask
:disable="formDialog.data.currency == null"
></q-input> ></q-input>
</div> </div>
</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"> <div class="row q-mt-lg">
<q-btn <q-btn

108
tests/test_api.py Normal file
View file

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

View file

@ -8,7 +8,8 @@ from lnbits.helpers import template_renderer
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse from starlette.responses import HTMLResponse
from .crud import get_event, get_ticket 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()
@ -32,6 +33,15 @@ async def display(request: Request, event_id):
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." 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: if event.amount_tickets < 1:
return events_renderer().TemplateResponse( return events_renderer().TemplateResponse(
"events/error.html", "events/error.html",
@ -41,8 +51,20 @@ async def display(request: Request, event_id):
"event_error": "Sorry, tickets are sold out :(", "event_error": "Sorry, tickets are sold out :(",
}, },
) )
datetime_object = datetime.strptime(event.closing_date, "%Y-%m-%d").date() if event.extra.conditional and not is_min_tickets_met and not is_window_open:
if date.today() > datetime_object: 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( return events_renderer().TemplateResponse(
"events/error.html", "events/error.html",
{ {
@ -52,6 +74,12 @@ async def display(request: Request, event_id):
}, },
) )
if len(event.extra.promo_codes) > 0:
has_promo_codes = True
else:
has_promo_codes = False
event.extra.promo_codes = []
return events_renderer().TemplateResponse( return events_renderer().TemplateResponse(
"events/display.html", "events/display.html",
{ {
@ -61,6 +89,8 @@ async def display(request: Request, event_id):
"event_info": event.info, "event_info": event.info,
"event_price": event.price_per_ticket, "event_price": event.price_per_ticket,
"event_banner": event.banner, "event_banner": event.banner,
"event_extra": event.extra.json(),
"has_promo_codes": has_promo_codes,
}, },
) )

View file

@ -3,9 +3,10 @@ from http import HTTPStatus
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from lnbits.core.crud import get_standalone_payment, get_user from lnbits.core.crud import get_standalone_payment, get_user
from lnbits.core.models import 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 (
check_admin,
require_admin_key, require_admin_key,
require_invoice_key, require_invoice_key,
) )
@ -24,18 +25,50 @@ from .crud import (
get_event, get_event,
get_event_tickets, get_event_tickets,
get_events, get_events,
get_pending_events,
get_public_events,
get_settings,
get_ticket, get_ticket,
get_tickets, get_tickets,
purge_unpaid_tickets, get_tickets_by_user_id,
# TODO: consider exposing purge_unpaid_tickets via an admin endpoint
update_event, update_event,
update_settings,
update_ticket, update_ticket,
) )
from .models import CreateEvent, CreateTicket, Ticket from .models import CreateEvent, CreateTicket, EventsSettings, Ticket
from .services import set_ticket_paid from .nostr_publisher import publish_event_to_nostr
from .services import refund_tickets, set_ticket_paid
events_api_router = APIRouter() events_api_router = APIRouter()
async def _publish_or_delete_nostr_event(event, delete=False):
"""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("/api/v1/events") @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),
@ -50,14 +83,67 @@ async def api_events(
return [event.dict() for event in await get_events(wallet_ids)] return [event.dict() for event in await get_events(wallet_ids)]
@events_api_router.get("/api/v1/events/public")
async def api_events_public():
"""
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("/api/v1/events/all")
async def api_events_all(
admin: Account = Depends(check_admin),
):
"""Get all events across all wallets. LNbits admin only."""
from .crud import get_all_events
events = await get_all_events()
return [event.dict() for event in events]
@events_api_router.post("/api/v1/events") @events_api_router.post("/api/v1/events")
@events_api_router.put("/api/v1/events/{event_id}")
async def api_event_create( async def api_event_create(
data: CreateEvent, data: CreateEvent,
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
event_id: str | None = None,
): ):
if event_id: """
Create a new event. Any authenticated user can create events.
Admin-created events are auto-approved. Non-admin events require
approval unless auto_approve is enabled in extension settings.
"""
if not data.wallet:
data.wallet = wallet.wallet.id
from lnbits.settings import settings
ext_settings = await get_settings()
user_id = wallet.wallet.user
is_admin = (
user_id == settings.super_user
or user_id in settings.lnbits_admin_users
)
if not is_admin and not ext_settings.auto_approve:
data.status = "proposed"
event = await create_event(data)
# Publish to Nostr if approved
if event.status == "approved":
await _publish_or_delete_nostr_event(event)
return event.dict()
@events_api_router.put("/api/v1/events/{event_id}")
async def api_event_update(
event_id: str,
data: CreateEvent,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
"""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(
@ -71,8 +157,34 @@ async def api_event_create(
for k, v in data.dict().items(): for k, v in data.dict().items():
setattr(event, k, v) setattr(event, k, v)
event = await update_event(event) event = await update_event(event)
else:
event = await create_event(data) # Republish to Nostr if event is approved (kind 31922 is replaceable)
if event.status == "approved" and event.nostr_event_id:
await _publish_or_delete_nostr_event(event)
return event.dict()
@events_api_router.put("/api/v1/events/{event_id}/cancel")
async def api_event_cancel(
event_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
event = await get_event(event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
if event.wallet != wallet.wallet.id:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.")
event.canceled = True
event = await update_event(event)
await refund_tickets(event.id)
# Delete NIP-52 event from Nostr if it was published
if event.nostr_event_id:
await _publish_or_delete_nostr_event(event, delete=True)
return event.dict() return event.dict()
@ -90,11 +202,93 @@ async def api_form_delete(
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:
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 return "", HTTPStatus.NO_CONTENT
#########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(
event_id: str,
admin: Account = Depends(check_admin),
):
"""Approve a proposed event. LNbits admin only."""
event = await get_event(event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
if event.status != "proposed":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Event is already {event.status}.",
)
event.status = "approved"
event = await update_event(event)
# Publish NIP-52 calendar event to Nostr
await _publish_or_delete_nostr_event(event)
return event.dict()
@events_api_router.put("/api/v1/events/{event_id}/reject")
async def api_event_reject(
event_id: str,
admin: Account = Depends(check_admin),
):
"""Reject a proposed event. LNbits admin only."""
event = await get_event(event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
if event.status != "proposed":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Event is already {event.status}.",
)
event.status = "rejected"
event = await update_event(event)
return event.dict()
#########Settings##########
@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########## #########Tickets##########
@ -112,15 +306,25 @@ async def api_tickets(
return await get_tickets(wallet_ids) return await get_tickets(wallet_ids)
@events_api_router.get("/api/v1/tickets/user/{user_id}")
async def api_tickets_by_user_id(user_id: str) -> list[Ticket]:
"""Get all tickets for a specific user by their user_id"""
return await get_tickets_by_user_id(user_id)
@events_api_router.post("/api/v1/tickets/{event_id}") @events_api_router.post("/api/v1/tickets/{event_id}")
async def api_ticket_create(event_id: str, data: CreateTicket): async def api_ticket_create(event_id: str, data: CreateTicket):
name = data.name if data.user_id:
email = data.email return await api_ticket_make_ticket_with_user_id(event_id, data.user_id)
return await api_ticket_make_ticket(event_id, name, email) else:
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
)
@events_api_router.get("/api/v1/tickets/{event_id}/{name}/{email}") async def api_ticket_make_ticket_with_user_id(event_id: str, user_id: str):
async def api_ticket_make_ticket(event_id, name, email):
event = await get_event(event_id) event = await get_event(event_id)
if not event: if not event:
raise HTTPException( raise HTTPException(
@ -128,7 +332,7 @@ async def api_ticket_make_ticket(event_id, name, email):
) )
price = event.price_per_ticket price = event.price_per_ticket
extra = {"tag": "events", "name": name, "email": email} extra = {"tag": "events", "user_id": user_id}
if event.currency != "sats": if event.currency != "sats":
price = await fiat_amount_as_satoshis(event.price_per_ticket, event.currency) price = await fiat_amount_as_satoshis(event.price_per_ticket, event.currency)
@ -138,6 +342,63 @@ async def api_ticket_make_ticket(event_id, name, email):
extra["fiatAmount"] = event.price_per_ticket extra["fiatAmount"] = event.price_per_ticket
extra["rate"] = await get_fiat_rate_satoshis(event.currency) 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:
# check if promo_code exists in event.extra.promo_codes
if promo_code not in [pc.code for pc in event.extra.promo_codes]:
raise HTTPException(
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)
extra["promo_code"] = promo.code
price = event.price_per_ticket * (1 - promo.discount_percent / 100)
if event.currency != "sats":
extra["fiat"] = True
extra["currency"] = event.currency
extra["fiatAmount"] = price
extra["rate"] = await get_fiat_rate_satoshis(event.currency)
price = await fiat_amount_as_satoshis(price, event.currency)
try: try:
payment = await create_invoice( payment = await create_invoice(
wallet_id=event.wallet, wallet_id=event.wallet,
@ -151,6 +412,11 @@ async def api_ticket_make_ticket(event_id, name, email):
event=event.id, event=event.id,
name=name, name=name,
email=email, email=email,
extra={
"applied_promo_code": promo_code,
"refund_address": refund_address,
"sats_paid": int(price),
},
) )
except Exception as exc: except Exception as exc:
raise HTTPException( raise HTTPException(
@ -176,16 +442,31 @@ async def api_ticket_send_ticket(event_id, payment_hash):
) )
payment = await get_standalone_payment(payment_hash, incoming=True) payment = await get_standalone_payment(payment_hash, incoming=True)
assert payment 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 = ( price = (
event.price_per_ticket * 1000 event.price_per_ticket * 1000
if event.currency == "sats" if event.currency == "sats"
else await fiat_amount_as_satoshis(event.price_per_ticket, event.currency) else await fiat_amount_as_satoshis(event.price_per_ticket, event.currency)
* 1000 * 1000
) )
# check if price is equal to payment.amount # check if price is equal to payment.amount
lower_bound = price * 0.99 # 1% decrease lower_bound = price * 0.99 # 1% decrease
if not payment.pending and abs(payment.amount) >= lower_bound: # allow 1% error 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) await set_ticket_paid(ticket)
return {"paid": True, "ticket_id": ticket.id} return {"paid": True, "ticket_id": ticket.id}
@ -208,17 +489,6 @@ async def api_ticket_delete(
await delete_ticket(ticket_id) await delete_ticket(ticket_id)
# TODO: DELETE, updates db! @tal
@events_api_router.get("/api/v1/purge/{event_id}")
async def api_event_purge_tickets(event_id: str):
event = await get_event(event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
return await purge_unpaid_tickets(event_id)
@events_api_router.get("/api/v1/eventtickets/{event_id}") @events_api_router.get("/api/v1/eventtickets/{event_id}")
async def api_event_tickets(event_id: str) -> list[Ticket]: async def api_event_tickets(event_id: str) -> list[Ticket]:
return await get_event_tickets(event_id) return await get_event_tickets(event_id)