Compare commits
38 commits
main
...
feat/event
| Author | SHA1 | Date | |
|---|---|---|---|
| ef5d2dcfcf | |||
| e937883564 | |||
| 1bddb99132 | |||
| 4d91426e82 | |||
| b4d7653988 | |||
| 29045163a3 | |||
| d69ec7dda2 | |||
| 2db0102857 | |||
| e8fcecac40 | |||
| 5013709be7 | |||
| f76e21e960 | |||
| f965cf07c9 | |||
| 1ad99aa3d6 | |||
| 920125aaee | |||
| ba97205592 | |||
| c1e66fbf7f | |||
| 7843da21d8 | |||
| b467826622 | |||
| d740cb1f97 | |||
| 3425097a5c | |||
| cdfcee39ae | |||
| bdd49f8612 | |||
| 702ab70559 | |||
| 32ea79a137 | |||
| 41e64adfde | |||
| eb474b1390 | |||
| a41348df94 | |||
| 0c782e6239 | |||
| 1dcff37df5 | |||
| 68e6e3d02e | |||
| a77145e08e | |||
| c49abdb53f | |||
| 4fb6d90fcd | |||
|
|
a9ac6dcfc1 | ||
|
|
44f2cb5a62 | ||
| 33977c53d6 | |||
| 7cc622fc44 | |||
| c669da5822 |
64 changed files with 3082 additions and 9919 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -2,6 +2,3 @@ __pycache__
|
||||||
node_modules
|
node_modules
|
||||||
.mypy_cache
|
.mypy_cache
|
||||||
.venv
|
.venv
|
||||||
|
|
||||||
# lnbits data dir created by `make test` (settings default lnbits_data_folder)
|
|
||||||
data/
|
|
||||||
|
|
|
||||||
56
API_DOCUMENTATION.md
Normal file
56
API_DOCUMENTATION.md
Normal 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 |
|
||||||
7
Makefile
7
Makefile
|
|
@ -30,15 +30,10 @@ checkblack:
|
||||||
checkeditorconfig:
|
checkeditorconfig:
|
||||||
editorconfig-checker
|
editorconfig-checker
|
||||||
|
|
||||||
# The uv env resolves *upstream* lnbits from PyPI, which lacks the aio fork's
|
|
||||||
# modules (lnbits.core.signers, …). Point PYTHONPATH at a fork checkout so
|
|
||||||
# `import lnbits` picks it up; override with LNBITS_SRC=/path/to/lnbits.
|
|
||||||
LNBITS_SRC ?= $(HOME)/dev/lnbits/dev
|
|
||||||
test:
|
test:
|
||||||
PYTHONPATH=$(LNBITS_SRC) \
|
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
DEBUG=true \
|
DEBUG=true \
|
||||||
uv run --frozen pytest
|
uv run pytest
|
||||||
install-pre-commit-hook:
|
install-pre-commit-hook:
|
||||||
@echo "Installing pre-commit hook to git"
|
@echo "Installing pre-commit hook to git"
|
||||||
@echo "Uninstall the hook with uv run pre-commit uninstall"
|
@echo "Uninstall the hook with uv run pre-commit uninstall"
|
||||||
|
|
|
||||||
56
README.md
56
README.md
|
|
@ -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)
|
|
||||||
[](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.
|
||||||
|
|
||||||
|
|
@ -23,6 +13,7 @@ Events includes a shareable ticket scanner, which can be used to register attend
|
||||||
1. Create an event\
|
1. Create an event\
|
||||||

|

|
||||||
2. Fill out the event information:
|
2. Fill out the event information:
|
||||||
|
|
||||||
- event name
|
- event name
|
||||||
- wallet (normally there's only one)
|
- wallet (normally there's only one)
|
||||||
- event information
|
- event information
|
||||||
|
|
@ -33,6 +24,7 @@ Events includes a shareable ticket scanner, which can be used to register attend
|
||||||
|
|
||||||
3. Share the event registration link\
|
3. Share the event registration link\
|
||||||

|

|
||||||
|
|
||||||
- ticket example\
|
- ticket example\
|
||||||

|

|
||||||
|
|
||||||
|
|
@ -41,45 +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\
|
||||||

|

|
||||||
|
|
||||||
## Guest checkout, card payments and email delivery (aio fork)
|
|
||||||
|
|
||||||
- **Identity.** `POST /events/api/v1/tickets/{event_id}` accepts either an
|
|
||||||
LNbits `user_id` or a guest `name` + `email`; a `user_id` ticket may also
|
|
||||||
carry an `email` so logged-in buyers get their ticket mailed.
|
|
||||||
- **Payment methods.** `extra.payment_methods` (`lightning`, `fiat`) lists the
|
|
||||||
rails an event accepts; an empty list keeps the legacy rule (Lightning always,
|
|
||||||
fiat when `allow_fiat`). The effective list is published on the NIP-52 event
|
|
||||||
as `tickets_payment_methods` and enforced at purchase.
|
|
||||||
- **Return to the calling app.** A client may send `frontend_url` (its app
|
|
||||||
root, e.g. `https://app.example/events`). Its origin must be one of
|
|
||||||
`LNBITS_CORS_ALLOWED_ORIGINS`, the LNbits base URL or
|
|
||||||
`LNBITS_CUSTOM_FRONTEND_URL`, otherwise the request is refused. Under that
|
|
||||||
root the extension builds the Stripe `success_url`
|
|
||||||
(`/events/{event_id}?checkout=success&tickets=<id,id>`), `cancel_url`
|
|
||||||
(`/events/{event_id}?checkout=cancelled`) and the emailed ticket link
|
|
||||||
(`/events/ticket/{ticket_id}`). Absent, the LNbits host is used as before.
|
|
||||||
- **Stripe session.** The buyer's email is passed as `customer_email`
|
|
||||||
(prefilled and locked on the hosted page); the line item is named after the
|
|
||||||
event; `event_id`, `quantity` and `ticket_ids` ride along as metadata.
|
|
||||||
- **Promo codes.** `extra.promo_codes` (`code`, `discount_percent`, `active`,
|
|
||||||
`max_uses`; `used_count` is derived from paid tickets) are organizer-only: they are
|
|
||||||
never part of public responses. Buyers preview a code with
|
|
||||||
`POST /events/api/v1/promo/validate/{event_id}` (`{codes, quantity}` → v2-shaped
|
|
||||||
`BasketTotals` + `currency`); purchase enforces `active` and `max_uses` (each ticket
|
|
||||||
of a multi-ticket purchase consumes one use) and rejects bad codes with a distinct
|
|
||||||
`detail`. Updates that omit `extra.promo_codes` keep the stored list.
|
|
||||||
- **Email.** Multipart text + HTML (links, no images) with the **ticket card**
|
|
||||||
attached — a self-describing PNG (site, event, when, where, QR with the
|
|
||||||
instance logo, name on ticket, ticket id) also served at
|
|
||||||
`GET /events/api/v1/ticket-card/{ticket_id}`; the bare QR stays at
|
|
||||||
`GET /events/api/v1/qr/{ticket_id}`. Headers carry Date, Message-ID and a
|
|
||||||
From display name (site title). `POST /events/api/v1/tickets/{ticket_id}/resend-email`
|
|
||||||
returns a `TicketResendResult` with per-channel outcome.
|
|
||||||
|
|
||||||
## Powered by LNbits
|
|
||||||
|
|
||||||
[LNbits](https://lnbits.com) is a free and open-source lightning accounts system.
|
|
||||||
|
|
||||||
[](https://shop.lnbits.com/)
|
|
||||||
[](https://my.lnbits.com/login)
|
|
||||||
|
|
|
||||||
117
__init__.py
117
__init__.py
|
|
@ -6,19 +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 (
|
from .views_api import events_api_router
|
||||||
events_api_router,
|
|
||||||
promo_api_router,
|
|
||||||
qr_api_router,
|
|
||||||
tickets_api_router,
|
|
||||||
)
|
|
||||||
|
|
||||||
events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"])
|
events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"])
|
||||||
events_ext.include_router(events_generic_router)
|
events_ext.include_router(events_generic_router)
|
||||||
events_ext.include_router(events_api_router)
|
events_ext.include_router(events_api_router)
|
||||||
events_ext.include_router(tickets_api_router)
|
|
||||||
events_ext.include_router(qr_api_router)
|
|
||||||
events_ext.include_router(promo_api_router)
|
|
||||||
|
|
||||||
events_static_files = [
|
events_static_files = [
|
||||||
{
|
{
|
||||||
|
|
@ -29,20 +21,9 @@ 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
|
||||||
|
|
||||||
# Reconciliation sweep for NIP-52 publishes that never reached a relay
|
|
||||||
# (aiolabs/events#35). Five minutes is well under the window in which a
|
|
||||||
# stale ticket count matters to a buyer, and the query costs nothing
|
|
||||||
# when there is no drift — the normal case returns zero rows.
|
|
||||||
REPUBLISH_SWEEP_INTERVAL = 300
|
|
||||||
# Long enough for _start_nostr_client's own 10s wait plus the relay
|
|
||||||
# handshake, so the first pass isn't guaranteed to fail on a cold boot.
|
|
||||||
REPUBLISH_SWEEP_FIRST_DELAY = 60
|
|
||||||
|
|
||||||
|
|
||||||
def events_stop():
|
def events_stop():
|
||||||
for task in scheduled_tasks:
|
for task in scheduled_tasks:
|
||||||
|
|
@ -62,38 +43,6 @@ def events_start():
|
||||||
task1 = create_permanent_unique_task("ext_events", wait_for_paid_invoices)
|
task1 = create_permanent_unique_task("ext_events", wait_for_paid_invoices)
|
||||||
scheduled_tasks.append(task1)
|
scheduled_tasks.append(task1)
|
||||||
|
|
||||||
# Register nostr-transport RPCs. Swallow ImportError on older LNbits
|
|
||||||
# versions that pre-date the transport (the events extension still
|
|
||||||
# works fine via HTTP without it).
|
|
||||||
try:
|
|
||||||
from lnbits.core.services.nostr_transport.dispatcher import (
|
|
||||||
AUTH_WALLET,
|
|
||||||
register_rpc,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .transport_rpcs import (
|
|
||||||
handle_events_list_event_tickets,
|
|
||||||
handle_events_ticket_register,
|
|
||||||
)
|
|
||||||
|
|
||||||
register_rpc(
|
|
||||||
"events_ticket_register", handle_events_ticket_register, AUTH_WALLET
|
|
||||||
)
|
|
||||||
register_rpc(
|
|
||||||
"events_list_event_tickets",
|
|
||||||
handle_events_list_event_tickets,
|
|
||||||
AUTH_WALLET,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"[EVENTS] Registered nostr-transport RPCs: "
|
|
||||||
"events_ticket_register, events_list_event_tickets"
|
|
||||||
)
|
|
||||||
except ImportError:
|
|
||||||
logger.info(
|
|
||||||
"[EVENTS] nostr_transport not available on this LNbits — "
|
|
||||||
"ticket scanner over Nostr disabled, HTTP endpoint still works"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _start_nostr_client():
|
async def _start_nostr_client():
|
||||||
global nostr_client
|
global nostr_client
|
||||||
await asyncio.sleep(10) # Wait for nostrclient to be ready
|
await asyncio.sleep(10) # Wait for nostrclient to be ready
|
||||||
|
|
@ -103,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)
|
||||||
|
|
@ -120,61 +69,13 @@ 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("ext_events_nostr_sync", _sync_nostr_events)
|
task3 = create_permanent_unique_task(
|
||||||
|
"ext_events_nostr_sync", _sync_nostr_events
|
||||||
|
)
|
||||||
scheduled_tasks.append(task3)
|
scheduled_tasks.append(task3)
|
||||||
|
|
||||||
async def _republish_pending_sweep():
|
|
||||||
"""Retry NIP-52 publishes that never landed.
|
|
||||||
|
|
||||||
Inventory reaches clients only through the republished calendar
|
|
||||||
event, and a publish can fail (signer outage) or be skipped
|
|
||||||
entirely (no signer, no NostrClient) without anything noticing.
|
|
||||||
Both leave `nostr_publish_pending` set, so this sweep retries
|
|
||||||
from the DB rather than from an in-memory queue — it survives a
|
|
||||||
restart, which the previous behaviour did not.
|
|
||||||
|
|
||||||
Quiet by design: on a healthy instance the query returns nothing
|
|
||||||
and this logs nothing. It only speaks up when there is drift.
|
|
||||||
"""
|
|
||||||
from .crud import flag_wave_transitions, get_events_pending_republish
|
|
||||||
from .nostr_hooks import publish_or_delete_nostr_event
|
|
||||||
|
|
||||||
await asyncio.sleep(REPUBLISH_SWEEP_FIRST_DELAY)
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
# Wave boundaries are time-driven, so nothing else flags
|
|
||||||
# them. Done here rather than on a timer of its own: the
|
|
||||||
# boundary is day-granular, so one sweep interval of
|
|
||||||
# staleness is immaterial (aiolabs/events#61).
|
|
||||||
moved = await flag_wave_transitions()
|
|
||||||
if moved:
|
|
||||||
logger.info(
|
|
||||||
f"[EVENTS] Republish sweep: {moved} event(s) changed "
|
|
||||||
f"ticket wave"
|
|
||||||
)
|
|
||||||
pending = await get_events_pending_republish()
|
|
||||||
if pending:
|
|
||||||
total = len(pending)
|
|
||||||
logger.info(f"[EVENTS] Republish sweep: {total} event(s) pending")
|
|
||||||
recovered = 0
|
|
||||||
for event in pending:
|
|
||||||
take_down = event.canceled or event.status != "approved"
|
|
||||||
if await publish_or_delete_nostr_event(event, delete=take_down):
|
|
||||||
recovered += 1
|
|
||||||
logger.info(
|
|
||||||
f"[EVENTS] Republish sweep: {recovered}/{total} recovered"
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error(f"[EVENTS] Republish sweep failed: {exc}")
|
|
||||||
await asyncio.sleep(REPUBLISH_SWEEP_INTERVAL)
|
|
||||||
|
|
||||||
task4 = create_permanent_unique_task(
|
|
||||||
"ext_events_republish_sweep", _republish_pending_sweep
|
|
||||||
)
|
|
||||||
scheduled_tasks.append(task4)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["db", "events_ext", "events_start", "events_static_files", "events_stop"]
|
__all__ = ["db", "events_ext", "events_start", "events_static_files", "events_stop"]
|
||||||
|
|
|
||||||
20
config.json
20
config.json
|
|
@ -1,12 +1,9 @@
|
||||||
{
|
{
|
||||||
"id": "events",
|
|
||||||
"version": "1.6.8-aio.4",
|
|
||||||
"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 + edit gating)"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
270
crud.py
270
crud.py
|
|
@ -1,79 +1,68 @@
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import cast
|
from typing import Optional
|
||||||
|
|
||||||
from lnbits.db import Database, Filters, Page
|
from lnbits.db import Database
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
|
||||||
from .models import (
|
from .models import CreateEvent, Event, EventsSettings, Ticket, TicketExtra
|
||||||
CreateEvent,
|
|
||||||
Event,
|
|
||||||
EventsSettings,
|
|
||||||
Ticket,
|
|
||||||
TicketExtra,
|
|
||||||
TicketFilters,
|
|
||||||
advertised_wave_key,
|
|
||||||
sync_event_ticket_waves,
|
|
||||||
)
|
|
||||||
|
|
||||||
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_id: str | None = None,
|
|
||||||
) -> Ticket:
|
) -> Ticket:
|
||||||
"""Persist one ticket row.
|
|
||||||
|
|
||||||
`payment_hash` is the LNbits invoice hash shared across all rows
|
|
||||||
of a multi-ticket purchase. `ticket_id` is the row primary key /
|
|
||||||
scannable id; defaults to `payment_hash` for single-ticket
|
|
||||||
purchases so the legacy id == payment_hash invariant holds.
|
|
||||||
Multi-ticket callers pass a unique uuid here so each attendee
|
|
||||||
gets a distinct scannable QR.
|
|
||||||
"""
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
row_id = ticket_id or payment_hash
|
|
||||||
|
|
||||||
# name/email columns are NOT NULL in the schema, so we store "" when a
|
# TODO: Check if this empty string workaround is still needed.
|
||||||
# value is absent. _parse_ticket_row reverses this on read. A user_id
|
# This converts None to empty strings for database storage because:
|
||||||
# ticket may carry an email too — that is how logged-in webapp buyers get
|
# 1. Database may have NOT NULL constraints on name/email columns
|
||||||
# their ticket emailed.
|
# 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_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=row_id,
|
id=payment_hash,
|
||||||
wallet=wallet,
|
wallet=wallet,
|
||||||
event=event,
|
event=event,
|
||||||
name=db_name,
|
name=db_name,
|
||||||
|
|
@ -84,12 +73,14 @@ async def create_ticket(
|
||||||
reg_timestamp=now,
|
reg_timestamp=now,
|
||||||
time=now,
|
time=now,
|
||||||
extra=TicketExtra(**extra) if extra else TicketExtra(),
|
extra=TicketExtra(**extra) if extra else TicketExtra(),
|
||||||
payment_hash=payment_hash,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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=row_id,
|
id=payment_hash,
|
||||||
wallet=wallet,
|
wallet=wallet,
|
||||||
event=event,
|
event=event,
|
||||||
name=name,
|
name=name,
|
||||||
|
|
@ -100,42 +91,34 @@ async def create_ticket(
|
||||||
reg_timestamp=now,
|
reg_timestamp=now,
|
||||||
time=now,
|
time=now,
|
||||||
extra=TicketExtra(**extra) if extra else TicketExtra(),
|
extra=TicketExtra(**extra) if extra else TicketExtra(),
|
||||||
payment_hash=payment_hash,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
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_tickets_by_payment_hash(payment_hash: str) -> list[Ticket]:
|
async def get_ticket(payment_hash: str) -> Optional[Ticket]:
|
||||||
"""All ticket rows sharing the given LNbits invoice payment_hash.
|
|
||||||
|
|
||||||
For a single-ticket purchase returns one row (legacy invariant
|
|
||||||
`id == payment_hash` still holds). For a multi-ticket purchase
|
|
||||||
returns the N rows created with shared `payment_hash` but
|
|
||||||
distinct `id`s — each attendee's scannable QR.
|
|
||||||
"""
|
|
||||||
rows = await db.fetchall(
|
|
||||||
"SELECT * FROM events.ticket WHERE payment_hash = :ph",
|
|
||||||
{"ph": payment_hash},
|
|
||||||
)
|
|
||||||
return [Ticket(**_parse_ticket_row(row)) for row in rows]
|
|
||||||
|
|
||||||
|
|
||||||
async def get_ticket(payment_hash: str) -> Ticket | None:
|
|
||||||
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))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -144,52 +127,20 @@ 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]
|
|
||||||
|
|
||||||
|
|
||||||
async def get_tickets_by_event(event_id: str) -> list[Ticket]:
|
|
||||||
"""All ticket rows for the given calendar event id."""
|
|
||||||
rows = await db.fetchall(
|
|
||||||
"SELECT * FROM events.ticket WHERE event = :event_id",
|
|
||||||
{"event_id": event_id},
|
|
||||||
)
|
|
||||||
return [Ticket(**_parse_ticket_row(row)) for row in rows]
|
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]
|
||||||
|
|
||||||
|
|
||||||
async def get_tickets_paginated(
|
|
||||||
wallet_ids: str | list[str], filters: Filters[TicketFilters] | None = None
|
|
||||||
) -> Page[Ticket]:
|
|
||||||
if isinstance(wallet_ids, str):
|
|
||||||
wallet_ids = [wallet_ids]
|
|
||||||
|
|
||||||
wallet_where = []
|
|
||||||
values = {}
|
|
||||||
for idx, wallet_id in enumerate(wallet_ids):
|
|
||||||
key = f"wallet_id_{idx}"
|
|
||||||
wallet_where.append(f":{key}")
|
|
||||||
values[key] = wallet_id
|
|
||||||
|
|
||||||
where = [f"wallet IN ({', '.join(wallet_where)})", "paid = true"]
|
|
||||||
|
|
||||||
return await db.fetch_page(
|
|
||||||
"SELECT * FROM events.ticket",
|
|
||||||
where=where,
|
|
||||||
values=values,
|
|
||||||
filters=filters,
|
|
||||||
model=Ticket,
|
|
||||||
table_name="events.ticket",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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})
|
||||||
|
|
||||||
|
|
@ -213,61 +164,51 @@ 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())
|
||||||
event = cast(Event, sync_event_ticket_waves(event))
|
|
||||||
await db.insert("events.events", event)
|
await db.insert("events.events", event)
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
|
||||||
async def update_event(event: Event) -> Event:
|
async def update_event(event: Event) -> Event:
|
||||||
event = cast(Event, sync_event_ticket_waves(event))
|
|
||||||
await db.update("events.events", event)
|
await db.update("events.events", event)
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
|
||||||
async def get_event(event_id: str) -> Event | None:
|
async def get_event(event_id: str) -> Event | None:
|
||||||
event = await db.fetchone(
|
return await db.fetchone(
|
||||||
"SELECT * FROM events.events WHERE id = :id",
|
"SELECT * FROM events.events WHERE id = :id",
|
||||||
{"id": event_id},
|
{"id": event_id},
|
||||||
Event,
|
Event,
|
||||||
)
|
)
|
||||||
return cast(Event, sync_event_ticket_waves(event)) if event else None
|
|
||||||
|
|
||||||
|
|
||||||
async def get_events(wallet_ids: str | list[str]) -> list[Event]:
|
async def get_events(wallet_ids: str | list[str]) -> list[Event]:
|
||||||
if isinstance(wallet_ids, str):
|
if isinstance(wallet_ids, str):
|
||||||
wallet_ids = [wallet_ids]
|
wallet_ids = [wallet_ids]
|
||||||
q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids])
|
q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids])
|
||||||
events = await db.fetchall(
|
return await db.fetchall(
|
||||||
f"SELECT * FROM events.events WHERE wallet IN ({q})",
|
f"SELECT * FROM events.events WHERE wallet IN ({q})",
|
||||||
model=Event,
|
model=Event,
|
||||||
)
|
)
|
||||||
return [cast(Event, sync_event_ticket_waves(event)) for event in events]
|
|
||||||
|
|
||||||
|
|
||||||
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."""
|
||||||
events = 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,
|
||||||
)
|
)
|
||||||
# Wave-sync on read, exactly as upstream's `get_event` / `get_events`
|
|
||||||
# do. These four getters are fork-only, so upstream's v1.6.8 diff
|
|
||||||
# never reached them — and two of them publish: `get_all_events`
|
|
||||||
# backs /republish-all and `get_events_pending_republish` drives the
|
|
||||||
# #55 sweep. Without this they would emit the stale roll-up rather
|
|
||||||
# than the current per-wave figures.
|
|
||||||
return [cast(Event, sync_event_ticket_waves(e)) for e in events]
|
|
||||||
|
|
||||||
|
|
||||||
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."""
|
||||||
events = await db.fetchall(
|
return await db.fetchall(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM events.events
|
SELECT * FROM events.events
|
||||||
WHERE status = 'approved' AND canceled = FALSE
|
WHERE status = 'approved' AND canceled = FALSE
|
||||||
|
|
@ -275,101 +216,18 @@ async def get_public_events() -> list[Event]:
|
||||||
""",
|
""",
|
||||||
model=Event,
|
model=Event,
|
||||||
)
|
)
|
||||||
# Wave-sync on read, exactly as upstream's `get_event` / `get_events`
|
|
||||||
# do. These four getters are fork-only, so upstream's v1.6.8 diff
|
|
||||||
# never reached them — and two of them publish: `get_all_events`
|
|
||||||
# backs /republish-all and `get_events_pending_republish` drives the
|
|
||||||
# #55 sweep. Without this they would emit the stale roll-up rather
|
|
||||||
# than the current per-wave figures.
|
|
||||||
return [cast(Event, sync_event_ticket_waves(e)) for e in events]
|
|
||||||
|
|
||||||
|
|
||||||
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."""
|
||||||
events = 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,
|
||||||
)
|
)
|
||||||
# Wave-sync on read, exactly as upstream's `get_event` / `get_events`
|
|
||||||
# do. These four getters are fork-only, so upstream's v1.6.8 diff
|
|
||||||
# never reached them — and two of them publish: `get_all_events`
|
|
||||||
# backs /republish-all and `get_events_pending_republish` drives the
|
|
||||||
# #55 sweep. Without this they would emit the stale roll-up rather
|
|
||||||
# than the current per-wave figures.
|
|
||||||
return [cast(Event, sync_event_ticket_waves(e)) for e in events]
|
|
||||||
|
|
||||||
|
|
||||||
async def flag_wave_transitions() -> int:
|
|
||||||
"""Flag events whose advertised ticket wave has moved on.
|
|
||||||
|
|
||||||
Every republish this extension performs is *sale*-driven. A wave
|
|
||||||
boundary is a *date* boundary, so when early bird closes at midnight
|
|
||||||
nothing fires and the relay keeps serving the closed wave's price until
|
|
||||||
the next ticket happens to sell (aiolabs/events#61).
|
|
||||||
|
|
||||||
Rather than add a scheduler and a second publish path, this compares the
|
|
||||||
wave a row would advertise now against the one its last successful
|
|
||||||
publish did (`nostr_published_wave_id`) and sets `nostr_publish_pending`
|
|
||||||
on a mismatch — handing the work to the existing reconciliation sweep,
|
|
||||||
which already retries, survives restarts and logs.
|
|
||||||
|
|
||||||
Rows with NULL `nostr_published_wave_id` are skipped: that means "never
|
|
||||||
published, or published before the column existed", which is no evidence
|
|
||||||
of drift. Flagging them would republish the whole table on first boot
|
|
||||||
after the upgrade.
|
|
||||||
|
|
||||||
Returns the number of rows newly flagged.
|
|
||||||
"""
|
|
||||||
events = await db.fetchall(
|
|
||||||
"""
|
|
||||||
SELECT * FROM events.events
|
|
||||||
WHERE nostr_published_wave_id IS NOT NULL
|
|
||||||
AND nostr_publish_pending = FALSE
|
|
||||||
AND canceled = FALSE
|
|
||||||
AND status = 'approved'
|
|
||||||
""",
|
|
||||||
model=Event,
|
|
||||||
)
|
|
||||||
flagged = 0
|
|
||||||
for event in events:
|
|
||||||
event = cast(Event, sync_event_ticket_waves(event))
|
|
||||||
if advertised_wave_key(event) == event.nostr_published_wave_id:
|
|
||||||
continue
|
|
||||||
event.nostr_publish_pending = True
|
|
||||||
await update_event(event)
|
|
||||||
flagged += 1
|
|
||||||
return flagged
|
|
||||||
|
|
||||||
|
|
||||||
async def get_events_pending_republish() -> list[Event]:
|
|
||||||
"""Events whose relay copy may be behind this row.
|
|
||||||
|
|
||||||
`nostr_publish_pending` is set before every publish attempt and
|
|
||||||
cleared only on a confirmed success, so a row still flagged here
|
|
||||||
either failed to publish or never got the chance. Drives the
|
|
||||||
reconciliation sweep in `events_start`.
|
|
||||||
|
|
||||||
Ordered oldest-first so a backlog drains in the order it accrued.
|
|
||||||
"""
|
|
||||||
events = await db.fetchall(
|
|
||||||
"""
|
|
||||||
SELECT * FROM events.events
|
|
||||||
WHERE nostr_publish_pending = TRUE
|
|
||||||
ORDER BY time ASC
|
|
||||||
""",
|
|
||||||
model=Event,
|
|
||||||
)
|
|
||||||
# Wave-sync on read, exactly as upstream's `get_event` / `get_events`
|
|
||||||
# do. These four getters are fork-only, so upstream's v1.6.8 diff
|
|
||||||
# never reached them — and two of them publish: `get_all_events`
|
|
||||||
# backs /republish-all and `get_events_pending_republish` drives the
|
|
||||||
# #55 sweep. Without this they would emit the stale roll-up rather
|
|
||||||
# than the current per-wave figures.
|
|
||||||
return [cast(Event, sync_event_ticket_waves(e)) for e in events]
|
|
||||||
|
|
||||||
|
|
||||||
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))
|
||||||
|
|
@ -377,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
|
||||||
|
|
@ -393,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]
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
# Rebase playbook
|
|
||||||
|
|
||||||
How to merge an upstream release into this fork without shipping the
|
|
||||||
failures a clean `git merge` cannot see.
|
|
||||||
|
|
||||||
Written during the v1.6.1 → v1.6.8 rebase (#33) after the first two
|
|
||||||
instances showed up within minutes of each other. Both were textually
|
|
||||||
clean merges that would have been semantically wrong in production.
|
|
||||||
Modes C and D were added later in the same rebase, from `services.py`.
|
|
||||||
|
|
||||||
## The four failure modes
|
|
||||||
|
|
||||||
Git resolves _text_. Neither of these produces a conflict marker.
|
|
||||||
|
|
||||||
### A. Missed application
|
|
||||||
|
|
||||||
Upstream establishes an invariant and applies it at every call site **it
|
|
||||||
knows about**. The fork has additional call sites upstream cannot see,
|
|
||||||
so the invariant silently does not hold there.
|
|
||||||
|
|
||||||
> **Worked example.** v1.6.8 made `event.amount_tickets` a per-wave
|
|
||||||
> roll-up recomputed by `sync_event_ticket_waves`, and added that call to
|
|
||||||
> `get_event` and `get_events` in `crud.py`. Both merged cleanly. But the
|
|
||||||
> fork has four getters upstream never had — `get_all_events`,
|
|
||||||
> `get_public_events`, `get_pending_events`,
|
|
||||||
> `get_events_pending_republish` — and two of them _publish_
|
|
||||||
> (`/republish-all` and the #55 sweep). Without the same call they emit
|
|
||||||
> the stale roll-up, so waves would have been wrong on exactly the paths
|
|
||||||
> that push to relays, and nowhere else.
|
|
||||||
|
|
||||||
### B. Changed meaning
|
|
||||||
|
|
||||||
Upstream redefines what an existing field _means_. Fork code that reads
|
|
||||||
it is untouched by the diff and keeps compiling, while now saying
|
|
||||||
something false.
|
|
||||||
|
|
||||||
> **Worked example.** After `sync_event_ticket_waves`,
|
|
||||||
> `event.price_per_ticket` is the **primary (first)** wave's price and
|
|
||||||
> `event.amount_tickets` is the **sum across all waves**. Our
|
|
||||||
> `nostr_publisher.build_nip52_event` reads both. Merged untouched, it
|
|
||||||
> would advertise the early-bird price after early-bird closed, and count
|
|
||||||
> stock in waves that have not opened. See #61.
|
|
||||||
|
|
||||||
### C. Misplaced conflict boundary
|
|
||||||
|
|
||||||
Git anchors a conflict on whatever lines happen to match. When both sides
|
|
||||||
rewrote the same region, an _incidental_ shared line inside it can become
|
|
||||||
the anchor — and everything past that line lands **outside** the markers,
|
|
||||||
where it reads as cleanly merged.
|
|
||||||
|
|
||||||
Resolving only what sits between the markers then leaves **both**
|
|
||||||
implementations in the file. Python does not complain: the later `def`
|
|
||||||
silently wins. Since the merge appends upstream after ours, the survivor
|
|
||||||
is upstream's — the fork's version is shadowed without a single warning.
|
|
||||||
|
|
||||||
> **Worked example.** In `services.py` the notification stack conflicted.
|
|
||||||
> Ours (220 lines: multipart HTML mail, the QR-card attachment, the
|
|
||||||
> Date/Message-ID headers that keep SpamAssassin quiet, npub DM support)
|
|
||||||
> appeared between the markers; upstream's showed as 4 lines. But both
|
|
||||||
> sides define the _same nine functions_, and git had anchored on a
|
|
||||||
> shared `_send_nostr_ticket_notification` line — so upstream's entire
|
|
||||||
> parallel stack sat below `>>>>>>>`, looking merged. Taking "ours" and
|
|
||||||
> moving on would have left upstream's definitions last in the file and
|
|
||||||
> therefore live, quietly reverting every one of those features.
|
|
||||||
|
|
||||||
**Do not trust the marker as the edit's boundary.** Before resolving a
|
|
||||||
hunk, list the function names on each side and compare them to the names
|
|
||||||
already in the file:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
grep -o '^\(async \)\?def \w*' <file>.py | sed 's/.*def //' | sort | uniq -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Run that per file **as you resolve**, not at the end. `ruff` does not
|
|
||||||
flag redefinition (verified: F ruleset passes on a duplicated `def`).
|
|
||||||
Only `mypy` does, via `no-redef` — and mypy refuses to run at all while
|
|
||||||
any file in the package still has conflict markers, so the one tool that
|
|
||||||
catches mode C is unavailable for the whole merge. The `uniq -d` line
|
|
||||||
above is the substitute.
|
|
||||||
|
|
||||||
### D. Collided names
|
|
||||||
|
|
||||||
Ours and upstream independently grew a function with the **same name for
|
|
||||||
a different feature**. Every resolution that reads as sane — take ours,
|
|
||||||
take theirs, take "the newer one" — silently deletes a feature, and the
|
|
||||||
diff looks like an ordinary reconciliation of one function.
|
|
||||||
|
|
||||||
> **Worked example.** Both sides had `_ticket_image_url`. Ours: the
|
|
||||||
> rendered QR ticket-card PNG, always attached, served by this extension
|
|
||||||
> off `lnbits_baseurl`. Upstream's: an organiser-uploaded template, opt-in
|
|
||||||
> per wave via `use_ticket_image`, served off `ticket_base_url` and
|
|
||||||
> returning `None` when the wave has not enabled it. Same name, different
|
|
||||||
> arity, different return type, unrelated features. Resolved by renaming
|
|
||||||
> ours to `_ticket_card_url` and keeping both — upstream's ticket-image
|
|
||||||
> upload UI had already merged into `index.vue`, so dropping their backend
|
|
||||||
> would have orphaned live UI.
|
|
||||||
|
|
||||||
Signals worth stopping on: the two versions differ in **arity**, in
|
|
||||||
**return type** (`str` vs `str | None`), or in which setting they build a
|
|
||||||
URL from. Any of those means it is probably not one function with two
|
|
||||||
histories.
|
|
||||||
|
|
||||||
## The procedure
|
|
||||||
|
|
||||||
Run this _after_ the merge resolves and _before_ the release.
|
|
||||||
|
|
||||||
### 1. Enumerate what upstream introduced
|
|
||||||
|
|
||||||
```sh
|
|
||||||
MB=$(git merge-base HEAD upstream/main)
|
|
||||||
# new public names
|
|
||||||
git diff $MB..<tag> -- '*.py' | grep -E "^\+(def |class |async def )"
|
|
||||||
# fields whose meaning was redefined
|
|
||||||
git show <tag>:models.py | sed -n '/^def sync_event_ticket_waves/,/return event/p'
|
|
||||||
```
|
|
||||||
|
|
||||||
Split the result into **new symbols** (mode A candidates) and
|
|
||||||
**redefined fields** (mode B candidates).
|
|
||||||
|
|
||||||
### 2. For each new symbol upstream _calls_, find the fork-only siblings
|
|
||||||
|
|
||||||
Ask what category of place the call belongs to — "every function that
|
|
||||||
returns an `Event` from the DB", "every path that prices a ticket" — then
|
|
||||||
enumerate that whole category in the merged tree and check coverage.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
grep -n "sync_event_ticket_waves" crud.py # where upstream put it
|
|
||||||
grep -n "^async def get_.*-> \(list\[\)\?Event" crud.py # where it belongs
|
|
||||||
```
|
|
||||||
|
|
||||||
The gap between those two lists is the work.
|
|
||||||
|
|
||||||
### 3. For each redefined field, grep the fork-only files
|
|
||||||
|
|
||||||
The 30-odd files upstream has never seen are where mode B hides, because
|
|
||||||
nothing in the diff touches them:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git diff --name-only $MB..HEAD > /tmp/fork.txt
|
|
||||||
git diff --name-only $MB..<tag> > /tmp/up.txt
|
|
||||||
comm -23 <(sort /tmp/fork.txt) <(sort /tmp/up.txt) # fork-only files
|
|
||||||
grep -n "price_per_ticket\|amount_tickets" $(comm -23 ...)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Prove each finding before fixing it
|
|
||||||
|
|
||||||
Both examples above were confirmed by reading the code path end to end,
|
|
||||||
not inferred from the diff. A wrong theory costs more than the check:
|
|
||||||
during this rebase an inference that `amount_tickets` "goes stale on
|
|
||||||
sale" was wrong — `sync_event_ticket_waves` also runs on _reads_, which
|
|
||||||
only the call-site list showed.
|
|
||||||
|
|
||||||
### 5. Write the reason at the site
|
|
||||||
|
|
||||||
Every fix from this procedure gets a comment saying **why upstream's diff
|
|
||||||
missed it**. That is what stops the next rebase re-dropping it, and it is
|
|
||||||
the only durable record that the omission was considered rather than
|
|
||||||
overlooked.
|
|
||||||
|
|
||||||
## Checklist
|
|
||||||
|
|
||||||
- [ ] `migrations.py` still byte-identical to upstream
|
|
||||||
- [ ] New upstream symbols enumerated; each call-site category audited
|
|
||||||
- [ ] Redefined fields enumerated; every fork-only reader checked
|
|
||||||
- [ ] Fork-only files listed and grepped for both modes
|
|
||||||
- [ ] Every resolved file checked for duplicate `def`s (mode C) — as it is
|
|
||||||
resolved, since mypy cannot run until the whole package is clean
|
|
||||||
- [ ] Same-named functions on both sides compared by arity and return type
|
|
||||||
before being treated as one function (mode D)
|
|
||||||
- [ ] Publishing paths specifically audited — they fail silently and
|
|
||||||
externally, so they are the worst place for either mode to land
|
|
||||||
- [ ] Every fix carries a comment explaining the omission
|
|
||||||
- [ ] Deviations recorded in `docs/upstream-candidates.md`
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
# Upstream PR candidates
|
|
||||||
|
|
||||||
Running log of fork features that are shaped so they could be offered to
|
|
||||||
`lnbits/events` (or `lnbits/lnbits`). Add a row whenever a change lands here
|
|
||||||
in an upstream-compatible form; strike it when the PR merges upstream.
|
|
||||||
|
|
||||||
| Feature | Where | Upstream target | Readiness |
|
|
||||||
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR |
|
|
||||||
| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above |
|
|
||||||
| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch |
|
|
||||||
| `extra.payment_methods` per event + `tickets_payment_methods` NIP-52 tag | `models.py`, `nostr_publisher.py` | lnbits/events (v2 PR #64 introduces the same field) | offer as review input on #64 |
|
|
||||||
| `asyncio.to_thread` around the smtplib send | `services.py` `_send_ticket_email_notification` | lnbits/events | trivial, standalone |
|
|
||||||
| QR logo overlay in `make_qr_png` (instance `lnbits_qr_logo`) | `views_api.py` | lnbits/events | standalone |
|
|
||||||
| Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 |
|
|
||||||
| Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone |
|
|
||||||
| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 |
|
|
||||||
| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 |
|
|
||||||
| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `<img>` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) |
|
|
||||||
| Promo `max_uses` + derived `used_count` (per ticket; v2 counts per basket), `POST /promo/validate/{event_id}` with `quantity` instead of `items`, `PublicEventExtra` projection (v2 still exposes `extra` fully) | `promo.py`, `models.py`, `views_api.py` | lnbits/events (v2 PR #64) | review input on #64 |
|
|
||||||
| NIP-52 tags describe the **active** ticket wave (cheapest open wave; `tickets_available: 0` when none is open), plus `nostr_published_wave_id` so the reconciliation sweep republishes at wave boundaries | `nostr_publisher.py`, `crud.py` `flag_wave_transitions`, `migrations_fork.py` m004 | lnbits/events #46 (rides with the NIP-52 publishing PR) | **deviation, deliberate** — upstream has waves but publishes no calendar event, so there is nothing upstream to match. Several waves can be open at once and a publisher cannot ask which one the buyer wants (upstream's purchase path errors with "Please select a ticket wave"), so it advertises the cheapest — the price a buyer can actually obtain. Rationale and the rejected alternatives: aiolabs/events#61 |
|
|
||||||
|
|
@ -162,36 +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 m006_add_extra_fields(db):
|
async def m006_add_user_id_support(db):
|
||||||
|
"""
|
||||||
|
Add user_id column to tickets table to support LNbits user-id as identifier
|
||||||
|
Make name and email optional when user_id is provided
|
||||||
|
"""
|
||||||
|
await db.execute("ALTER TABLE events.ticket ADD COLUMN user_id TEXT;")
|
||||||
|
|
||||||
|
# Since SQLite doesn't support changing column constraints directly,
|
||||||
|
# we'll work around this by allowing the application logic to handle
|
||||||
|
# the validation that either (name AND email) OR user_id is provided
|
||||||
|
# The database will continue to expect name and email as NOT NULL
|
||||||
|
# but we'll insert empty strings for user_id tickets
|
||||||
|
|
||||||
|
async def m007_add_extra_fields(db):
|
||||||
"""
|
"""
|
||||||
Add a canceled and 'extra' column to events and ticket tables
|
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.
|
||||||
"""
|
"""
|
||||||
# Add canceled and 'extra' columns to events table
|
# Add canceled and 'extra' columns to events table
|
||||||
|
# SQLite requires separate ALTER TABLE statements for each column
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"ALTER TABLE events.events ADD COLUMN canceled BOOLEAN NOT NULL DEFAULT FALSE;"
|
"ALTER TABLE events.events ADD COLUMN canceled BOOLEAN NOT NULL DEFAULT FALSE;"
|
||||||
)
|
)
|
||||||
await db.execute("ALTER TABLE events.events ADD COLUMN extra TEXT;")
|
await db.execute(
|
||||||
|
"ALTER TABLE events.events ADD COLUMN extra TEXT;"
|
||||||
|
)
|
||||||
|
|
||||||
# Add 'extra' column to ticket table
|
# Add 'extra' column to ticket table
|
||||||
await db.execute("ALTER TABLE events.ticket ADD COLUMN extra TEXT;")
|
await db.execute("ALTER TABLE events.ticket ADD COLUMN extra TEXT;")
|
||||||
|
|
||||||
|
|
||||||
async def m007_add_allow_fiat(db):
|
async def m008_add_event_status(db):
|
||||||
"""
|
"""
|
||||||
Add an allow_fiat column so event owners can explicitly enable fiat checkout.
|
Add status column to events table for proposal/approval workflow.
|
||||||
|
Values: 'proposed', 'approved', 'rejected'.
|
||||||
|
Default 'approved' for backward compatibility with existing events.
|
||||||
"""
|
"""
|
||||||
await db.execute("""
|
await db.execute(
|
||||||
ALTER TABLE events.events
|
"ALTER TABLE events.events ADD COLUMN status TEXT NOT NULL DEFAULT 'approved';"
|
||||||
ADD COLUMN allow_fiat BOOLEAN NOT NULL DEFAULT FALSE;
|
)
|
||||||
""")
|
|
||||||
|
|
||||||
|
|
||||||
async def m008_add_fiat_currency(db):
|
async def m009_add_nostr_columns(db):
|
||||||
"""
|
"""
|
||||||
Add a fiat_currency column for sat-denominated events using fiat checkout.
|
Add columns to track published NIP-52 Nostr calendar events.
|
||||||
"""
|
"""
|
||||||
await db.execute("""
|
await db.execute(
|
||||||
ALTER TABLE events.events
|
"ALTER TABLE events.events ADD COLUMN nostr_event_id TEXT;"
|
||||||
ADD COLUMN fiat_currency TEXT NOT NULL DEFAULT 'GBP';
|
)
|
||||||
""")
|
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;"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,187 +0,0 @@
|
||||||
"""
|
|
||||||
Fork-specific database migrations for the aiolabs events extension.
|
|
||||||
|
|
||||||
These migrations are tracked separately under `events_fork` in the
|
|
||||||
`dbversions` table (loaded by `lnbits/core/helpers.py:migrate_extension_database`),
|
|
||||||
so they do not collide with upstream's `m{NNN}_*` numbering in
|
|
||||||
`migrations.py`. Keeping the upstream-tracked file untouched means
|
|
||||||
`git pull upstream` stays rebase-clean for schema changes.
|
|
||||||
|
|
||||||
Conventions:
|
|
||||||
- Sequential numbering starting from m001.
|
|
||||||
- Each migration is `async def m{NNN}_<description>(db)`.
|
|
||||||
- DDL must be idempotent: a fresh install runs every migration; an
|
|
||||||
install that previously ran the OLD versions of these as
|
|
||||||
`m007-m011` in `migrations.py` has the columns/tables already.
|
|
||||||
Use `_alter_add_column_safe` / `_create_table_safe` so re-runs are
|
|
||||||
no-ops instead of crashes.
|
|
||||||
|
|
||||||
History compressed into m001 (was m007-m011 in migrations.py pre-v1.6
|
|
||||||
rebase):
|
|
||||||
- m007 add_user_id_support (ticket.user_id column)
|
|
||||||
- m008 add_event_status (events.status column)
|
|
||||||
- m009 add_nostr_columns (events.nostr_event_id + created_at)
|
|
||||||
- m010 add_events_settings (events.settings singleton table)
|
|
||||||
- m011 add_location_and_categories (events.location + categories)
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
async def _alter_add_column_safe(db, sql: str) -> None:
|
|
||||||
"""ALTER TABLE ADD COLUMN that swallows duplicate-column errors.
|
|
||||||
|
|
||||||
Re-running the squashed migration on a database that already has
|
|
||||||
these columns (from the pre-squash `m007-m011` in migrations.py)
|
|
||||||
must be a silent no-op. Same swallow we used in the old migrations.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
await db.execute(sql)
|
|
||||||
except Exception as exc:
|
|
||||||
msg = str(exc).lower()
|
|
||||||
if "duplicate column" in msg or "already exists" in msg:
|
|
||||||
return
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
async def m001_aio_event_schema(db):
|
|
||||||
"""
|
|
||||||
Apply every aiolabs schema delta on top of upstream events v1.3.0.
|
|
||||||
|
|
||||||
This is the squashed equivalent of the pre-v1.6 sequence
|
|
||||||
m007 → m011. Order matters for the settings table seed insert
|
|
||||||
but the individual column adds are independent and idempotent.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# --- ticket.user_id ----------------------------------------------
|
|
||||||
# Lets a ticket reference an LNbits user id instead of (name, email).
|
|
||||||
# Application logic enforces that exactly one identifier scheme is
|
|
||||||
# used per ticket.
|
|
||||||
await _alter_add_column_safe(
|
|
||||||
db, "ALTER TABLE events.ticket ADD COLUMN user_id TEXT"
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- events.status -----------------------------------------------
|
|
||||||
# Proposal / approval workflow. Existing rows default to 'approved'
|
|
||||||
# so they stay visible after upgrade.
|
|
||||||
await _alter_add_column_safe(
|
|
||||||
db,
|
|
||||||
"ALTER TABLE events.events ADD COLUMN status TEXT NOT NULL DEFAULT 'approved'",
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- events.nostr_event_id, nostr_event_created_at ---------------
|
|
||||||
# Track the most recent NIP-52 calendar event we published, so
|
|
||||||
# subsequent edits can issue replaceable updates and NIP-09 deletes
|
|
||||||
# against the right addressable coordinate.
|
|
||||||
await _alter_add_column_safe(
|
|
||||||
db, "ALTER TABLE events.events ADD COLUMN nostr_event_id TEXT"
|
|
||||||
)
|
|
||||||
await _alter_add_column_safe(
|
|
||||||
db, "ALTER TABLE events.events ADD COLUMN nostr_event_created_at INTEGER"
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- events.settings ---------------------------------------------
|
|
||||||
# Singleton settings row used by the admin UI to toggle e.g.
|
|
||||||
# auto_approve. CREATE TABLE IF NOT EXISTS + a guarded seed keeps
|
|
||||||
# this idempotent.
|
|
||||||
await db.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS events.settings (
|
|
||||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
|
||||||
auto_approve BOOLEAN NOT NULL DEFAULT FALSE
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
await db.execute(
|
|
||||||
"INSERT INTO events.settings (id, auto_approve) "
|
|
||||||
"SELECT 1, FALSE WHERE NOT EXISTS "
|
|
||||||
"(SELECT 1 FROM events.settings WHERE id = 1)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- events.location, events.categories --------------------------
|
|
||||||
# NIP-52 calendar metadata. `categories` carries a JSON-encoded
|
|
||||||
# list of hashtags (the NIP-52 `t` tags).
|
|
||||||
await _alter_add_column_safe(
|
|
||||||
db, "ALTER TABLE events.events ADD COLUMN location TEXT"
|
|
||||||
)
|
|
||||||
await _alter_add_column_safe(
|
|
||||||
db, "ALTER TABLE events.events ADD COLUMN categories TEXT"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def m002_ticket_payment_hash(db):
|
|
||||||
"""
|
|
||||||
Add `ticket.payment_hash` for multi-ticket purchases.
|
|
||||||
|
|
||||||
Multi-ticket purchases land as N rows sharing one LNbits invoice
|
|
||||||
(so each attendee gets a distinct scannable QR but the buyer
|
|
||||||
pays once). `ticket.id` stays the row primary key — for legacy
|
|
||||||
single-purchase rows it equals payment_hash; for multi-purchase
|
|
||||||
children it's a uuid generated at create-time. `payment_hash`
|
|
||||||
is the new join key for invoice lookup.
|
|
||||||
|
|
||||||
Backfill existing rows from id so the
|
|
||||||
GET-tickets-by-payment-hash path keeps working for pre-migration
|
|
||||||
data (id was the payment_hash by invariant before this column).
|
|
||||||
"""
|
|
||||||
await _alter_add_column_safe(
|
|
||||||
db, "ALTER TABLE events.ticket ADD COLUMN payment_hash TEXT"
|
|
||||||
)
|
|
||||||
await db.execute(
|
|
||||||
"UPDATE events.ticket SET payment_hash = id "
|
|
||||||
"WHERE payment_hash IS NULL OR payment_hash = ''"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def m003_event_nostr_publish_pending(db):
|
|
||||||
"""
|
|
||||||
Add `events.nostr_publish_pending` — the marker that makes NIP-52
|
|
||||||
publish drift queryable instead of invisible.
|
|
||||||
|
|
||||||
Inventory reaches clients only through the republished calendar
|
|
||||||
event. When that publish doesn't land, the relay keeps serving the
|
|
||||||
counts it last saw and nothing anywhere records the divergence; it
|
|
||||||
has twice been caught only by a human reading a public page
|
|
||||||
(aiolabs/events#35, #51).
|
|
||||||
|
|
||||||
The flag is set before each publish attempt and cleared only on a
|
|
||||||
confirmed success, so it covers *both* observed failure shapes:
|
|
||||||
an attempt that raised (a signer outage) and an attempt that was
|
|
||||||
never made at all (no signer resolved, no NostrClient). A periodic
|
|
||||||
sweep republishes whatever is still marked.
|
|
||||||
|
|
||||||
Existing rows default to FALSE rather than TRUE: on upgrade we have
|
|
||||||
no evidence they're stale, and marking the whole table pending would
|
|
||||||
stampede the signer with a full-table republish on first boot.
|
|
||||||
`/republish-all` is the deliberate way to force that.
|
|
||||||
"""
|
|
||||||
await _alter_add_column_safe(
|
|
||||||
db,
|
|
||||||
"ALTER TABLE events.events "
|
|
||||||
"ADD COLUMN nostr_publish_pending BOOLEAN NOT NULL DEFAULT FALSE",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def m004_event_nostr_published_wave(db):
|
|
||||||
"""
|
|
||||||
Add `events.nostr_published_wave_id` — which ticket wave the last
|
|
||||||
successful NIP-52 publish advertised.
|
|
||||||
|
|
||||||
Since upstream v1.6.8 price and inventory live on time-boxed waves, so
|
|
||||||
what a calendar event should advertise changes at a *date* boundary.
|
|
||||||
Every republish we have is sale-driven, and no sale happens at
|
|
||||||
midnight when early bird ends — so without this the relay keeps
|
|
||||||
serving the closed wave's price until the next ticket sells
|
|
||||||
(aiolabs/events#61).
|
|
||||||
|
|
||||||
Recording the advertised wave makes that drift detectable with the
|
|
||||||
machinery already in place: the reconciliation sweep compares this
|
|
||||||
against the wave that would be advertised now and sets
|
|
||||||
`nostr_publish_pending`, reusing the existing retry path rather than
|
|
||||||
adding a scheduler.
|
|
||||||
|
|
||||||
NULL on existing rows means "never published, or published before this
|
|
||||||
column existed". The sweep treats NULL as "no evidence of drift" and
|
|
||||||
leaves it alone, so an upgrade does not stampede the signer with a
|
|
||||||
full-table republish; the first ordinary publish fills it in.
|
|
||||||
"""
|
|
||||||
await _alter_add_column_safe(
|
|
||||||
db,
|
|
||||||
"ALTER TABLE events.events ADD COLUMN nostr_published_wave_id TEXT",
|
|
||||||
)
|
|
||||||
481
models.py
481
models.py
|
|
@ -1,142 +1,71 @@
|
||||||
import json
|
import json
|
||||||
from datetime import date, datetime
|
from datetime import datetime
|
||||||
from urllib.parse import urlsplit
|
from typing import Optional
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from lnbits.db import FilterModel
|
from fastapi import Query
|
||||||
from pydantic import BaseModel, EmailStr, Field, root_validator, validator
|
from pydantic import BaseModel, EmailStr, Field, root_validator, validator
|
||||||
|
|
||||||
PAYMENT_METHODS = ("lightning", "fiat")
|
|
||||||
|
|
||||||
|
|
||||||
class PromoCode(BaseModel):
|
class PromoCode(BaseModel):
|
||||||
code: str
|
code: str
|
||||||
discount_percent: float = 0.0
|
discount_percent: float = 0.0
|
||||||
active: bool = True
|
active: bool = True
|
||||||
# Redemption cap; None / 0 = unlimited. Field names follow upstream v2.
|
|
||||||
max_uses: int | None = None
|
|
||||||
# Derived on read from PAID tickets whose extra.applied_promo_code matches
|
|
||||||
# (see promo.promo_usage / services.hydrate_promo_usage). Whatever a
|
|
||||||
# client sends back here is ignored — it is never the source of truth.
|
|
||||||
used_count: int = 0
|
|
||||||
|
|
||||||
# stored form: stripped + upper-case, never empty
|
# make the promo code uppercase
|
||||||
@validator("code")
|
@validator("code")
|
||||||
def uppercase_code(cls, v):
|
def uppercase_code(cls, v):
|
||||||
v = (v or "").strip().upper()
|
return v.upper()
|
||||||
if not v:
|
|
||||||
raise ValueError("Promo code cannot be empty.")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@validator("discount_percent")
|
@validator("discount_percent")
|
||||||
def validate_discount_percent(cls, v):
|
def validate_discount_percent(cls, v):
|
||||||
assert 0 <= v <= 100, "Discount must be between 0 and 100."
|
assert 0 <= v <= 100, "Discount must be between 0 and 100."
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@validator("max_uses", pre=True)
|
|
||||||
def normalize_max_uses(cls, v):
|
|
||||||
if v in (None, "", 0, "0"):
|
|
||||||
return None
|
|
||||||
v = int(v)
|
|
||||||
if v < 1:
|
|
||||||
raise ValueError("max_uses must be at least 1.")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class TicketWave(BaseModel):
|
|
||||||
id: str = Field(default_factory=lambda: uuid4().hex[:8])
|
|
||||||
title: str = "Primary wave"
|
|
||||||
opening_date: str
|
|
||||||
closing_date: str
|
|
||||||
currency: str = "sat"
|
|
||||||
use_ticket_image: bool = False
|
|
||||||
ticket_image_id: str | None = None
|
|
||||||
allow_fiat: bool = False
|
|
||||||
fiat_currency: str = "GBP"
|
|
||||||
amount_tickets: int = Field(default=0, ge=0)
|
|
||||||
price_per_ticket: float = Field(default=0, ge=0)
|
|
||||||
|
|
||||||
|
|
||||||
class EventExtraBase(BaseModel):
|
|
||||||
"""Everything in `extra` that is safe to show anyone — ticket waves
|
|
||||||
included, since a buyer needs a wave id to choose one. `EventExtra` adds
|
|
||||||
the organizer-only promo codes on top; `PublicEventExtra` is this base,
|
|
||||||
so anonymous responses can never carry them."""
|
|
||||||
|
|
||||||
|
class EventExtra(BaseModel):
|
||||||
|
promo_codes: list[PromoCode] = Field(default_factory=list)
|
||||||
conditional: bool = False
|
conditional: bool = False
|
||||||
min_tickets: int = 1
|
min_tickets: int = 1
|
||||||
email_notifications: bool = False
|
|
||||||
nostr_notifications: bool = False
|
|
||||||
notification_subject: str = ""
|
|
||||||
notification_body: str = ""
|
|
||||||
# Rails the organizer accepts for this event. Empty = legacy rule
|
|
||||||
# ("lightning" always, "fiat" when allow_fiat) — see
|
|
||||||
# `effective_payment_methods`. Same field name/shape as upstream v2 so the
|
|
||||||
# eventual rebase (#33) merges cleanly.
|
|
||||||
payment_methods: list[str] = Field(default_factory=list)
|
|
||||||
# Upstream v1.6.8 ticket waves — time-boxed pricing tiers. The
|
|
||||||
# event-level `currency` / `allow_fiat` / `amount_tickets` /
|
|
||||||
# `price_per_ticket` fields become derived values (see
|
|
||||||
# `sync_event_ticket_waves`), which is why fork code that reads them
|
|
||||||
# needs auditing — aiolabs/events#61.
|
|
||||||
#
|
|
||||||
# Public, not organizer-only: a buyer cannot choose a wave without its
|
|
||||||
# id, and neither the public event response nor the NIP-52 tags carried
|
|
||||||
# one before. A wave holds price, dates and remaining stock — the sales
|
|
||||||
# information a buyer needs — so the only thing exposing it reveals is
|
|
||||||
# the upcoming price schedule, which is what #61 regretted giving up
|
|
||||||
# when it settled on flat Nostr tags.
|
|
||||||
ticket_waves: list[TicketWave] = Field(default_factory=list)
|
|
||||||
|
|
||||||
@validator("payment_methods", pre=True)
|
|
||||||
def normalize_payment_methods(cls, v):
|
|
||||||
if not v:
|
|
||||||
return []
|
|
||||||
if isinstance(v, str):
|
|
||||||
v = v.split(",")
|
|
||||||
seen: list[str] = []
|
|
||||||
for method in v:
|
|
||||||
method = str(method).strip().lower()
|
|
||||||
if method not in PAYMENT_METHODS:
|
|
||||||
raise ValueError(f"Unsupported payment method: {method}")
|
|
||||||
if method not in seen:
|
|
||||||
seen.append(method)
|
|
||||||
return seen
|
|
||||||
|
|
||||||
|
|
||||||
class EventExtra(EventExtraBase):
|
|
||||||
promo_codes: list[PromoCode] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
PublicEventExtra = EventExtraBase
|
|
||||||
|
|
||||||
|
|
||||||
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 # date-only YYYY-MM-DD; defaults to event_end_date
|
closing_date: Optional[str] = None # defaults to event_end_date or event_start_date
|
||||||
# ISO 8601: date-only ("2026-05-19") or datetime ("2026-05-19T18:30").
|
event_start_date: str # required
|
||||||
# Presence of a "T" toggles NIP-52 kind (31922 date / 31923 time).
|
event_end_date: Optional[str] = None # defaults to event_start_date
|
||||||
event_start_date: str
|
|
||||||
event_end_date: str | None = None # same format as event_start_date
|
|
||||||
currency: str = "sat"
|
currency: str = "sat"
|
||||||
allow_fiat: bool = False
|
amount_tickets: int = 0 # 0 = unlimited / not ticketed
|
||||||
fiat_currency: str = "GBP"
|
|
||||||
# Capacity is always required and there is no unlimited (#34): a zero
|
|
||||||
# here means sold out / not sellable, which `api_get_event` and
|
|
||||||
# `api_ticket_create` both enforce with a 410. Under v1.6.8 waves this
|
|
||||||
# is only the seed for the primary wave — `sync_event_ticket_waves`
|
|
||||||
# recomputes it as the sum of every wave's remaining stock.
|
|
||||||
amount_tickets: int = 0
|
|
||||||
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
|
||||||
|
|
@ -147,8 +76,6 @@ class Event(BaseModel):
|
||||||
event_start_date: str
|
event_start_date: str
|
||||||
event_end_date: str | None = None
|
event_end_date: str | None = None
|
||||||
currency: str = "sat"
|
currency: str = "sat"
|
||||||
allow_fiat: bool = False
|
|
||||||
fiat_currency: str = "GBP"
|
|
||||||
amount_tickets: int = 0
|
amount_tickets: int = 0
|
||||||
price_per_ticket: float = 0
|
price_per_ticket: float = 0
|
||||||
time: datetime
|
time: datetime
|
||||||
|
|
@ -157,19 +84,9 @@ 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
|
||||||
# Set before every publish attempt, cleared on confirmed success.
|
|
||||||
# True means the relay's copy may be behind this row — see
|
|
||||||
# migrations_fork.m003 and the sweep in __init__.events_start.
|
|
||||||
nostr_publish_pending: bool = False
|
|
||||||
# Which wave the last successful publish advertised (see
|
|
||||||
# `advertised_wave_key`). NULL = never published; "" = published while
|
|
||||||
# nothing was on sale. The sweep compares this against the current key
|
|
||||||
# to catch wave boundaries, which are time-driven and so fire no
|
|
||||||
# sale-triggered republish (aiolabs/events#61).
|
|
||||||
nostr_published_wave_id: str | None = None
|
|
||||||
|
|
||||||
@validator("categories", pre=True)
|
@validator("categories", pre=True)
|
||||||
def parse_categories(cls, v):
|
def parse_categories(cls, v):
|
||||||
|
|
@ -178,344 +95,28 @@ 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
|
|
||||||
currency: str
|
|
||||||
allow_fiat: bool = False
|
|
||||||
fiat_currency: str = "GBP"
|
|
||||||
price_per_ticket: float
|
|
||||||
banner: str | None
|
|
||||||
location: str | None = None
|
|
||||||
categories: list[str] = Field(default_factory=list)
|
|
||||||
# PublicEventExtra: promo codes are organizer-only (a buyer who can read
|
|
||||||
# every code can mint every discount).
|
|
||||||
extra: PublicEventExtra = Field(default_factory=PublicEventExtra)
|
|
||||||
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 []
|
|
||||||
|
|
||||||
|
|
||||||
def effective_payment_methods(
|
|
||||||
event: "Event | PublicEvent | CreateEvent",
|
|
||||||
wave: "TicketWave | None" = None,
|
|
||||||
) -> list[str]:
|
|
||||||
"""Rails a buyer may pick for `event`.
|
|
||||||
|
|
||||||
Explicit `extra.payment_methods` wins; an empty list falls back to the
|
|
||||||
pre-#payment-methods rule so events created before the field existed
|
|
||||||
keep behaving the same (Lightning always, fiat iff `allow_fiat`).
|
|
||||||
|
|
||||||
Pass `wave` when the answer is about one specific ticket wave. Fiat is a
|
|
||||||
per-wave opt-in since v1.6.8 and `event.allow_fiat` is only the PRIMARY
|
|
||||||
wave's, so without it a publisher can advertise a fiat rail for an
|
|
||||||
advertised wave that does not accept fiat — which the purchase endpoint
|
|
||||||
then rejects (aiolabs/events#61). Callers asking the event-level
|
|
||||||
question ("which rails did the organiser enable at all") leave it unset.
|
|
||||||
"""
|
|
||||||
explicit = list(getattr(event.extra, "payment_methods", []) or [])
|
|
||||||
if explicit:
|
|
||||||
# The organiser's rail list is event-level, but fiat is a per-wave
|
|
||||||
# opt-in. Asking about a specific wave means asking what a buyer can
|
|
||||||
# actually use for it, so drop a rail that wave cannot honour —
|
|
||||||
# otherwise the NIP-52 tag advertises fiat and the checkout offers a
|
|
||||||
# card button that `api_ticket_create` then refuses with "Fiat
|
|
||||||
# payments are not enabled for this ticket wave."
|
|
||||||
if wave is not None and not wave.allow_fiat:
|
|
||||||
return [method for method in explicit if method != "fiat"]
|
|
||||||
return explicit
|
|
||||||
methods = ["lightning"]
|
|
||||||
if wave.allow_fiat if wave is not None else event.allow_fiat:
|
|
||||||
methods.append("fiat")
|
|
||||||
return methods
|
|
||||||
|
|
||||||
|
|
||||||
class PromoValidateRequest(BaseModel):
|
|
||||||
"""Upstream v2 shape. v2 sends `items` (ticket types); this fork prices a
|
|
||||||
plain `quantity` against one ticket wave.
|
|
||||||
|
|
||||||
`ticket_wave_id` may be omitted when exactly one wave is open, matching
|
|
||||||
how the purchase endpoint resolves it — the preview has to price the same
|
|
||||||
wave the invoice will, and since v1.6.8 price and currency are per-wave.
|
|
||||||
"""
|
|
||||||
|
|
||||||
codes: list[str] = Field(default_factory=list)
|
|
||||||
quantity: int = Field(default=1, ge=1, le=10)
|
|
||||||
ticket_wave_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class BasketDiscount(BaseModel):
|
|
||||||
code: str
|
|
||||||
discount_percent: float | None = None
|
|
||||||
discount_fixed: int | None = None # always None here (percent-only); v2 shape
|
|
||||||
amount_saved: float = 0
|
|
||||||
|
|
||||||
|
|
||||||
class BasketTotals(BaseModel):
|
|
||||||
subtotal: float = 0
|
|
||||||
discount: float = 0
|
|
||||||
total: float = 0
|
|
||||||
discounts_applied: list[BasketDiscount] = Field(default_factory=list)
|
|
||||||
currency: str = "sat" # fork addition so a client can format the numbers
|
|
||||||
|
|
||||||
|
|
||||||
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):
|
||||||
applied_promo_code: str | None = None
|
applied_promo_code: str | None = None
|
||||||
ticket_wave_id: str | None = None
|
|
||||||
ticket_wave_title: str | None = None
|
|
||||||
sats_paid: int | None = None
|
sats_paid: int | None = None
|
||||||
refund_address: str | None = None
|
refund_address: str | None = None
|
||||||
nostr_identifier: str | None = None
|
|
||||||
ticket_base_url: str | None = None
|
|
||||||
email_notification_sent: bool = False
|
|
||||||
nostr_notification_sent: bool = False
|
|
||||||
refunded: bool = False
|
refunded: bool = False
|
||||||
# On-chain vocabulary, populated once native lnbits on-chain lands
|
|
||||||
# (aiolabs/events#41). Upstream's field names, minus the SatsPay charge
|
|
||||||
# id — SatsPay is the implementation we are not adopting.
|
|
||||||
onchain: bool = False
|
|
||||||
onchain_address: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class CreateTicket(BaseModel):
|
|
||||||
name: str | None = None
|
|
||||||
email: EmailStr | None = None
|
|
||||||
user_id: str | None = None # LNbits user id (alternative to name+email)
|
|
||||||
ticket_wave_id: str | None = None
|
|
||||||
promo_code: str | None = None
|
|
||||||
refund_address: str | None = None
|
|
||||||
nostr_identifier: str | None = None
|
|
||||||
payment_method: str | None = None
|
|
||||||
fiat_provider: str | None = None
|
|
||||||
# Number of tickets to buy on this single invoice. Bounded so a
|
|
||||||
# bad client can't run away with the organizer's capacity.
|
|
||||||
quantity: int = Field(default=1, ge=1, le=10)
|
|
||||||
# App root of the client that is buying (e.g. https://app.example/events).
|
|
||||||
# The extension builds the Stripe success/cancel URLs and the emailed
|
|
||||||
# ticket link under it, so the buyer lands back in the app they came
|
|
||||||
# from. Origin is allow-listed server-side (see `_resolve_frontend_root`);
|
|
||||||
# absent = today's behaviour (the LNbits host).
|
|
||||||
frontend_url: str | None = Field(default=None, max_length=512)
|
|
||||||
|
|
||||||
@validator("frontend_url")
|
|
||||||
def validate_frontend_url(cls, v):
|
|
||||||
if v is None:
|
|
||||||
return None
|
|
||||||
v = v.strip()
|
|
||||||
if not v:
|
|
||||||
return None
|
|
||||||
parts = urlsplit(v)
|
|
||||||
if parts.scheme not in ("http", "https") or not parts.netloc:
|
|
||||||
raise ValueError("frontend_url must be an absolute http(s) URL")
|
|
||||||
if parts.query or parts.fragment or ".." in parts.path:
|
|
||||||
raise ValueError("frontend_url must not contain a query, fragment or '..'")
|
|
||||||
return v.rstrip("/")
|
|
||||||
|
|
||||||
@root_validator
|
|
||||||
def validate_identifiers(cls, values):
|
|
||||||
"""A ticket needs an identity: an LNbits `user_id`, or `name` +
|
|
||||||
`email` for guests. A logged-in buyer may add `email` (and `name`)
|
|
||||||
on top of `user_id` so the ticket can be emailed to them."""
|
|
||||||
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")
|
|
||||||
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)
|
||||||
# Shared LNbits invoice payment_hash. Equals `id` for single-ticket
|
|
||||||
# purchases (legacy + post-migration default). Multi-ticket
|
|
||||||
# purchases create N rows sharing one payment_hash so each attendee
|
|
||||||
# gets a distinct scannable id while the buyer pays once.
|
|
||||||
payment_hash: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationDeliveryResult(BaseModel):
|
|
||||||
attempted: bool = False
|
|
||||||
sent: bool = False
|
|
||||||
error: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class TicketResendResult(BaseModel):
|
|
||||||
ticket: Ticket
|
|
||||||
email: NotificationDeliveryResult = Field(
|
|
||||||
default_factory=NotificationDeliveryResult
|
|
||||||
)
|
|
||||||
nostr: NotificationDeliveryResult = Field(
|
|
||||||
default_factory=NotificationDeliveryResult
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PublicTicket(BaseModel):
|
|
||||||
event: str
|
|
||||||
name: str | None = None
|
|
||||||
registered: bool
|
|
||||||
paid: bool
|
|
||||||
time: datetime
|
|
||||||
reg_timestamp: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class TicketPaymentRequest(BaseModel):
|
|
||||||
payment_hash: str
|
|
||||||
payment_request: str | None = None
|
|
||||||
fiat_payment_request: str | None = None
|
|
||||||
fiat_provider: str | None = None
|
|
||||||
is_fiat: bool = False
|
|
||||||
# True when the tickets are already issued + paid with no invoice to
|
|
||||||
# settle — free events (price 0) or a 100%-off promo. The client skips
|
|
||||||
# the QR / payment-poll step and goes straight to the ticket QRs.
|
|
||||||
paid: bool = False
|
|
||||||
# Row ids created on this invoice — one for single-ticket
|
|
||||||
# purchases, N for multi-ticket (each independently scannable at
|
|
||||||
# the door). Buyers fetch these after payment to render N QRs in
|
|
||||||
# My Tickets.
|
|
||||||
ticket_ids: list[str] = Field(default_factory=list)
|
|
||||||
onchain_amount_sat: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class TicketFilters(FilterModel):
|
|
||||||
__search_fields__ = ["event", "name", "email", "id"] # noqa: RUF012
|
|
||||||
__sort_fields__ = [ # noqa: RUF012
|
|
||||||
"time",
|
|
||||||
"event",
|
|
||||||
"name",
|
|
||||||
"email",
|
|
||||||
"registered",
|
|
||||||
"id",
|
|
||||||
]
|
|
||||||
|
|
||||||
event: str | None = None
|
|
||||||
name: str | None = None
|
|
||||||
email: str | None = None
|
|
||||||
registered: bool | None = None
|
|
||||||
paid: bool | None = None
|
|
||||||
id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_date(value: str) -> date:
|
|
||||||
"""Date component of `value`.
|
|
||||||
|
|
||||||
Upstream only ever produces bare `YYYY-MM-DD` here, so its version is a
|
|
||||||
plain `strptime(value, "%Y-%m-%d")`. In this fork `event_end_date` may
|
|
||||||
carry a time (start/end times, v1.3.0-aio.3) and `create_event` defaults
|
|
||||||
`closing_date` to it, so a wave derived from an event inherits the full
|
|
||||||
ISO datetime and upstream's parser raises
|
|
||||||
`ValueError: unconverted data remains: T18:00:00` — on the purchase path,
|
|
||||||
the public event gate, and the promo preview.
|
|
||||||
"""
|
|
||||||
return date.fromisoformat(value[:10])
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_ticket_waves(event: Event | PublicEvent | CreateEvent) -> list[TicketWave]:
|
|
||||||
ticket_waves = list(getattr(event.extra, "ticket_waves", []) or [])
|
|
||||||
if ticket_waves:
|
|
||||||
return ticket_waves
|
|
||||||
|
|
||||||
# `TicketWave` requires both dates; `Event.closing_date` is Optional in
|
|
||||||
# this fork (it defaults from event_end_date at create time), so fall
|
|
||||||
# back the same way `create_event` does rather than handing None to a
|
|
||||||
# required field.
|
|
||||||
closing_date = event.closing_date or event.event_end_date or event.event_start_date
|
|
||||||
|
|
||||||
fallback_opening_date = None
|
|
||||||
event_time = getattr(event, "time", None)
|
|
||||||
if event_time:
|
|
||||||
fallback_opening_date = event_time.date().isoformat()
|
|
||||||
if not fallback_opening_date:
|
|
||||||
fallback_opening_date = closing_date
|
|
||||||
|
|
||||||
return [
|
|
||||||
TicketWave(
|
|
||||||
id="primary",
|
|
||||||
title="Primary wave",
|
|
||||||
opening_date=fallback_opening_date,
|
|
||||||
closing_date=closing_date,
|
|
||||||
currency=event.currency,
|
|
||||||
allow_fiat=event.allow_fiat,
|
|
||||||
fiat_currency=event.fiat_currency,
|
|
||||||
amount_tickets=getattr(event, "amount_tickets", 0),
|
|
||||||
price_per_ticket=event.price_per_ticket,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def advertised_ticket_wave(event: "Event | PublicEvent") -> "TicketWave | None":
|
|
||||||
"""The wave a public listing should describe, or None when nothing is
|
|
||||||
on sale (sold out, between waves, or not yet open).
|
|
||||||
|
|
||||||
Several waves can be open at once. The purchase endpoint refuses to
|
|
||||||
guess; a publisher has no one to ask, so it advertises the CHEAPEST
|
|
||||||
open wave — the price a buyer is actually able to obtain.
|
|
||||||
|
|
||||||
Shared by the NIP-52 publisher and the wave-transition detector so the
|
|
||||||
two cannot disagree about which wave is currently being advertised.
|
|
||||||
"""
|
|
||||||
active = get_active_ticket_waves(event)
|
|
||||||
if not active:
|
|
||||||
return None
|
|
||||||
return min(active, key=lambda wave: wave.price_per_ticket)
|
|
||||||
|
|
||||||
|
|
||||||
def advertised_wave_key(event: "Event | PublicEvent") -> str:
|
|
||||||
"""Stable key for what a publish advertised. Empty string means "nothing
|
|
||||||
on sale", which is a real published state and distinct from NULL in
|
|
||||||
`nostr_published_wave_id` (never published)."""
|
|
||||||
wave = advertised_ticket_wave(event)
|
|
||||||
return wave.id if wave else ""
|
|
||||||
|
|
||||||
|
|
||||||
def sync_event_ticket_waves(event: Event | CreateEvent) -> Event | CreateEvent:
|
|
||||||
ticket_waves = ensure_ticket_waves(event)
|
|
||||||
event.extra.ticket_waves = ticket_waves
|
|
||||||
|
|
||||||
primary_wave = ticket_waves[0]
|
|
||||||
event.closing_date = max(wave.closing_date for wave in ticket_waves)
|
|
||||||
event.currency = primary_wave.currency
|
|
||||||
event.allow_fiat = primary_wave.allow_fiat
|
|
||||||
event.fiat_currency = primary_wave.fiat_currency
|
|
||||||
event.amount_tickets = sum(wave.amount_tickets for wave in ticket_waves)
|
|
||||||
event.price_per_ticket = primary_wave.price_per_ticket
|
|
||||||
|
|
||||||
return event
|
|
||||||
|
|
||||||
|
|
||||||
def get_active_ticket_waves(
|
|
||||||
event: Event | PublicEvent, today: date | None = None
|
|
||||||
) -> list[TicketWave]:
|
|
||||||
current_day = today or datetime.utcnow().date()
|
|
||||||
return [
|
|
||||||
wave
|
|
||||||
for wave in ensure_ticket_waves(event)
|
|
||||||
if _parse_date(wave.opening_date)
|
|
||||||
<= current_day
|
|
||||||
<= _parse_date(wave.closing_date)
|
|
||||||
and wave.amount_tickets > 0
|
|
||||||
]
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -10,37 +10,27 @@ 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
|
||||||
|
|
||||||
MAX_SEEN_EVENTS = 500
|
MAX_SEEN_EVENTS = 500
|
||||||
# How many times a dequeued req is retried before it is dropped. Bounded
|
|
||||||
# so one unsendable message can't block every later publish behind it.
|
|
||||||
MAX_SEND_ATTEMPTS = 3
|
|
||||||
# How long to wait for the relay's `OK` before treating a publish as
|
|
||||||
# unconfirmed. nostrclient's router answers within its own
|
|
||||||
# PUBLISH_TIMEOUT_SECONDS (10s) even when every relay stays silent, so
|
|
||||||
# this only needs headroom over that.
|
|
||||||
PUBLISH_OK_TIMEOUT_SECONDS = 12
|
|
||||||
|
|
||||||
|
|
||||||
class NostrClient:
|
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()
|
||||||
# event id -> future awaiting that publish's `OK`. Resolved in
|
|
||||||
# `get_event`, which is the single point where relay messages
|
|
||||||
# cross into the event loop.
|
|
||||||
self._pending_oks: dict[str, asyncio.Future] = {}
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_websocket_connected(self):
|
def is_websocket_connected(self):
|
||||||
|
|
@ -51,7 +41,8 @@ class NostrClient:
|
||||||
async def connect(self) -> WebSocketApp:
|
async def connect(self) -> WebSocketApp:
|
||||||
relay_endpoint = encrypt_internal_message("relay", urlsafe=True)
|
relay_endpoint = encrypt_internal_message("relay", urlsafe=True)
|
||||||
ws_url = (
|
ws_url = (
|
||||||
f"ws://localhost:{settings.port}" f"/nostrclient/api/v1/{relay_endpoint}"
|
f"ws://localhost:{settings.port}"
|
||||||
|
f"/nostrclient/api/v1/{relay_endpoint}"
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("[EVENTS] Connecting to nostrclient WebSocket...")
|
logger.info("[EVENTS] Connecting to nostrclient WebSocket...")
|
||||||
|
|
@ -69,8 +60,12 @@ class NostrClient:
|
||||||
logger.warning(f"[EVENTS] WebSocket error: {error}")
|
logger.warning(f"[EVENTS] WebSocket error: {error}")
|
||||||
|
|
||||||
def on_close(_, status_code, message):
|
def on_close(_, status_code, message):
|
||||||
logger.warning(f"[EVENTS] WebSocket closed: {status_code} {message}")
|
logger.warning(
|
||||||
self.receive_event_queue.put_nowait(ValueError("WebSocket closed"))
|
f"[EVENTS] WebSocket closed: {status_code} {message}"
|
||||||
|
)
|
||||||
|
self.receive_event_queue.put_nowait(
|
||||||
|
ValueError("WebSocket closed")
|
||||||
|
)
|
||||||
|
|
||||||
ws = WebSocketApp(
|
ws = WebSocketApp(
|
||||||
ws_url,
|
ws_url,
|
||||||
|
|
@ -90,37 +85,17 @@ class NostrClient:
|
||||||
|
|
||||||
async def run_forever(self):
|
async def run_forever(self):
|
||||||
self.running = True
|
self.running = True
|
||||||
# A req that was dequeued but whose send raised. It is already
|
|
||||||
# off the queue, so dropping it loses the publish outright and
|
|
||||||
# the caller has long since been told it succeeded (the queue
|
|
||||||
# put returns immediately). Hold it across the reconnect and
|
|
||||||
# retry instead.
|
|
||||||
held_req: list | None = None
|
|
||||||
held_attempts = 0
|
|
||||||
while self.running:
|
while self.running:
|
||||||
try:
|
try:
|
||||||
if not self.is_websocket_connected:
|
if not self.is_websocket_connected:
|
||||||
self.ws = await self.connect()
|
self.ws = await self.connect()
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
if held_req is not None:
|
|
||||||
req = held_req
|
|
||||||
else:
|
|
||||||
req = await self.send_req_queue.get()
|
req = await self.send_req_queue.get()
|
||||||
held_req, held_attempts = req, held_attempts + 1
|
|
||||||
assert self.ws
|
assert self.ws
|
||||||
self.ws.send(json.dumps(req))
|
self.ws.send(json.dumps(req))
|
||||||
held_req, held_attempts = None, 0
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(f"[EVENTS] NostrClient error: {ex}")
|
logger.warning(f"[EVENTS] NostrClient error: {ex}")
|
||||||
if held_req is not None and held_attempts >= MAX_SEND_ATTEMPTS:
|
|
||||||
# Bounded: a req the relay or the socket will never
|
|
||||||
# accept must not wedge the queue behind it forever.
|
|
||||||
logger.error(
|
|
||||||
f"[EVENTS] Dropping req after {held_attempts} "
|
|
||||||
f"failed sends: {held_req[0]}"
|
|
||||||
)
|
|
||||||
held_req, held_attempts = None, 0
|
|
||||||
await asyncio.sleep(60)
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
def is_duplicate_event(self, event_id: str) -> bool:
|
def is_duplicate_event(self, event_id: str) -> bool:
|
||||||
|
|
@ -133,87 +108,21 @@ class NostrClient:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def get_event(self):
|
async def get_event(self):
|
||||||
"""Get the next relay message, consuming `OK` frames on the way.
|
"""Get next event from the receive queue."""
|
||||||
|
|
||||||
This is the only place relay messages cross from the websocket
|
|
||||||
thread into the event loop, which makes it the natural place to
|
|
||||||
settle publish confirmations — no cross-thread future juggling.
|
|
||||||
`OK` frames are swallowed rather than forwarded; the sync loop
|
|
||||||
never handled them.
|
|
||||||
"""
|
|
||||||
while True:
|
|
||||||
value = await self.receive_event_queue.get()
|
value = await self.receive_event_queue.get()
|
||||||
if isinstance(value, ValueError):
|
if isinstance(value, ValueError):
|
||||||
self._fail_pending_oks("connection closed")
|
|
||||||
raise value
|
raise value
|
||||||
if self._settle_ok(value):
|
|
||||||
continue
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def _settle_ok(self, message) -> bool:
|
async def publish_nostr_event(self, e: NostrEvent):
|
||||||
"""Resolve the future for an `["OK", <id>, <bool>, <msg>]` frame.
|
|
||||||
|
|
||||||
Returns True when `message` was an OK frame (and so should not
|
|
||||||
be forwarded), False otherwise. An OK for a publish we are not
|
|
||||||
waiting on — a retry whose original already timed out, say — is
|
|
||||||
still consumed; it has nowhere useful to go.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = json.loads(message)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
return False
|
|
||||||
if not isinstance(data, list) or len(data) < 3 or data[0] != "OK":
|
|
||||||
return False
|
|
||||||
|
|
||||||
event_id = data[1]
|
|
||||||
accepted = bool(data[2])
|
|
||||||
detail = data[3] if len(data) > 3 and isinstance(data[3], str) else ""
|
|
||||||
future = self._pending_oks.get(event_id)
|
|
||||||
if future and not future.done():
|
|
||||||
future.set_result((accepted, detail))
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _fail_pending_oks(self, reason: str) -> None:
|
|
||||||
"""Settle every in-flight publish as unconfirmed.
|
|
||||||
|
|
||||||
Without this a disconnect leaves callers waiting the full
|
|
||||||
timeout for an `OK` that can no longer arrive.
|
|
||||||
"""
|
|
||||||
for future in self._pending_oks.values():
|
|
||||||
if not future.done():
|
|
||||||
future.set_result((False, f"error: {reason}"))
|
|
||||||
|
|
||||||
async def publish_nostr_event(self, e: NostrEvent) -> bool:
|
|
||||||
"""Publish and wait for the relay's `OK`. True only when accepted.
|
|
||||||
|
|
||||||
Queueing is not delivery: nostrclient drops an EVENT outright
|
|
||||||
when no relay is connected, answering `OK false`. Reporting
|
|
||||||
success on the queue put let a stale ticket count survive a
|
|
||||||
republish that never left the building (aiolabs/events#56).
|
|
||||||
"""
|
|
||||||
future: asyncio.Future = asyncio.get_running_loop().create_future()
|
|
||||||
self._pending_oks[e.id] = future
|
|
||||||
try:
|
|
||||||
await self.send_req_queue.put(["EVENT", e.dict()])
|
await self.send_req_queue.put(["EVENT", e.dict()])
|
||||||
accepted, detail = await asyncio.wait_for(
|
|
||||||
future, PUBLISH_OK_TIMEOUT_SECONDS
|
|
||||||
)
|
|
||||||
if not accepted:
|
|
||||||
logger.warning(f"[EVENTS] Relay rejected event {e.id[:12]}…: {detail}")
|
|
||||||
return accepted
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning(
|
|
||||||
f"[EVENTS] No OK for event {e.id[:12]}… within "
|
|
||||||
f"{PUBLISH_OK_TIMEOUT_SECONDS}s — treating as unconfirmed"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
finally:
|
|
||||||
self._pending_oks.pop(e.id, None)
|
|
||||||
|
|
||||||
async def subscribe(self, filters: list[dict]):
|
async def subscribe(self, filters: list[dict]):
|
||||||
"""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(["REQ", self.subscription_id, *filters])
|
await self.send_req_queue.put(
|
||||||
|
["REQ", self.subscription_id] + filters
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[EVENTS] Subscribed to NIP-52 events "
|
f"[EVENTS] Subscribed to NIP-52 events "
|
||||||
f"(sub: {self.subscription_id[:20]}...)"
|
f"(sub: {self.subscription_id[:20]}...)"
|
||||||
|
|
|
||||||
|
|
@ -1,86 +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, advertised_wave_key
|
|
||||||
from .nostr_publisher import publish_event_to_nostr
|
|
||||||
|
|
||||||
|
|
||||||
async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -> bool:
|
|
||||||
"""Publish or delete the NIP-52 calendar event for `event`.
|
|
||||||
|
|
||||||
Resolves a `NostrSigner` for the wallet owner — backend-agnostic
|
|
||||||
(LocalSigner / RemoteBunkerSigner / ClientSideOnlySigner). The
|
|
||||||
signer abstraction handles the actual key material; this hook
|
|
||||||
only needs `signer.pubkey` for event construction and
|
|
||||||
`await signer.sign_event(...)` for signing. Failures are logged
|
|
||||||
and swallowed so a Nostr outage doesn't break the HTTP flow that
|
|
||||||
triggered the publish.
|
|
||||||
|
|
||||||
Returns True when the event was signed and handed to the client,
|
|
||||||
False on any skip or failure. Callers are free to ignore it — the
|
|
||||||
`nostr_publish_pending` flag is the durable record, and the sweep
|
|
||||||
retries from that rather than from a return value.
|
|
||||||
"""
|
|
||||||
# Mark before attempting, clear only on confirmed success. Doing it
|
|
||||||
# in this order is what makes "the attempt was never made" — no
|
|
||||||
# signer, no NostrClient, process died mid-flight — as visible as
|
|
||||||
# "the attempt raised". Cheap guard so a re-publish of an already
|
|
||||||
# pending row doesn't write twice; `set_ticket_paid` sets the flag
|
|
||||||
# inside its own update so the sale path adds no extra write.
|
|
||||||
if not event.nostr_publish_pending:
|
|
||||||
event.nostr_publish_pending = True
|
|
||||||
await update_event(event)
|
|
||||||
|
|
||||||
try:
|
|
||||||
from lnbits.core.signers import resolve_for_wallet
|
|
||||||
|
|
||||||
from . import nostr_client
|
|
||||||
|
|
||||||
signer = await resolve_for_wallet(event.wallet)
|
|
||||||
if signer is None:
|
|
||||||
# Wallet missing, account missing, unclassified row, or
|
|
||||||
# ClientSideOnlySigner account (server can't sign for them).
|
|
||||||
# Soft-fail: the HTTP / payment flow that triggered this must
|
|
||||||
# not break. The user can still publish kind-31922/31923
|
|
||||||
# events client-side once we have that path.
|
|
||||||
#
|
|
||||||
# Logged at WARNING, not debug: skipping the publish means the
|
|
||||||
# relay keeps serving whatever inventory it last saw, so the
|
|
||||||
# public ticket count silently stops tracking the DB. That has
|
|
||||||
# twice been discovered only by a human noticing a wrong number
|
|
||||||
# on a public page (aiolabs/events#35, #51).
|
|
||||||
logger.warning(
|
|
||||||
f"[EVENTS] No signer for wallet {event.wallet}, skipping "
|
|
||||||
f"NIP-52 {'delete' if delete else 'publish'} for event {event.id}"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
nostr_event = await publish_event_to_nostr(
|
|
||||||
nostr_client, event, signer, delete=delete
|
|
||||||
)
|
|
||||||
if nostr_event is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
event.nostr_publish_pending = False
|
|
||||||
if not delete:
|
|
||||||
event.nostr_event_id = nostr_event.id
|
|
||||||
event.nostr_event_created_at = nostr_event.created_at
|
|
||||||
# Record which wave this publish advertised so the sweep can
|
|
||||||
# notice a wave boundary later (aiolabs/events#61). Written in
|
|
||||||
# the same update as the cleared flag, so the two can never
|
|
||||||
# disagree about what is on the relay.
|
|
||||||
event.nostr_published_wave_id = advertised_wave_key(event)
|
|
||||||
await update_event(event)
|
|
||||||
return True
|
|
||||||
except Exception as exc:
|
|
||||||
# ERROR, not warning: the row stays flagged and its published
|
|
||||||
# counts stay behind until the sweep or a later edit succeeds.
|
|
||||||
logger.error(f"[EVENTS] Nostr publish failed for event {event.id}: {exc}")
|
|
||||||
return False
|
|
||||||
|
|
@ -1,172 +1,54 @@
|
||||||
"""
|
"""
|
||||||
NIP-52 calendar event publishing for the events extension.
|
NIP-52 calendar event publishing for the events extension.
|
||||||
|
|
||||||
Builds NIP-52 calendar events from the Event model, signs them via the
|
Builds kind 31922 (date-based) calendar events from the Event model,
|
||||||
core `NostrSigner` abstraction (backend-agnostic: LocalSigner,
|
signs them with the event creator's Account keypair, and publishes
|
||||||
RemoteBunkerSigner, etc.), and publishes via the NostrClient.
|
via the NostrClient to nostrclient relays.
|
||||||
|
|
||||||
Kind 31922 is used for date-only events; kind 31923 (time-based) is used
|
|
||||||
when event_start_date / event_end_date include a time component.
|
|
||||||
|
|
||||||
Reference: https://github.com/nostr-protocol/nips/blob/master/52.md
|
Reference: https://github.com/nostr-protocol/nips/blob/master/52.md
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from typing import Optional
|
||||||
|
|
||||||
from lnbits.core.signers import NostrSigner
|
import coincurve
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from .models import (
|
from .models import Event
|
||||||
Event,
|
|
||||||
advertised_ticket_wave,
|
|
||||||
effective_payment_methods,
|
|
||||||
ensure_ticket_waves,
|
|
||||||
)
|
|
||||||
from .nostr.event import NostrEvent
|
from .nostr.event import NostrEvent
|
||||||
from .nostr_timestamp import monotonic_created_at
|
|
||||||
|
|
||||||
|
|
||||||
def _has_time(value: str | None) -> bool:
|
|
||||||
"""ISO 8601 datetime strings contain a 'T' between date and time."""
|
|
||||||
return value is not None and "T" in value
|
|
||||||
|
|
||||||
|
|
||||||
def _to_unix(value: str) -> int:
|
|
||||||
"""Parse ISO 8601 datetime (assume UTC if naive) to unix seconds."""
|
|
||||||
dt = datetime.fromisoformat(value)
|
|
||||||
if dt.tzinfo is None:
|
|
||||||
dt = dt.replace(tzinfo=timezone.utc)
|
|
||||||
return int(dt.timestamp())
|
|
||||||
|
|
||||||
|
|
||||||
def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
|
def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
|
||||||
"""
|
"""
|
||||||
Convert an Event model to a NIP-52 calendar event.
|
Convert an Event model to a NIP-52 kind 31922 (date-based) calendar event.
|
||||||
|
|
||||||
Time-based (kind 31923) if event_start_date carries an HH:MM, otherwise
|
Tags:
|
||||||
date-based (kind 31922). Tags:
|
d - event.id (addressable identifier)
|
||||||
d - event.id
|
|
||||||
title - event.name
|
title - event.name
|
||||||
start - unix timestamp (31923) or YYYY-MM-DD (31922)
|
start - event.event_start_date (ISO date string)
|
||||||
end - same encoding (optional)
|
end - event.event_end_date (optional)
|
||||||
image, location, t (categories) - optional
|
image - event.banner (optional)
|
||||||
tickets_available - remaining capacity of the advertised wave
|
Content: event.info (description)
|
||||||
(always emitted; 0 = nothing on sale now)
|
|
||||||
tickets_sold - running paid-count across all waves (always
|
|
||||||
emitted)
|
|
||||||
tickets_price - the advertised wave's price (always emitted;
|
|
||||||
0 means free)
|
|
||||||
tickets_currency - the advertised wave's currency
|
|
||||||
tickets_allow_fiat - "true" when fiat checkout is enabled (omitted otherwise)
|
|
||||||
tickets_fiat_currency - the fiat settle currency (only when allow_fiat)
|
|
||||||
Content: event.info
|
|
||||||
|
|
||||||
The four ticket_* tags are AIO custom additions outside the NIP-52
|
|
||||||
spec; spec-compliant clients ignore unknown tags so this stays
|
|
||||||
backwards-compatible. They let connected clients render the
|
|
||||||
"X tickets remaining" badge and the Buy CTA without an extra REST hop,
|
|
||||||
and pick up live inventory updates via the same relay subscription.
|
|
||||||
"""
|
"""
|
||||||
time_based = _has_time(event.event_start_date)
|
|
||||||
kind = 31923 if time_based else 31922
|
|
||||||
start_value = (
|
|
||||||
str(_to_unix(event.event_start_date)) if time_based else event.event_start_date
|
|
||||||
)
|
|
||||||
|
|
||||||
tags = [
|
tags = [
|
||||||
["d", event.id],
|
["d", event.id],
|
||||||
["title", event.name],
|
["title", event.name],
|
||||||
["start", start_value],
|
["start", event.event_start_date],
|
||||||
]
|
]
|
||||||
|
|
||||||
end_unix: int | None = None
|
|
||||||
if event.event_end_date:
|
if event.event_end_date:
|
||||||
end_value = (
|
tags.append(["end", event.event_end_date])
|
||||||
str(_to_unix(event.event_end_date)) if time_based else event.event_end_date
|
|
||||||
)
|
|
||||||
tags.append(["end", end_value])
|
|
||||||
if time_based:
|
|
||||||
end_unix = _to_unix(event.event_end_date)
|
|
||||||
|
|
||||||
if time_based:
|
|
||||||
start_unix = _to_unix(event.event_start_date)
|
|
||||||
start_day = start_unix // 86400
|
|
||||||
end_day = (end_unix // 86400) if end_unix is not None else start_day
|
|
||||||
for day in range(start_day, end_day + 1):
|
|
||||||
tags.append(["D", str(day)])
|
|
||||||
|
|
||||||
if event.banner:
|
if event.banner:
|
||||||
tags.append(["image", event.banner])
|
tags.append(["image", event.banner])
|
||||||
if event.location:
|
if event.location:
|
||||||
tags.append(["location", event.location])
|
tags.append(["location", event.location])
|
||||||
for cat in event.categories or []:
|
for cat in (event.categories or []):
|
||||||
tags.append(["t", cat])
|
tags.append(["t", cat])
|
||||||
|
|
||||||
# Always emitted, including zero. Omitting it used to mean "unlimited",
|
|
||||||
# which contradicted every other reader: `api_get_event` and
|
|
||||||
# `api_ticket_create` both treat `amount_tickets < 1` as sold out, so a
|
|
||||||
# zero-capacity event advertised "Unlimited tickets" on the card while
|
|
||||||
# the detail page and the purchase both returned 410 (aiolabs/events#34).
|
|
||||||
# Clients that must still handle an absent tag — a NIP-52 event from
|
|
||||||
# some other publisher — are unaffected; we simply never omit it.
|
|
||||||
#
|
|
||||||
# Since v1.6.8 price, currency and inventory belong to a ticket WAVE.
|
|
||||||
# The event-level fields `sync_event_ticket_waves` derives are the
|
|
||||||
# PRIMARY wave's price/currency and the SUM of every wave's stock, so
|
|
||||||
# publishing them would keep advertising the early-bird price after
|
|
||||||
# early bird closed, and count stock in waves that have not opened yet
|
|
||||||
# (aiolabs/events#61). Advertise the wave a buyer can actually buy from.
|
|
||||||
#
|
|
||||||
# Several waves can be open at once. The purchase endpoint refuses to
|
|
||||||
# guess ("Please select a ticket wave"); a publisher has no one to ask,
|
|
||||||
# so it advertises the CHEAPEST open wave — the price a buyer is able to
|
|
||||||
# obtain right now. Deviation recorded in docs/upstream-candidates.md.
|
|
||||||
open_wave = advertised_ticket_wave(event)
|
|
||||||
# Nothing open: sold out, between waves, or not yet on sale. Fall back
|
|
||||||
# to the primary wave so price/currency still describe the event,
|
|
||||||
# paired with the zero availability below.
|
|
||||||
advertised = open_wave or ensure_ticket_waves(event)[0]
|
|
||||||
|
|
||||||
# Always emitted, including zero — see #34 above. With no open wave
|
|
||||||
# there is nothing to sell regardless of what the waves hold, so this
|
|
||||||
# is 0 rather than the wave's stock.
|
|
||||||
available = advertised.amount_tickets if open_wave else 0
|
|
||||||
tags.append(["tickets_available", str(available)])
|
|
||||||
# Event-level: total paid across every wave, which is what "sold" means
|
|
||||||
# to a reader of the card.
|
|
||||||
tags.append(["tickets_sold", str(event.sold)])
|
|
||||||
tags.append(["tickets_price", str(advertised.price_per_ticket)])
|
|
||||||
tags.append(["tickets_currency", advertised.currency])
|
|
||||||
# Fiat-checkout config — only emitted when allow_fiat is on so
|
|
||||||
# clients can branch the buy UI without re-reading the schema.
|
|
||||||
if advertised.allow_fiat:
|
|
||||||
tags.append(["tickets_allow_fiat", "true"])
|
|
||||||
if advertised.fiat_currency:
|
|
||||||
tags.append(["tickets_fiat_currency", advertised.fiat_currency])
|
|
||||||
# Rails the organizer accepts, resolved through the same helper the
|
|
||||||
# ticket endpoint enforces with, so a client can render exactly the
|
|
||||||
# buttons that will be accepted (e.g. a card-only event) without a REST
|
|
||||||
# round-trip. Comma-separated, lowercase.
|
|
||||||
tags.append(
|
|
||||||
[
|
|
||||||
"tickets_payment_methods",
|
|
||||||
# Scoped to the advertised wave so this cannot contradict
|
|
||||||
# `tickets_allow_fiat` above — they are the same fact.
|
|
||||||
",".join(effective_payment_methods(event, advertised)),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
# NIP-52 calendar events are replaceable: this d-tag is republished
|
|
||||||
# whenever inventory changes (a ticket sells). Use a strictly-monotonic
|
|
||||||
# created_at anchored on the last published value so a same-second
|
|
||||||
# republish still outranks the prior version and relays push it to open
|
|
||||||
# subscriptions — a bare int(time.time()) can tie and be silently
|
|
||||||
# dropped, stalling clients' live "tickets remaining" badge.
|
|
||||||
nostr_event = NostrEvent(
|
nostr_event = NostrEvent(
|
||||||
pubkey=pubkey,
|
pubkey=pubkey,
|
||||||
created_at=monotonic_created_at(event.nostr_event_created_at),
|
created_at=int(time.time()),
|
||||||
kind=kind,
|
kind=31922,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
content=event.info or "",
|
content=event.info or "",
|
||||||
)
|
)
|
||||||
|
|
@ -178,17 +60,15 @@ def build_nip52_delete_event(event: Event, pubkey: str) -> NostrEvent:
|
||||||
"""
|
"""
|
||||||
Build a kind 5 delete event for a published NIP-52 calendar event.
|
Build a kind 5 delete event for a published NIP-52 calendar event.
|
||||||
|
|
||||||
Uses an 'a' tag to reference the parameterized replaceable event per
|
Uses an 'a' tag to reference the parameterized replaceable event
|
||||||
NIP-09. The referenced kind must match what we published — 31923 for
|
(kind 31922) per NIP-09.
|
||||||
time-based events, 31922 for date-only.
|
|
||||||
"""
|
"""
|
||||||
referenced_kind = 31923 if _has_time(event.event_start_date) else 31922
|
|
||||||
nostr_event = NostrEvent(
|
nostr_event = NostrEvent(
|
||||||
pubkey=pubkey,
|
pubkey=pubkey,
|
||||||
created_at=int(time.time()),
|
created_at=int(time.time()),
|
||||||
kind=5,
|
kind=5,
|
||||||
tags=[
|
tags=[
|
||||||
["a", f"{referenced_kind}:{pubkey}:{event.id}"],
|
["a", f"31922:{pubkey}:{event.id}"],
|
||||||
],
|
],
|
||||||
content="Event canceled",
|
content="Event canceled",
|
||||||
)
|
)
|
||||||
|
|
@ -196,64 +76,37 @@ def build_nip52_delete_event(event: Event, pubkey: str) -> NostrEvent:
|
||||||
return nostr_event
|
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(
|
async def publish_event_to_nostr(
|
||||||
nostr_client,
|
nostr_client,
|
||||||
event: Event,
|
event: Event,
|
||||||
signer: NostrSigner,
|
account_pubkey: 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).
|
||||||
|
|
||||||
Signing routes through the core `NostrSigner` abstraction —
|
|
||||||
`signer.pubkey` for the event identity, `await signer.sign_event(...)`
|
|
||||||
for the Schnorr signature. The signer backend (LocalSigner /
|
|
||||||
RemoteBunkerSigner) is transparent to this function.
|
|
||||||
|
|
||||||
Returns the published NostrEvent for metadata storage, or None on failure.
|
Returns the published NostrEvent for metadata storage, or None on failure.
|
||||||
"""
|
"""
|
||||||
if not nostr_client:
|
if not nostr_client:
|
||||||
# WARNING, not debug: with no client the event is never queued, so
|
logger.debug("[EVENTS] No NostrClient available, skipping publish")
|
||||||
# the relay keeps serving stale inventory and nothing downstream
|
|
||||||
# can tell. At debug this skip is invisible at the INFO level
|
|
||||||
# instances actually run at (aiolabs/events#35, #51).
|
|
||||||
logger.warning(
|
|
||||||
"[EVENTS] No NostrClient, skipping NIP-52 "
|
|
||||||
f"{'delete' if delete else 'publish'} for event {event.id}"
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if delete:
|
if delete:
|
||||||
nostr_event = build_nip52_delete_event(event, signer.pubkey)
|
nostr_event = build_nip52_delete_event(event, account_pubkey)
|
||||||
else:
|
else:
|
||||||
nostr_event = build_nip52_event(event, signer.pubkey)
|
nostr_event = build_nip52_event(event, account_pubkey)
|
||||||
|
|
||||||
# Hand the unsigned event to the signer — it fills in `id`,
|
sign_nostr_event(nostr_event, account_prvkey)
|
||||||
# `pubkey`, and `sig`. The signer's serialization rules match
|
await nostr_client.publish_nostr_event(nostr_event)
|
||||||
# NIP-01 (same as the local `event_id` property uses), so the
|
|
||||||
# returned id matches what we'd have computed locally.
|
|
||||||
unsigned = {
|
|
||||||
"kind": nostr_event.kind,
|
|
||||||
"created_at": nostr_event.created_at,
|
|
||||||
"tags": nostr_event.tags,
|
|
||||||
"content": nostr_event.content,
|
|
||||||
}
|
|
||||||
signed = await signer.sign_event(unsigned)
|
|
||||||
nostr_event.id = signed["id"]
|
|
||||||
nostr_event.pubkey = signed["pubkey"]
|
|
||||||
nostr_event.sig = signed["sig"]
|
|
||||||
|
|
||||||
accepted = await nostr_client.publish_nostr_event(nostr_event)
|
|
||||||
if not accepted:
|
|
||||||
# Returning None keeps `nostr_publish_pending` set, so the
|
|
||||||
# sweep retries instead of recording a delivery that never
|
|
||||||
# happened (aiolabs/events#56).
|
|
||||||
logger.warning(
|
|
||||||
f"[EVENTS] Relay did not confirm NIP-52 "
|
|
||||||
f"{'delete' if delete else 'calendar'} event for {event.id}"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[EVENTS] Published NIP-52 {'delete' if delete else 'calendar'} "
|
f"[EVENTS] Published NIP-52 {'delete' if delete else 'calendar'} "
|
||||||
|
|
@ -262,8 +115,5 @@ async def publish_event_to_nostr(
|
||||||
return nostr_event
|
return nostr_event
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# ERROR, not warning: this is the signer-outage shape of
|
logger.warning(f"[EVENTS] Failed to publish to Nostr: {e}")
|
||||||
# aiolabs/events#35 — the calendar event never reaches the relay
|
|
||||||
# and the published ticket counts stop tracking the DB.
|
|
||||||
logger.error(f"[EVENTS] Failed to publish event {event.id} to Nostr: {e}")
|
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -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="",
|
||||||
|
|
@ -137,11 +149,9 @@ async def wait_for_nostr_events(nostr_client: NostrClient):
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# Subscribe to NIP-52 calendar events
|
# Subscribe to NIP-52 calendar events
|
||||||
await nostr_client.subscribe(
|
await nostr_client.subscribe([
|
||||||
[
|
|
||||||
{"kinds": [31922, 31923]},
|
{"kinds": [31922, 31923]},
|
||||||
]
|
])
|
||||||
)
|
|
||||||
|
|
||||||
# Process incoming events
|
# Process incoming events
|
||||||
while True:
|
while True:
|
||||||
|
|
@ -155,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
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
"""Monotonic ``created_at`` for replaceable / addressable Nostr events.
|
|
||||||
|
|
||||||
Relays only push a replaceable update to OPEN subscriptions when its
|
|
||||||
``created_at`` is strictly newer than the version they already hold.
|
|
||||||
``created_at`` is integer seconds, so a publisher that stamps
|
|
||||||
``int(time.time())`` can emit two versions within the same wall-clock
|
|
||||||
second (e.g. two ticket sales republishing the NIP-52 calendar event) —
|
|
||||||
the relay treats the second as not-newer and never propagates it to live
|
|
||||||
subscribers (it only surfaces on a reload / fresh REQ).
|
|
||||||
|
|
||||||
Returning ``max(now, last_created_at + 1)`` guarantees a strictly
|
|
||||||
increasing timestamp across successive publishes of the same replaceable
|
|
||||||
event. When enough real seconds have elapsed it tracks wall-clock; only
|
|
||||||
same-second (or clock-skewed) republishes get nudged forward.
|
|
||||||
|
|
||||||
Mirrors the webapp's ``monotonicCreatedAt`` (src/lib/nostr/timestamp.ts)
|
|
||||||
and ``docs/nostr-patterns/replaceable-events.md``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
def monotonic_created_at(last_created_at: int | None, now: int | None = None) -> int:
|
|
||||||
"""Strictly-newer ``created_at`` for the next publish of a coord.
|
|
||||||
|
|
||||||
:param last_created_at: ``created_at`` of the previously published
|
|
||||||
version (seconds), or ``None`` if none has been published yet.
|
|
||||||
:param now: Current time in seconds — injectable for tests; defaults
|
|
||||||
to ``int(time.time())``.
|
|
||||||
"""
|
|
||||||
base = int(time.time()) if now is None else now
|
|
||||||
if last_created_at is None:
|
|
||||||
return base
|
|
||||||
return max(base, last_created_at + 1)
|
|
||||||
117
promo.py
117
promo.py
|
|
@ -1,117 +0,0 @@
|
||||||
"""Promo-code arithmetic shared by the validate endpoint and the purchase path.
|
|
||||||
|
|
||||||
Pure functions (no DB, no settings) so the number a buyer sees in the
|
|
||||||
"Apply" preview is exactly the number the invoice / Stripe session charges.
|
|
||||||
Field names and the `BasketTotals` shape follow upstream lnbits/events v2
|
|
||||||
(PR #64) so the eventual rebase (#33) merges cleanly; deviations are noted
|
|
||||||
inline.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections import Counter
|
|
||||||
|
|
||||||
from .models import BasketDiscount, BasketTotals, Event, PromoCode, Ticket, TicketWave
|
|
||||||
|
|
||||||
SAT_UNITS = ("sat", "sats")
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_code(raw: str | None) -> str | None:
|
|
||||||
"""Buyer input → stored form (stripped, upper-cased); empty → None."""
|
|
||||||
if raw is None:
|
|
||||||
return None
|
|
||||||
code = raw.strip().upper()
|
|
||||||
return code or None
|
|
||||||
|
|
||||||
|
|
||||||
def find_promo(event: Event, code: str) -> PromoCode | None:
|
|
||||||
return next((pc for pc in event.extra.promo_codes if pc.code == code), None)
|
|
||||||
|
|
||||||
|
|
||||||
def promo_usage(tickets: list[Ticket]) -> dict[str, int]:
|
|
||||||
"""Redemptions per code = PAID tickets carrying it in
|
|
||||||
`extra.applied_promo_code`. Every row counts, so a multi-ticket
|
|
||||||
purchase consumes `quantity` uses (upstream v2 counts one per basket).
|
|
||||||
|
|
||||||
Paid only: pending rows live up to 24 h (`purge_unpaid_tickets`), so
|
|
||||||
counting them would let an abandoned Stripe session lock out the last
|
|
||||||
uses of a limited code for a day. The cost is a bounded overshoot when
|
|
||||||
several buyers pass the check before any of them pays — accepted.
|
|
||||||
"""
|
|
||||||
counter: Counter[str] = Counter()
|
|
||||||
for ticket in tickets:
|
|
||||||
code = ticket.extra.applied_promo_code
|
|
||||||
if ticket.paid and code:
|
|
||||||
counter[code] += 1
|
|
||||||
return dict(counter)
|
|
||||||
|
|
||||||
|
|
||||||
def remaining_uses(promo: PromoCode, used: int) -> int | None:
|
|
||||||
"""None = unlimited (`max_uses` unset / 0)."""
|
|
||||||
if not promo.max_uses:
|
|
||||||
return None
|
|
||||||
return max(promo.max_uses - used, 0)
|
|
||||||
|
|
||||||
|
|
||||||
def round_amount(amount: float, currency: str | None) -> float:
|
|
||||||
"""Sats are integers; fiat is 2 dp. Applied once, at the end, so
|
|
||||||
subtotal - total == discount holds for what is actually charged."""
|
|
||||||
if (currency or "sat").lower() in SAT_UNITS:
|
|
||||||
return float(int(amount))
|
|
||||||
return round(amount, 2)
|
|
||||||
|
|
||||||
|
|
||||||
def basket_totals(
|
|
||||||
event: Event,
|
|
||||||
codes: list[str],
|
|
||||||
quantity: int,
|
|
||||||
usage: dict[str, int],
|
|
||||||
wave: TicketWave,
|
|
||||||
) -> BasketTotals:
|
|
||||||
"""Price `quantity` tickets from `wave` with the first applicable code.
|
|
||||||
|
|
||||||
A code is applicable when it exists, is active, has enough uses left
|
|
||||||
for the whole quantity, and actually saves something. Anything else is
|
|
||||||
simply absent from `discounts_applied` (upstream v2 semantics — the
|
|
||||||
purchase endpoint is where hard errors are raised). Only one code is
|
|
||||||
applied; v2's `combinable` stacking is out of scope here.
|
|
||||||
|
|
||||||
`wave` is the pricing authority, not `event`. Since v1.6.8 a ticket's
|
|
||||||
price and currency belong to the wave it is bought from, and the
|
|
||||||
event-level fields are a derived roll-up of the PRIMARY wave
|
|
||||||
(`sync_event_ticket_waves`) — pricing off `event` would quote and charge
|
|
||||||
the first wave's price to a buyer who picked a later one. It is a
|
|
||||||
required argument rather than an optional override precisely because
|
|
||||||
that failure is silent: both call sites have to name the wave.
|
|
||||||
"""
|
|
||||||
currency = wave.currency or "sat"
|
|
||||||
subtotal = round_amount(wave.price_per_ticket * quantity, currency)
|
|
||||||
totals = BasketTotals(
|
|
||||||
subtotal=subtotal, discount=0, total=subtotal, currency=currency
|
|
||||||
)
|
|
||||||
for raw in codes:
|
|
||||||
code = normalize_code(raw)
|
|
||||||
if not code:
|
|
||||||
continue
|
|
||||||
promo = find_promo(event, code)
|
|
||||||
if not promo or not promo.active:
|
|
||||||
continue
|
|
||||||
remaining = remaining_uses(promo, usage.get(promo.code, 0))
|
|
||||||
if remaining is not None and remaining < quantity:
|
|
||||||
continue
|
|
||||||
total = round_amount(subtotal * (1 - promo.discount_percent / 100), currency)
|
|
||||||
saved = round_amount(subtotal - total, currency)
|
|
||||||
if saved <= 0:
|
|
||||||
continue
|
|
||||||
totals.total = total
|
|
||||||
totals.discount = saved
|
|
||||||
totals.discounts_applied = [
|
|
||||||
BasketDiscount(
|
|
||||||
code=promo.code,
|
|
||||||
discount_percent=promo.discount_percent,
|
|
||||||
discount_fixed=None,
|
|
||||||
amount_saved=saved,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
break
|
|
||||||
return totals
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
254
qr.py
254
qr.py
|
|
@ -1,254 +0,0 @@
|
||||||
"""QR + ticket-card rendering shared by the API and the mailer.
|
|
||||||
|
|
||||||
`make_qr_png` is upstream v1.6.8's helper (pyqrcode + Pillow) with the
|
|
||||||
instance QR logo pasted in the centre; `render_ticket_card` wraps it in a
|
|
||||||
self-describing card (event, when, where, name, ticket id) so the PNG a
|
|
||||||
buyer saves from the email still says what it is for.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from datetime import datetime
|
|
||||||
from io import BytesIO
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pyqrcode # type: ignore[import-untyped]
|
|
||||||
from lnbits.settings import settings
|
|
||||||
from loguru import logger
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
|
|
||||||
from .models import Event, Ticket
|
|
||||||
|
|
||||||
_qr_logo_cache: dict[str, Image.Image | None] = {}
|
|
||||||
|
|
||||||
|
|
||||||
async def load_qr_logo() -> Image.Image | None:
|
|
||||||
"""LNbits' "QR Code/Favicon Logo" setting, as a Pillow image (cached).
|
|
||||||
|
|
||||||
Local `/static/...` values resolve inside the LNbits package; absolute
|
|
||||||
URLs are fetched once. Any failure just yields a plain QR.
|
|
||||||
"""
|
|
||||||
source = (settings.lnbits_qr_logo or "").strip()
|
|
||||||
if not source:
|
|
||||||
return None
|
|
||||||
if source in _qr_logo_cache:
|
|
||||||
return _qr_logo_cache[source]
|
|
||||||
logo: Image.Image | None = None
|
|
||||||
try:
|
|
||||||
if source.startswith(("http://", "https://")):
|
|
||||||
async with httpx.AsyncClient(timeout=5) as client:
|
|
||||||
resp = await client.get(source)
|
|
||||||
resp.raise_for_status()
|
|
||||||
logo = Image.open(BytesIO(resp.content)).convert("RGBA")
|
|
||||||
else:
|
|
||||||
local = Path(settings.lnbits_path) / source.lstrip("/")
|
|
||||||
if local.is_file():
|
|
||||||
logo = Image.open(local).convert("RGBA")
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(f"QR logo '{source}' unavailable: {exc}")
|
|
||||||
logo = None
|
|
||||||
_qr_logo_cache[source] = logo
|
|
||||||
return logo
|
|
||||||
|
|
||||||
|
|
||||||
def make_qr_png(
|
|
||||||
data: str,
|
|
||||||
size: int = 235,
|
|
||||||
border: int = 4,
|
|
||||||
logo: Image.Image | None = None,
|
|
||||||
) -> Image.Image:
|
|
||||||
"""Render `data` as a QR image. With `logo`, the code is built at
|
|
||||||
error-correction level H and the logo is pasted in the centre on a white
|
|
||||||
pad at ≤ 20 % of the width — the same look LNbits' client-side
|
|
||||||
`lnbits-qrcode` component produces."""
|
|
||||||
qr = pyqrcode.create(data, error="H" if logo is not None else "M")
|
|
||||||
matrix = qr.code
|
|
||||||
modules = len(matrix)
|
|
||||||
|
|
||||||
total_modules = modules + border * 2
|
|
||||||
box_size = max(1, size // total_modules)
|
|
||||||
img_size = total_modules * box_size
|
|
||||||
|
|
||||||
img = Image.new("RGBA", (img_size, img_size), "white")
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
|
|
||||||
for y, row in enumerate(matrix):
|
|
||||||
for x, cell in enumerate(row):
|
|
||||||
if cell:
|
|
||||||
x0 = (x + border) * box_size
|
|
||||||
y0 = (y + border) * box_size
|
|
||||||
draw.rectangle(
|
|
||||||
[x0, y0, x0 + box_size - 1, y0 + box_size - 1],
|
|
||||||
fill="black",
|
|
||||||
)
|
|
||||||
|
|
||||||
if img_size != size:
|
|
||||||
img = img.resize((size, size), Image.Resampling.NEAREST)
|
|
||||||
|
|
||||||
if logo is not None:
|
|
||||||
logo_size = max(8, int(size * 0.2))
|
|
||||||
pad = max(2, logo_size // 8)
|
|
||||||
scaled = logo.copy()
|
|
||||||
scaled.thumbnail((logo_size, logo_size), Image.Resampling.LANCZOS)
|
|
||||||
plate = Image.new(
|
|
||||||
"RGBA", (scaled.width + 2 * pad, scaled.height + 2 * pad), "white"
|
|
||||||
)
|
|
||||||
plate.paste(scaled, (pad, pad), scaled)
|
|
||||||
img.paste(
|
|
||||||
plate,
|
|
||||||
((size - plate.width) // 2, (size - plate.height) // 2),
|
|
||||||
plate,
|
|
||||||
)
|
|
||||||
|
|
||||||
return img
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_iso(value: str | None) -> datetime | None:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return datetime.fromisoformat(value)
|
|
||||||
except ValueError:
|
|
||||||
try:
|
|
||||||
return datetime.strptime(value[:10], "%Y-%m-%d")
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def format_event_when(event: Event) -> str:
|
|
||||||
"""'Fri 19 Feb 2027, 16:00 - 20:00' (same day) or a full range; the raw
|
|
||||||
strings when they do not parse."""
|
|
||||||
start = _parse_iso(event.event_start_date)
|
|
||||||
end = _parse_iso(event.event_end_date)
|
|
||||||
if not start:
|
|
||||||
return event.event_start_date or ""
|
|
||||||
has_time = "T" in (event.event_start_date or "")
|
|
||||||
day = start.strftime("%a %d %b %Y")
|
|
||||||
if not end or end == start:
|
|
||||||
return f"{day}, {start.strftime('%H:%M')}" if has_time else day
|
|
||||||
if end.date() == start.date():
|
|
||||||
if has_time:
|
|
||||||
return f"{day}, {start.strftime('%H:%M')} - {end.strftime('%H:%M')}"
|
|
||||||
return day
|
|
||||||
end_day = end.strftime("%a %d %b %Y")
|
|
||||||
if has_time:
|
|
||||||
return f"{day} {start.strftime('%H:%M')} - {end_day} {end.strftime('%H:%M')}"
|
|
||||||
return f"{day} - {end_day}"
|
|
||||||
|
|
||||||
|
|
||||||
def ticket_card_filename(ticket: Ticket, event: Event) -> str:
|
|
||||||
slug = re.sub(r"[^a-z0-9]+", "-", event.name.lower()).strip("-")[:40] or "event"
|
|
||||||
return f"ticket-{slug}-{ticket.id[:8]}.png"
|
|
||||||
|
|
||||||
|
|
||||||
_FONT_DIR = Path(__file__).resolve().parent / "static" / "fonts"
|
|
||||||
|
|
||||||
|
|
||||||
def _font(
|
|
||||||
size: int, bold: bool = False
|
|
||||||
) -> ImageFont.ImageFont | ImageFont.FreeTypeFont:
|
|
||||||
"""DejaVu Sans shipped with the extension (full Latin coverage — the
|
|
||||||
Pillow-bundled default lacks accented glyphs, so 'Château' would render
|
|
||||||
as tofu); Pillow's default is the fallback."""
|
|
||||||
path = _FONT_DIR / ("DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf")
|
|
||||||
try:
|
|
||||||
return ImageFont.truetype(str(path), size)
|
|
||||||
except OSError:
|
|
||||||
try:
|
|
||||||
return ImageFont.load_default(size=size)
|
|
||||||
except Exception: # very old Pillow: bitmap default only
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def _wrap(draw: ImageDraw.ImageDraw, text: str, font, max_width: int) -> list[str]:
|
|
||||||
lines: list[str] = []
|
|
||||||
for paragraph in text.split("\n"):
|
|
||||||
words = paragraph.split()
|
|
||||||
line = ""
|
|
||||||
for word in words:
|
|
||||||
candidate = f"{line} {word}".strip()
|
|
||||||
if draw.textlength(candidate, font=font) <= max_width or not line:
|
|
||||||
line = candidate
|
|
||||||
else:
|
|
||||||
lines.append(line)
|
|
||||||
line = word
|
|
||||||
lines.append(line)
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
def render_ticket_card(
|
|
||||||
ticket: Ticket,
|
|
||||||
event: Event,
|
|
||||||
*,
|
|
||||||
logo: Image.Image | None = None,
|
|
||||||
site_title: str | None = None,
|
|
||||||
width: int = 800,
|
|
||||||
) -> Image.Image:
|
|
||||||
"""A self-describing ticket: header (site), event name, when/where, the
|
|
||||||
QR, then name on ticket + ticket id + door instruction."""
|
|
||||||
pad = 48
|
|
||||||
inner = width - 2 * pad
|
|
||||||
title_font = _font(40, bold=True)
|
|
||||||
body_font = _font(28)
|
|
||||||
small_font = _font(22)
|
|
||||||
mono_font = _font(24)
|
|
||||||
|
|
||||||
# Measure first: the card grows with the wrapped title.
|
|
||||||
probe = ImageDraw.Draw(Image.new("RGB", (width, 10), "white"))
|
|
||||||
title_lines = _wrap(probe, event.name, title_font, inner)
|
|
||||||
when = format_event_when(event)
|
|
||||||
meta_lines = [when] if when else []
|
|
||||||
if event.location:
|
|
||||||
meta_lines += _wrap(probe, event.location, body_font, inner)
|
|
||||||
qr_size = min(inner, 560)
|
|
||||||
detail_lines = []
|
|
||||||
if ticket.name:
|
|
||||||
detail_lines.append(f"Name: {ticket.name}")
|
|
||||||
detail_lines.append(f"Ticket {ticket.id}")
|
|
||||||
|
|
||||||
y = pad
|
|
||||||
y += 30 + 16 # site title line
|
|
||||||
y += len(title_lines) * 52 + 12
|
|
||||||
y += len(meta_lines) * 36 + 28
|
|
||||||
qr_y = y
|
|
||||||
y += qr_size + 28
|
|
||||||
y += len(detail_lines) * 36 + 12
|
|
||||||
y += 30 + pad # footer
|
|
||||||
height = y
|
|
||||||
|
|
||||||
img = Image.new("RGB", (width, height), "white")
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
grey = (110, 110, 110)
|
|
||||||
black = (20, 20, 20)
|
|
||||||
|
|
||||||
y = pad
|
|
||||||
draw.text((pad, y), (site_title or "Ticket").upper(), fill=grey, font=small_font)
|
|
||||||
y += 30 + 16
|
|
||||||
for line in title_lines:
|
|
||||||
draw.text((pad, y), line, fill=black, font=title_font)
|
|
||||||
y += 52
|
|
||||||
y += 12
|
|
||||||
for line in meta_lines:
|
|
||||||
draw.text((pad, y), line, fill=black, font=body_font)
|
|
||||||
y += 36
|
|
||||||
y += 28
|
|
||||||
|
|
||||||
qr = make_qr_png(f"ticket://{ticket.id}", size=qr_size, logo=logo).convert("RGB")
|
|
||||||
img.paste(qr, ((width - qr_size) // 2, qr_y))
|
|
||||||
y = qr_y + qr_size + 28
|
|
||||||
|
|
||||||
for line in detail_lines:
|
|
||||||
font = mono_font if line.startswith("Ticket ") else body_font
|
|
||||||
draw.text((pad, y), line, fill=black, font=font)
|
|
||||||
y += 36
|
|
||||||
y += 12
|
|
||||||
draw.text((pad, y), "Show this QR code at the door.", fill=grey, font=small_font)
|
|
||||||
return img
|
|
||||||
|
|
||||||
|
|
||||||
def image_png_bytes(img: Image.Image) -> bytes:
|
|
||||||
out = BytesIO()
|
|
||||||
img.save(out, format="PNG")
|
|
||||||
return out.getvalue()
|
|
||||||
454
services.py
454
services.py
|
|
@ -1,21 +1,3 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import re
|
|
||||||
import smtplib
|
|
||||||
from asyncio.tasks import create_task
|
|
||||||
from email.mime.image import MIMEImage
|
|
||||||
from email.mime.multipart import MIMEMultipart
|
|
||||||
from email.mime.text import MIMEText
|
|
||||||
from email.utils import formataddr, formatdate, make_msgid
|
|
||||||
from html import escape
|
|
||||||
|
|
||||||
from lnbits.core.models.users import UserNotifications
|
|
||||||
from lnbits.core.services.nostr import send_nostr_dm
|
|
||||||
from lnbits.core.services.notifications import send_user_notification
|
|
||||||
from lnbits.helpers import is_valid_email_address
|
|
||||||
from lnbits.settings import settings
|
|
||||||
from lnbits.utils.nostr import normalize_private_key, normalize_public_key
|
|
||||||
from lnurl import execute
|
from lnurl import execute
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
|
@ -26,459 +8,25 @@ from .crud import (
|
||||||
update_event,
|
update_event,
|
||||||
update_ticket,
|
update_ticket,
|
||||||
)
|
)
|
||||||
from .models import (
|
from .models import Ticket
|
||||||
Event,
|
|
||||||
NotificationDeliveryResult,
|
|
||||||
Ticket,
|
|
||||||
TicketResendResult,
|
|
||||||
ensure_ticket_waves,
|
|
||||||
)
|
|
||||||
from .nostr_hooks import publish_or_delete_nostr_event
|
|
||||||
from .promo import promo_usage
|
|
||||||
from .qr import (
|
|
||||||
format_event_when,
|
|
||||||
image_png_bytes,
|
|
||||||
load_qr_logo,
|
|
||||||
render_ticket_card,
|
|
||||||
ticket_card_filename,
|
|
||||||
)
|
|
||||||
|
|
||||||
DEFAULT_NOSTR_RELAYS = [
|
|
||||||
"wss://relay.damus.io",
|
|
||||||
"wss://relay.primal.net",
|
|
||||||
"wss://relay.nostr.band",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Per-event lock: serializes the counter-update + Nostr republish for a
|
|
||||||
# single event_id so two paid invoices landing on the listener queue back-
|
|
||||||
# to-back can't reorder the published state. Lazy-populated; entries are
|
|
||||||
# left in memory for the lifetime of the process (cheap — one asyncio.Lock
|
|
||||||
# object per event ever sold).
|
|
||||||
_event_paid_locks: dict[str, asyncio.Lock] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _event_paid_lock(event_id: str) -> asyncio.Lock:
|
|
||||||
lock = _event_paid_locks.get(event_id)
|
|
||||||
if lock is None:
|
|
||||||
lock = asyncio.Lock()
|
|
||||||
_event_paid_locks[event_id] = lock
|
|
||||||
return lock
|
|
||||||
|
|
||||||
|
|
||||||
async def set_ticket_paid(ticket: Ticket) -> Ticket:
|
async def set_ticket_paid(ticket: Ticket) -> Ticket:
|
||||||
if ticket.paid:
|
if ticket.paid:
|
||||||
return ticket
|
return ticket
|
||||||
|
|
||||||
async with _event_paid_lock(ticket.event):
|
|
||||||
ticket.paid = True
|
ticket.paid = True
|
||||||
await update_ticket(ticket)
|
await update_ticket(ticket)
|
||||||
|
|
||||||
event = await get_event(ticket.event)
|
event = await get_event(ticket.event)
|
||||||
assert event, "Couldn't get event from ticket being paid"
|
assert event, "Couldn't get event from ticket being paid"
|
||||||
event.sold += 1
|
event.sold += 1
|
||||||
# Debit the wave the buyer actually bought from. v1.6.8 moved
|
|
||||||
# inventory onto waves; the event-level counter is only the
|
|
||||||
# fallback for events that predate them, and is itself a derived
|
|
||||||
# roll-up (`sync_event_ticket_waves`). Upstream's `> 0` guards are
|
|
||||||
# kept — ours decremented unconditionally and could go negative.
|
|
||||||
ticket_waves = event.extra.ticket_waves or []
|
|
||||||
if ticket_waves:
|
|
||||||
selected_wave = next(
|
|
||||||
(
|
|
||||||
wave
|
|
||||||
for wave in ticket_waves
|
|
||||||
if wave.id == ticket.extra.ticket_wave_id
|
|
||||||
),
|
|
||||||
ticket_waves[0],
|
|
||||||
)
|
|
||||||
if selected_wave.amount_tickets > 0:
|
|
||||||
selected_wave.amount_tickets -= 1
|
|
||||||
elif event.amount_tickets > 0:
|
|
||||||
event.amount_tickets -= 1
|
event.amount_tickets -= 1
|
||||||
# Flag inside this same write: the counters and "the relay does
|
|
||||||
# not know about them yet" land atomically, so a crash between
|
|
||||||
# here and the publish still leaves the drift discoverable.
|
|
||||||
event.nostr_publish_pending = True
|
|
||||||
await update_event(event)
|
await update_event(event)
|
||||||
|
|
||||||
# Republish the NIP-52 calendar event so connected clients see
|
|
||||||
# the new tickets_available / tickets_sold counters via their
|
|
||||||
# existing relay subscription. Failures are logged + swallowed
|
|
||||||
# inside publish_or_delete_nostr_event so a Nostr outage doesn't
|
|
||||||
# break the payment flow.
|
|
||||||
await publish_or_delete_nostr_event(event)
|
|
||||||
|
|
||||||
return ticket
|
return ticket
|
||||||
|
|
||||||
|
|
||||||
async def event_promo_usage(event_id: str) -> dict[str, int]:
|
|
||||||
"""Paid redemptions per promo code for one event (see promo.promo_usage)."""
|
|
||||||
return promo_usage(await get_event_tickets(event_id))
|
|
||||||
|
|
||||||
|
|
||||||
async def hydrate_promo_usage(event: Event) -> Event:
|
|
||||||
"""Fill `used_count` on each of the event's promo codes. No query when
|
|
||||||
the event has no codes, so listing stays cheap."""
|
|
||||||
if not event.extra.promo_codes:
|
|
||||||
return event
|
|
||||||
usage = await event_promo_usage(event.id)
|
|
||||||
for promo in event.extra.promo_codes:
|
|
||||||
promo.used_count = usage.get(promo.code, 0)
|
|
||||||
return event
|
|
||||||
|
|
||||||
|
|
||||||
def send_ticket_notification_in_background(ticket: Ticket) -> None:
|
|
||||||
create_task(_send_ticket_notification(ticket))
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_ticket_notification(ticket: Ticket) -> None:
|
|
||||||
event = await get_event(ticket.event)
|
|
||||||
if not event:
|
|
||||||
logger.warning(f"Event {ticket.event} not found for ticket notification.")
|
|
||||||
return
|
|
||||||
|
|
||||||
await _deliver_ticket_notifications(ticket, event)
|
|
||||||
|
|
||||||
|
|
||||||
async def resend_ticket_email_notification(
|
|
||||||
ticket: Ticket, base_url: str | None = None
|
|
||||||
) -> TicketResendResult:
|
|
||||||
"""Organizer-triggered re-delivery of the ticket email. Bypasses the
|
|
||||||
per-event `email_notifications` opt-in (the organizer asked explicitly)
|
|
||||||
but still needs the instance mailer and an address on the ticket.
|
|
||||||
|
|
||||||
`base_url` is upstream v1.6.8's: it re-points the ticket link at the
|
|
||||||
caller's frontend, which matters for us specifically because our
|
|
||||||
`ticket_base_url` can differ from `lnbits_baseurl` (separate web app).
|
|
||||||
"""
|
|
||||||
event = await get_event(ticket.event)
|
|
||||||
if not event:
|
|
||||||
raise ValueError("Event does not exist.")
|
|
||||||
if not settings.lnbits_email_notifications_enabled:
|
|
||||||
raise ValueError("Email notifications are not enabled.")
|
|
||||||
if not ticket.email:
|
|
||||||
raise ValueError("Ticket does not have an email address.")
|
|
||||||
if base_url:
|
|
||||||
ticket.extra.ticket_base_url = base_url.rstrip("/")
|
|
||||||
|
|
||||||
# email-only: this is the *email* resend endpoint. Upstream calls
|
|
||||||
# `_deliver_ticket_notifications(ticket, event)` unqualified, which in
|
|
||||||
# our fork would also fire a Nostr DM the organiser did not ask for.
|
|
||||||
return await _deliver_ticket_notifications(ticket, event, email=True, nostr=False)
|
|
||||||
|
|
||||||
|
|
||||||
def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str]:
|
|
||||||
ticket_url = _ticket_url(ticket)
|
|
||||||
subject = (
|
|
||||||
event.extra.notification_subject.strip()
|
|
||||||
or f"Your ticket for '{event.name}' is ready"
|
|
||||||
)
|
|
||||||
body = (
|
|
||||||
event.extra.notification_body.strip()
|
|
||||||
or f"Your ticket for '{event.name}' is ready."
|
|
||||||
)
|
|
||||||
|
|
||||||
return subject, f"{body}\n\nOpen it here: {ticket_url}"
|
|
||||||
|
|
||||||
|
|
||||||
def _ticket_details(ticket: Ticket, event: Event) -> str:
|
|
||||||
"""Human-readable ticket facts for the email body. Also what keeps the
|
|
||||||
mail from being an image with no words (SpamAssassin HTML_IMAGE_ONLY)."""
|
|
||||||
lines = [f"Event: {event.name}", f"When: {format_event_when(event)}"]
|
|
||||||
if event.location:
|
|
||||||
lines.append(f"Where: {event.location}")
|
|
||||||
if ticket.name:
|
|
||||||
lines.append(f"Name on ticket: {ticket.name}")
|
|
||||||
lines.append(f"Ticket ID: {ticket.id}")
|
|
||||||
lines.append(
|
|
||||||
"Your ticket (with its QR code) is attached to this email — save it "
|
|
||||||
"or open the link above on your phone, and show the QR code at the "
|
|
||||||
"door to be scanned in."
|
|
||||||
)
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) -> str:
|
|
||||||
"""Text part of the ticket mail.
|
|
||||||
|
|
||||||
Carries up to two images, which are NOT the same thing (see the note on
|
|
||||||
`_ticket_card_url` vs `_ticket_image_url`): our rendered QR card, always
|
|
||||||
present, and the organiser's uploaded template, only when the buyer's
|
|
||||||
wave opted into it. They had the same label before the v1.6.8 merge
|
|
||||||
because only one of them existed; now that both can appear the labels
|
|
||||||
have to distinguish them.
|
|
||||||
"""
|
|
||||||
message = (
|
|
||||||
f"{base_message}\n\n{_ticket_details(ticket, event)}"
|
|
||||||
f"\n\nTicket card: {_ticket_card_url(ticket)}"
|
|
||||||
)
|
|
||||||
ticket_image_url = _ticket_image_url(ticket, event)
|
|
||||||
if ticket_image_url:
|
|
||||||
message = f"{message}\n\nTicket image: {ticket_image_url}"
|
|
||||||
return message
|
|
||||||
|
|
||||||
|
|
||||||
def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) -> str:
|
|
||||||
"""HTML twin of the text part.
|
|
||||||
|
|
||||||
Deliberately no <img> for our own ticket card: it travels as an
|
|
||||||
attachment (renders inline in most clients, works offline, and keeps
|
|
||||||
SpamAssassin's HTML_IMAGE_ONLY rules quiet), and its URL becomes a link.
|
|
||||||
The organiser's uploaded ticket image is a remote URL with no attachment
|
|
||||||
to fall back on, so that one does get upstream's <img> — the surrounding
|
|
||||||
ticket details keep the mail from being image-only either way.
|
|
||||||
"""
|
|
||||||
text_message = _ticket_delivery_message(ticket, event, base_message)
|
|
||||||
html = escape(text_message)
|
|
||||||
html = re.sub(
|
|
||||||
r"(https?://[^\s<]+)",
|
|
||||||
lambda m: f'<a href="{m.group(1)}">{m.group(1)}</a>',
|
|
||||||
html,
|
|
||||||
)
|
|
||||||
html_message = f"<p>{html.replace(chr(10), '<br />')}</p>"
|
|
||||||
|
|
||||||
ticket_image_url = _ticket_image_url(ticket, event)
|
|
||||||
if not ticket_image_url:
|
|
||||||
return html_message
|
|
||||||
|
|
||||||
return (
|
|
||||||
f"{html_message}"
|
|
||||||
f'<p><img src="{escape(ticket_image_url, quote=True)}" alt="Ticket image" '
|
|
||||||
'style="max-width: 200px; height: auto;" /></p>'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]:
|
|
||||||
subject, base_message = _ticket_notification_message(ticket, event)
|
|
||||||
text_message = _ticket_delivery_message(ticket, event, base_message)
|
|
||||||
html_message = _ticket_email_html_message(ticket, event, base_message)
|
|
||||||
return subject, text_message, html_message
|
|
||||||
|
|
||||||
|
|
||||||
async def _deliver_ticket_notifications(
|
|
||||||
ticket: Ticket,
|
|
||||||
event: Event,
|
|
||||||
*,
|
|
||||||
email: bool | None = None,
|
|
||||||
nostr: bool | None = None,
|
|
||||||
) -> TicketResendResult:
|
|
||||||
"""Send the ticket by every configured channel and report per-channel
|
|
||||||
outcome (upstream v1.6.8 shape). `email` / `nostr` override the event's
|
|
||||||
opt-ins when not None; the instance-level prerequisites always apply.
|
|
||||||
|
|
||||||
Upstream v1.6.8 guards the nostr branch with a `_supports_nostr_delivery`
|
|
||||||
check that errors with "Only NIP-05 Nostr identifiers are supported."
|
|
||||||
That guard is NOT taken here: it encodes upstream's limitation, not ours.
|
|
||||||
`_send_nostr_ticket_notification` below dispatches NIP-05 identifiers to
|
|
||||||
core's notifier and bare npubs to `send_nostr_dm`, so adopting the guard
|
|
||||||
would silently refuse identifiers we deliver to today — and say so with
|
|
||||||
a message that would be false for this fork.
|
|
||||||
"""
|
|
||||||
subject, text_message, html_message = _ticket_notification_payload(ticket, event)
|
|
||||||
updated = False
|
|
||||||
|
|
||||||
email_wanted = event.extra.email_notifications if email is None else email
|
|
||||||
nostr_wanted = event.extra.nostr_notifications if nostr is None else nostr
|
|
||||||
result = TicketResendResult(
|
|
||||||
ticket=ticket,
|
|
||||||
email=NotificationDeliveryResult(
|
|
||||||
attempted=bool(
|
|
||||||
email_wanted
|
|
||||||
and settings.lnbits_email_notifications_enabled
|
|
||||||
and ticket.email
|
|
||||||
)
|
|
||||||
),
|
|
||||||
nostr=NotificationDeliveryResult(
|
|
||||||
attempted=bool(
|
|
||||||
nostr_wanted
|
|
||||||
and settings.is_nostr_notifications_configured()
|
|
||||||
and ticket.extra.nostr_identifier
|
|
||||||
)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.email.attempted:
|
|
||||||
try:
|
|
||||||
assert ticket.email
|
|
||||||
card = render_ticket_card(
|
|
||||||
ticket,
|
|
||||||
event,
|
|
||||||
logo=await load_qr_logo(),
|
|
||||||
site_title=settings.lnbits_site_title,
|
|
||||||
)
|
|
||||||
await _send_ticket_email_notification(
|
|
||||||
[ticket.email],
|
|
||||||
text_message,
|
|
||||||
subject,
|
|
||||||
html_message,
|
|
||||||
attachments=[
|
|
||||||
(ticket_card_filename(ticket, event), image_png_bytes(card))
|
|
||||||
],
|
|
||||||
)
|
|
||||||
ticket.extra.email_notification_sent = True
|
|
||||||
result.email.sent = True
|
|
||||||
updated = True
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(f"Failed to email ticket {ticket.id}: {exc}")
|
|
||||||
result.email.error = str(exc)
|
|
||||||
|
|
||||||
if result.nostr.attempted:
|
|
||||||
try:
|
|
||||||
identifier = ticket.extra.nostr_identifier
|
|
||||||
assert identifier
|
|
||||||
await _send_nostr_ticket_notification(identifier, text_message)
|
|
||||||
ticket.extra.nostr_notification_sent = True
|
|
||||||
result.nostr.sent = True
|
|
||||||
updated = True
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(f"Failed to send nostr DM for ticket {ticket.id}: {exc}")
|
|
||||||
result.nostr.error = str(exc)
|
|
||||||
|
|
||||||
if updated:
|
|
||||||
result.ticket = await update_ticket(ticket)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_ticket_email_notification(
|
|
||||||
to_emails: list[str],
|
|
||||||
message: str,
|
|
||||||
subject: str,
|
|
||||||
html_message: str | None = None,
|
|
||||||
attachments: list[tuple[str, bytes]] | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Multipart (text + HTML) ticket email through the instance SMTP
|
|
||||||
settings. Core's `send_email_notification` is plain-text only, which is
|
|
||||||
why this lives here (ported from upstream v1.6.8). The blocking smtplib
|
|
||||||
session runs in a worker thread so a slow relay cannot stall the event
|
|
||||||
loop while a batch of tickets settles."""
|
|
||||||
if not settings.lnbits_email_notifications_enabled:
|
|
||||||
raise ValueError("Email notifications are disabled")
|
|
||||||
from_email = settings.lnbits_email_notifications_email
|
|
||||||
if not is_valid_email_address(from_email):
|
|
||||||
raise ValueError(f"Invalid from email address: {from_email}")
|
|
||||||
if not to_emails:
|
|
||||||
raise ValueError("No email addresses provided")
|
|
||||||
for address in to_emails:
|
|
||||||
if not is_valid_email_address(address):
|
|
||||||
raise ValueError(f"Invalid email address: {address}")
|
|
||||||
|
|
||||||
msg = build_ticket_email(
|
|
||||||
from_email, to_emails, subject, message, html_message, attachments
|
|
||||||
)
|
|
||||||
username = settings.lnbits_email_notifications_username or from_email
|
|
||||||
await asyncio.to_thread(
|
|
||||||
_smtp_send,
|
|
||||||
settings.lnbits_email_notifications_server,
|
|
||||||
settings.lnbits_email_notifications_port,
|
|
||||||
username,
|
|
||||||
settings.lnbits_email_notifications_password,
|
|
||||||
from_email,
|
|
||||||
to_emails,
|
|
||||||
msg.as_string(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_ticket_email(
|
|
||||||
from_email: str,
|
|
||||||
to_emails: list[str],
|
|
||||||
subject: str,
|
|
||||||
message: str,
|
|
||||||
html_message: str | None = None,
|
|
||||||
attachments: list[tuple[str, bytes]] | None = None,
|
|
||||||
) -> MIMEMultipart:
|
|
||||||
"""Assemble the ticket email: text + HTML alternatives, PNG attachments
|
|
||||||
(the ticket card), and the headers receivers score on — a Date and a
|
|
||||||
Message-ID (their absence is what SpamAssassin's MISSING_DATE /
|
|
||||||
MISSING_MID flag, and what Gmail/Outlook read as machine-generated) and
|
|
||||||
a display name on From so the sender is not a bare address."""
|
|
||||||
body = MIMEMultipart("alternative")
|
|
||||||
body.attach(MIMEText(message, "plain"))
|
|
||||||
if html_message:
|
|
||||||
body.attach(MIMEText(html_message, "html"))
|
|
||||||
|
|
||||||
if attachments:
|
|
||||||
msg = MIMEMultipart("mixed")
|
|
||||||
msg.attach(body)
|
|
||||||
for filename, data in attachments:
|
|
||||||
part = MIMEImage(data, _subtype="png")
|
|
||||||
part.add_header("Content-Disposition", "attachment", filename=filename)
|
|
||||||
msg.attach(part)
|
|
||||||
else:
|
|
||||||
msg = body
|
|
||||||
|
|
||||||
sender_name = (settings.lnbits_site_title or "").strip() or "Tickets"
|
|
||||||
msg["From"] = formataddr((sender_name, from_email))
|
|
||||||
msg["To"] = ", ".join(to_emails)
|
|
||||||
msg["Subject"] = subject
|
|
||||||
msg["Date"] = formatdate(localtime=True)
|
|
||||||
msg["Message-ID"] = make_msgid(domain=from_email.rsplit("@", 1)[-1])
|
|
||||||
return msg
|
|
||||||
|
|
||||||
|
|
||||||
def _smtp_send(
|
|
||||||
server: str,
|
|
||||||
port: int,
|
|
||||||
username: str,
|
|
||||||
password: str,
|
|
||||||
from_email: str,
|
|
||||||
to_emails: list[str],
|
|
||||||
payload: str,
|
|
||||||
) -> None:
|
|
||||||
with smtplib.SMTP(server, port, timeout=30) as smtp_server:
|
|
||||||
smtp_server.starttls()
|
|
||||||
smtp_server.login(username, password)
|
|
||||||
smtp_server.sendmail(from_email, to_emails, payload)
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_nostr_ticket_notification(identifier: str, message: str) -> None:
|
|
||||||
if "@" in identifier:
|
|
||||||
await send_user_notification(
|
|
||||||
UserNotifications(nostr_identifier=identifier),
|
|
||||||
message,
|
|
||||||
"text_message",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
private_key = normalize_private_key(settings.lnbits_nostr_notifications_private_key)
|
|
||||||
public_key = normalize_public_key(identifier)
|
|
||||||
await send_nostr_dm(private_key, public_key, message, DEFAULT_NOSTR_RELAYS)
|
|
||||||
|
|
||||||
|
|
||||||
def _ticket_url(ticket: Ticket) -> str:
|
|
||||||
base_url = (ticket.extra.ticket_base_url or settings.lnbits_baseurl).rstrip("/")
|
|
||||||
return f"{base_url}/events/ticket/{ticket.id}"
|
|
||||||
|
|
||||||
|
|
||||||
def _ticket_card_url(ticket: Ticket) -> str:
|
|
||||||
"""Our rendered ticket-card PNG — always available, and served by THIS
|
|
||||||
extension on the LNbits host, so it is built from `lnbits_baseurl` even
|
|
||||||
when `ticket_base_url` points at a separate web app (deviation from
|
|
||||||
upstream, which assumes both are the same host)."""
|
|
||||||
return (
|
|
||||||
f"{settings.lnbits_baseurl.rstrip('/')}/events/api/v1/ticket-card/{ticket.id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _ticket_image_url(ticket: Ticket, event: Event) -> str | None:
|
|
||||||
"""Upstream's organiser-uploaded ticket template, opted into per wave.
|
|
||||||
|
|
||||||
Distinct from `_ticket_card_url`: that is our own rendered card and is
|
|
||||||
always attached, this is a template the organiser uploads and enables
|
|
||||||
on a specific wave. They shared a name before the v1.6.8 merge, which
|
|
||||||
made them look like one feature.
|
|
||||||
"""
|
|
||||||
waves = ensure_ticket_waves(event)
|
|
||||||
wave = next(
|
|
||||||
(wave for wave in waves if wave.id == ticket.extra.ticket_wave_id),
|
|
||||||
waves[0],
|
|
||||||
)
|
|
||||||
if not wave.use_ticket_image:
|
|
||||||
return None
|
|
||||||
|
|
||||||
base_url = (ticket.extra.ticket_base_url or settings.lnbits_baseurl).rstrip("/")
|
|
||||||
return f"{base_url}/events/api/v1/qr/{ticket.id}"
|
|
||||||
|
|
||||||
|
|
||||||
async def refund_tickets(event_id: str):
|
async def refund_tickets(event_id: str):
|
||||||
"""
|
"""
|
||||||
Refund tickets for an event that has not met the minimum ticket requirement.
|
Refund tickets for an event that has not met the minimum ticket requirement.
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -1,187 +0,0 @@
|
||||||
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
|
|
||||||
Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below)
|
|
||||||
|
|
||||||
|
|
||||||
Bitstream Vera Fonts Copyright
|
|
||||||
------------------------------
|
|
||||||
|
|
||||||
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is
|
|
||||||
a trademark of Bitstream, Inc.
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of the fonts accompanying this license ("Fonts") and associated
|
|
||||||
documentation files (the "Font Software"), to reproduce and distribute the
|
|
||||||
Font Software, including without limitation the rights to use, copy, merge,
|
|
||||||
publish, distribute, and/or sell copies of the Font Software, and to permit
|
|
||||||
persons to whom the Font Software is furnished to do so, subject to the
|
|
||||||
following conditions:
|
|
||||||
|
|
||||||
The above copyright and trademark notices and this permission notice shall
|
|
||||||
be included in all copies of one or more of the Font Software typefaces.
|
|
||||||
|
|
||||||
The Font Software may be modified, altered, or added to, and in particular
|
|
||||||
the designs of glyphs or characters in the Fonts may be modified and
|
|
||||||
additional glyphs or characters may be added to the Fonts, only if the fonts
|
|
||||||
are renamed to names not containing either the words "Bitstream" or the word
|
|
||||||
"Vera".
|
|
||||||
|
|
||||||
This License becomes null and void to the extent applicable to Fonts or Font
|
|
||||||
Software that has been modified and is distributed under the "Bitstream
|
|
||||||
Vera" names.
|
|
||||||
|
|
||||||
The Font Software may be sold as part of a larger software package but no
|
|
||||||
copy of one or more of the Font Software typefaces may be sold by itself.
|
|
||||||
|
|
||||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
||||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
|
|
||||||
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
|
|
||||||
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
|
|
||||||
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
|
|
||||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
|
|
||||||
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
|
|
||||||
FONT SOFTWARE.
|
|
||||||
|
|
||||||
Except as contained in this notice, the names of Gnome, the Gnome
|
|
||||||
Foundation, and Bitstream Inc., shall not be used in advertising or
|
|
||||||
otherwise to promote the sale, use or other dealings in this Font Software
|
|
||||||
without prior written authorization from the Gnome Foundation or Bitstream
|
|
||||||
Inc., respectively. For further information, contact: fonts at gnome dot
|
|
||||||
org.
|
|
||||||
|
|
||||||
Arev Fonts Copyright
|
|
||||||
------------------------------
|
|
||||||
|
|
||||||
Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of the fonts accompanying this license ("Fonts") and
|
|
||||||
associated documentation files (the "Font Software"), to reproduce
|
|
||||||
and distribute the modifications to the Bitstream Vera Font Software,
|
|
||||||
including without limitation the rights to use, copy, merge, publish,
|
|
||||||
distribute, and/or sell copies of the Font Software, and to permit
|
|
||||||
persons to whom the Font Software is furnished to do so, subject to
|
|
||||||
the following conditions:
|
|
||||||
|
|
||||||
The above copyright and trademark notices and this permission notice
|
|
||||||
shall be included in all copies of one or more of the Font Software
|
|
||||||
typefaces.
|
|
||||||
|
|
||||||
The Font Software may be modified, altered, or added to, and in
|
|
||||||
particular the designs of glyphs or characters in the Fonts may be
|
|
||||||
modified and additional glyphs or characters may be added to the
|
|
||||||
Fonts, only if the fonts are renamed to names not containing either
|
|
||||||
the words "Tavmjong Bah" or the word "Arev".
|
|
||||||
|
|
||||||
This License becomes null and void to the extent applicable to Fonts
|
|
||||||
or Font Software that has been modified and is distributed under the
|
|
||||||
"Tavmjong Bah Arev" names.
|
|
||||||
|
|
||||||
The Font Software may be sold as part of a larger software package but
|
|
||||||
no copy of one or more of the Font Software typefaces may be sold by
|
|
||||||
itself.
|
|
||||||
|
|
||||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
||||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL
|
|
||||||
TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
||||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
||||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
||||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
||||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
||||||
|
|
||||||
Except as contained in this notice, the name of Tavmjong Bah shall not
|
|
||||||
be used in advertising or otherwise to promote the sale, use or other
|
|
||||||
dealings in this Font Software without prior written authorization
|
|
||||||
from Tavmjong Bah. For further information, contact: tavmjong @ free
|
|
||||||
. fr.
|
|
||||||
|
|
||||||
TeX Gyre DJV Math
|
|
||||||
-----------------
|
|
||||||
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
|
|
||||||
|
|
||||||
Math extensions done by B. Jackowski, P. Strzelczyk and P. Pianowski
|
|
||||||
(on behalf of TeX users groups) are in public domain.
|
|
||||||
|
|
||||||
Letters imported from Euler Fraktur from AMSfonts are (c) American
|
|
||||||
Mathematical Society (see below).
|
|
||||||
Bitstream Vera Fonts Copyright
|
|
||||||
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera
|
|
||||||
is a trademark of Bitstream, Inc.
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of the fonts accompanying this license (“Fonts”) and associated
|
|
||||||
documentation
|
|
||||||
files (the “Font Software”), to reproduce and distribute the Font Software,
|
|
||||||
including without limitation the rights to use, copy, merge, publish,
|
|
||||||
distribute,
|
|
||||||
and/or sell copies of the Font Software, and to permit persons to whom
|
|
||||||
the Font Software is furnished to do so, subject to the following
|
|
||||||
conditions:
|
|
||||||
|
|
||||||
The above copyright and trademark notices and this permission notice
|
|
||||||
shall be
|
|
||||||
included in all copies of one or more of the Font Software typefaces.
|
|
||||||
|
|
||||||
The Font Software may be modified, altered, or added to, and in particular
|
|
||||||
the designs of glyphs or characters in the Fonts may be modified and
|
|
||||||
additional
|
|
||||||
glyphs or characters may be added to the Fonts, only if the fonts are
|
|
||||||
renamed
|
|
||||||
to names not containing either the words “Bitstream” or the word “Vera”.
|
|
||||||
|
|
||||||
This License becomes null and void to the extent applicable to Fonts or
|
|
||||||
Font Software
|
|
||||||
that has been modified and is distributed under the “Bitstream Vera”
|
|
||||||
names.
|
|
||||||
|
|
||||||
The Font Software may be sold as part of a larger software package but
|
|
||||||
no copy
|
|
||||||
of one or more of the Font Software typefaces may be sold by itself.
|
|
||||||
|
|
||||||
THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
||||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
|
|
||||||
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
|
|
||||||
FOUNDATION
|
|
||||||
BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL,
|
|
||||||
SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN
|
|
||||||
ACTION
|
|
||||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR
|
|
||||||
INABILITY TO USE
|
|
||||||
THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
||||||
Except as contained in this notice, the names of GNOME, the GNOME
|
|
||||||
Foundation,
|
|
||||||
and Bitstream Inc., shall not be used in advertising or otherwise to promote
|
|
||||||
the sale, use or other dealings in this Font Software without prior written
|
|
||||||
authorization from the GNOME Foundation or Bitstream Inc., respectively.
|
|
||||||
For further information, contact: fonts at gnome dot org.
|
|
||||||
|
|
||||||
AMSFonts (v. 2.2) copyright
|
|
||||||
|
|
||||||
The PostScript Type 1 implementation of the AMSFonts produced by and
|
|
||||||
previously distributed by Blue Sky Research and Y&Y, Inc. are now freely
|
|
||||||
available for general use. This has been accomplished through the
|
|
||||||
cooperation
|
|
||||||
of a consortium of scientific publishers with Blue Sky Research and Y&Y.
|
|
||||||
Members of this consortium include:
|
|
||||||
|
|
||||||
Elsevier Science IBM Corporation Society for Industrial and Applied
|
|
||||||
Mathematics (SIAM) Springer-Verlag American Mathematical Society (AMS)
|
|
||||||
|
|
||||||
In order to assure the authenticity of these fonts, copyright will be
|
|
||||||
held by
|
|
||||||
the American Mathematical Society. This is not meant to restrict in any way
|
|
||||||
the legitimate use of the fonts, such as (but not limited to) electronic
|
|
||||||
distribution of documents containing these fonts, inclusion of these fonts
|
|
||||||
into other public domain or commercial font collections or computer
|
|
||||||
applications, use of the outline data to create derivative fonts and/or
|
|
||||||
faces, etc. However, the AMS does require that the AMS copyright notice be
|
|
||||||
removed from any derivative versions of the fonts which have been altered in
|
|
||||||
any way. In addition, to ensure the fidelity of TeX documents using Computer
|
|
||||||
Modern fonts, Professor Donald Knuth, creator of the Computer Modern faces,
|
|
||||||
has requested that any alterations which yield different font metrics be
|
|
||||||
given a different name.
|
|
||||||
|
|
||||||
$Id$
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 104 KiB |
|
|
@ -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: {
|
||||||
|
|
@ -11,10 +10,7 @@ window.PageEventsDisplay = {
|
||||||
data: {
|
data: {
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
refund: '',
|
refund: ''
|
||||||
nostr_identifier: '',
|
|
||||||
ticket_wave_id: null,
|
|
||||||
payment_method: 'lightning'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
ticketLink: {
|
ticketLink: {
|
||||||
|
|
@ -26,146 +22,35 @@ window.PageEventsDisplay = {
|
||||||
receive: {
|
receive: {
|
||||||
show: false,
|
show: false,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
paymentReq: null,
|
paymentReq: null
|
||||||
isFiat: false
|
}
|
||||||
},
|
|
||||||
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)
|
||||||
// Default to the first rail the organizer accepts (a card-only event
|
this.banner = event_banner
|
||||||
// must not submit "lightning").
|
this.extra = event_extra
|
||||||
this.formDialog.data.payment_method = this.paymentMethods[0] || 'lightning'
|
this.hasPromoCodes = has_promoCodes
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
formatDescription() {
|
formatDescription() {
|
||||||
return LNbits.utils.convertMarkdown(this.event?.info || '')
|
return LNbits.utils.convertMarkdown(this.info)
|
||||||
},
|
|
||||||
paymentMethods() {
|
|
||||||
// Mirrors `effective_payment_methods` on the backend: an explicit
|
|
||||||
// extra.payment_methods list wins, else Lightning + fiat-if-allow_fiat.
|
|
||||||
// Since v1.6.8 `allow_fiat` is a per-wave flag and `event.allow_fiat`
|
|
||||||
// is only the PRIMARY wave's, so prefer the active wave when there is
|
|
||||||
// one — otherwise a later fiat-enabled wave would not offer fiat.
|
|
||||||
const explicit = this.event?.extra?.payment_methods || []
|
|
||||||
if (explicit.length) return explicit
|
|
||||||
const allowFiat = this.selectedTicketWave
|
|
||||||
? this.selectedTicketWave.allow_fiat
|
|
||||||
: this.event?.allow_fiat
|
|
||||||
return ['lightning', ...(allowFiat ? ['fiat'] : [])]
|
|
||||||
},
|
|
||||||
activeTicketWaves() {
|
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
|
||||||
return (this.event?.extra?.ticket_waves || []).filter(
|
|
||||||
wave =>
|
|
||||||
wave.amount_tickets > 0 &&
|
|
||||||
wave.opening_date <= today &&
|
|
||||||
wave.closing_date >= today
|
|
||||||
)
|
|
||||||
},
|
|
||||||
selectedTicketWave() {
|
|
||||||
return (
|
|
||||||
this.activeTicketWaves.find(
|
|
||||||
wave => wave.id === this.formDialog.data.ticket_wave_id
|
|
||||||
) ||
|
|
||||||
this.activeTicketWaves[0] ||
|
|
||||||
null
|
|
||||||
)
|
|
||||||
},
|
|
||||||
showTicketWaveSelector() {
|
|
||||||
return this.activeTicketWaves.length > 1
|
|
||||||
},
|
|
||||||
allowFiatCheckout() {
|
|
||||||
return this.paymentMethods.includes('fiat')
|
|
||||||
},
|
|
||||||
paymentMethodOptions() {
|
|
||||||
// Options come from `paymentMethods` — the same list the backend
|
|
||||||
// validates against in `effective_payment_methods` — rather than
|
|
||||||
// being re-derived from per-method booleans, so a rail the server
|
|
||||||
// would reject can never be offered. The `|| method` fallback covers
|
|
||||||
// a method with no label yet (on-chain, once #41 lands).
|
|
||||||
const labels = {
|
|
||||||
lightning: 'Lightning',
|
|
||||||
fiat: this.fiatCheckoutLabel
|
|
||||||
}
|
|
||||||
return this.paymentMethods.map(method => ({
|
|
||||||
value: method,
|
|
||||||
label: labels[method] || method
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
fiatCheckoutLabel() {
|
|
||||||
if (!this.allowFiatCheckout) return 'Fiat'
|
|
||||||
const unit = ['sat', 'sats'].includes(
|
|
||||||
(this.selectedTicketWave?.currency || '').toLowerCase()
|
|
||||||
)
|
|
||||||
? this.selectedTicketWave?.fiat_currency
|
|
||||||
: this.selectedTicketWave?.currency
|
|
||||||
return `Fiat (${(unit || 'GBP').toUpperCase()})`
|
|
||||||
},
|
|
||||||
allowEmailNotifications() {
|
|
||||||
return Boolean(this.event?.extra?.email_notifications)
|
|
||||||
},
|
|
||||||
allowNostrNotifications() {
|
|
||||||
return Boolean(this.event?.extra?.nostr_notifications)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async getEvent() {
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'GET',
|
|
||||||
`/events/api/v1/events/${this.eventId}`
|
|
||||||
)
|
|
||||||
const activeWaves = (data.extra?.ticket_waves || []).filter(wave => {
|
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
|
||||||
return (
|
|
||||||
wave.amount_tickets > 0 &&
|
|
||||||
wave.opening_date <= today &&
|
|
||||||
wave.closing_date >= today
|
|
||||||
)
|
|
||||||
})
|
|
||||||
this.formDialog.data.ticket_wave_id =
|
|
||||||
activeWaves.length === 1 ? activeWaves[0].id : null
|
|
||||||
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 = ''
|
||||||
this.formDialog.data.email = ''
|
this.formDialog.data.email = ''
|
||||||
this.formDialog.data.refund = ''
|
this.formDialog.data.refund = ''
|
||||||
this.formDialog.data.nostr_identifier = ''
|
|
||||||
this.formDialog.data.ticket_wave_id =
|
|
||||||
this.activeTicketWaves.length === 1
|
|
||||||
? this.activeTicketWaves[0].id
|
|
||||||
: null
|
|
||||||
this.formDialog.data.promo_code = ''
|
|
||||||
this.formDialog.data.payment_method =
|
|
||||||
this.paymentMethods[0] || 'lightning'
|
|
||||||
},
|
},
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
this.paymentReq = null
|
|
||||||
this.receive = {
|
|
||||||
show: false,
|
|
||||||
status: 'pending',
|
|
||||||
paymentReq: null,
|
|
||||||
isFiat: false
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
nameValidation(val) {
|
nameValidation(val) {
|
||||||
const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g
|
const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g
|
||||||
|
|
@ -178,22 +63,42 @@ 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,
|
||||||
|
promo_code: this.formDialog.data.promo_code || null
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
this.paymentReq = response.data.payment_request
|
||||||
|
this.paymentCheck = response.data.payment_hash
|
||||||
|
|
||||||
|
dismissMsg = Quasar.Notify.create({
|
||||||
|
timeout: 0,
|
||||||
|
message: 'Waiting for payment...'
|
||||||
|
})
|
||||||
|
|
||||||
|
this.receive = {
|
||||||
|
show: true,
|
||||||
|
status: 'pending',
|
||||||
|
paymentReq: this.paymentReq
|
||||||
}
|
}
|
||||||
this.paymentReq = null
|
paymentChecker = setInterval(() => {
|
||||||
|
axios
|
||||||
|
.post(`/events/api/v1/tickets/${event_id}/${this.paymentCheck}`, {
|
||||||
|
event: event_id,
|
||||||
|
event_name: event_name,
|
||||||
|
name: this.formDialog.data.name,
|
||||||
|
email: this.formDialog.data.email
|
||||||
|
})
|
||||||
|
.then(res => {
|
||||||
|
if (res.data.paid) {
|
||||||
|
clearInterval(paymentChecker)
|
||||||
|
dismissMsg()
|
||||||
this.formDialog.data.name = ''
|
this.formDialog.data.name = ''
|
||||||
this.formDialog.data.email = ''
|
this.formDialog.data.email = ''
|
||||||
this.formDialog.data.refund = ''
|
|
||||||
this.formDialog.data.nostr_identifier = ''
|
|
||||||
this.formDialog.data.ticket_wave_id =
|
|
||||||
this.activeTicketWaves.length === 1
|
|
||||||
? this.activeTicketWaves[0].id
|
|
||||||
: null
|
|
||||||
this.formDialog.data.payment_method =
|
|
||||||
this.paymentMethods[0] || 'lightning'
|
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Sent, thank you!',
|
message: 'Sent, thank you!',
|
||||||
|
|
@ -202,98 +107,24 @@ window.PageEventsDisplay = {
|
||||||
this.receive = {
|
this.receive = {
|
||||||
show: false,
|
show: false,
|
||||||
status: 'complete',
|
status: 'complete',
|
||||||
paymentReq: null,
|
paymentReq: null
|
||||||
isFiat: false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.ticketLink = {
|
this.ticketLink = {
|
||||||
show: true,
|
show: true,
|
||||||
data: {
|
data: {
|
||||||
link: `/events/ticket/${paymentHash}`
|
link: `/events/ticket/${res.data.ticket_id}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.open(`/events/ticket/${paymentHash}`, '_blank', 'noopener')
|
|
||||||
},
|
|
||||||
async createInvoice() {
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'POST',
|
|
||||||
`/events/api/v1/tickets/${this.eventId}`,
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
name: this.formDialog.data.name,
|
|
||||||
email: this.formDialog.data.email,
|
|
||||||
ticket_wave_id: this.formDialog.data.ticket_wave_id || null,
|
|
||||||
promo_code: this.formDialog.data.promo_code || null,
|
|
||||||
refund_address: this.formDialog.data.refund || null,
|
|
||||||
nostr_identifier: this.formDialog.data.nostr_identifier || null,
|
|
||||||
// Gate matches the template's (`paymentMethods.length > 1`).
|
|
||||||
// v1.6.8 gated on `showPaymentMethodSelector`
|
|
||||||
// (`allowFiatCheckout || allowOnchain`), a different condition:
|
|
||||||
// where the two disagreed the buyer's visible choice was
|
|
||||||
// silently replaced with 'lightning'.
|
|
||||||
payment_method:
|
|
||||||
this.paymentMethods.length > 1
|
|
||||||
? this.formDialog.data.payment_method
|
|
||||||
: this.paymentMethods[0] || 'lightning'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
const isFiat = Boolean(data.is_fiat)
|
|
||||||
this.paymentReq = isFiat
|
|
||||||
? data.fiat_payment_request || null
|
|
||||||
: data.payment_request
|
|
||||||
this.paymentHash = data.payment_hash
|
|
||||||
|
|
||||||
this.paymentDismissMsg = Quasar.Notify.create({
|
|
||||||
timeout: 0,
|
|
||||||
message: 'Waiting for payment...'
|
|
||||||
})
|
|
||||||
this.receive = {
|
|
||||||
show: true,
|
|
||||||
status: 'pending',
|
|
||||||
paymentReq: this.paymentReq,
|
|
||||||
isFiat
|
|
||||||
}
|
|
||||||
if (isFiat && this.paymentReq) {
|
|
||||||
window.open(this.paymentReq, '_blank', 'noopener')
|
|
||||||
}
|
|
||||||
this.paymentWatcher(this.paymentHash)
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
paymentWatcher(paymentHash) {
|
|
||||||
if (this.paymentWebsocket) {
|
|
||||||
this.paymentWebsocket.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(window.location)
|
|
||||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
||||||
url.pathname = `/events/api/v1/tickets/ws/${paymentHash}`
|
|
||||||
url.search = ''
|
|
||||||
url.hash = ''
|
|
||||||
|
|
||||||
const ws = new WebSocket(url.toString())
|
|
||||||
this.paymentWebsocket = ws
|
|
||||||
|
|
||||||
ws.onmessage = event => {
|
|
||||||
const data = JSON.parse(event.data)
|
|
||||||
if (data.paid === true) {
|
|
||||||
this.paymentSuccess(paymentHash)
|
|
||||||
ws.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ws.onerror = error => {
|
|
||||||
console.error('WebSocket error:', error)
|
|
||||||
}
|
|
||||||
ws.onclose = () => {
|
|
||||||
if (this.paymentWebsocket !== ws) return
|
|
||||||
this.paymentWebsocket = null
|
|
||||||
if (this.receive.show) {
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.receive.show) this.paymentWatcher(paymentHash)
|
window.location.href = `/events/ticket/${res.data.ticket_id}`
|
||||||
}, 3000)
|
}, 5000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(LNbits.utils.notifyApiError)
|
||||||
|
}, 2000)
|
||||||
|
})
|
||||||
|
.catch(LNbits.utils.notifyApiError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,229 +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> — this
|
|
||||||
event is awaiting an admin review and is not yet open for tickets.
|
|
||||||
</q-banner>
|
|
||||||
|
|
||||||
<q-banner
|
|
||||||
v-else-if="event.status === 'rejected'"
|
|
||||||
class="bg-red-2 text-red-10"
|
|
||||||
rounded
|
|
||||||
>
|
|
||||||
<template v-slot:avatar>
|
|
||||||
<q-icon name="block" color="red-10"></q-icon>
|
|
||||||
</template>
|
|
||||||
<span class="text-weight-medium">Not approved</span> — this event
|
|
||||||
was reviewed and is not being published.
|
|
||||||
</q-banner>
|
|
||||||
|
|
||||||
<q-card v-if="event.status === 'approved'" class="q-pa-lg">
|
|
||||||
<q-card-section class="q-pa-none">
|
|
||||||
<h5 class="q-mt-none">Buy Ticket</h5>
|
|
||||||
<q-form @submit="createInvoice()" class="q-gutter-md">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formDialog.data.name"
|
|
||||||
label="Your name "
|
|
||||||
:rules="[val => nameValidation(val)]"
|
|
||||||
></q-input>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-md-6 q-pr-sm">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formDialog.data.email"
|
|
||||||
type="email"
|
|
||||||
:label="
|
|
||||||
allowEmailNotifications
|
|
||||||
? 'Your email (ticket delivery) '
|
|
||||||
: 'Your email '
|
|
||||||
"
|
|
||||||
:rules="[
|
|
||||||
val => !!val || '* Required',
|
|
||||||
val => emailValidation(val)
|
|
||||||
]"
|
|
||||||
lazy-rules
|
|
||||||
></q-input>
|
|
||||||
</div>
|
|
||||||
<div v-if="allowNostrNotifications" class="col-12 col-md-6">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formDialog.data.nostr_identifier"
|
|
||||||
label="(optional) Nostr NIP-05"
|
|
||||||
hint="If provided, we'll DM your ticket link after payment."
|
|
||||||
></q-input>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<q-input
|
|
||||||
v-if="event.extra?.conditional"
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formDialog.data.refund"
|
|
||||||
label="Refund lnadress or LNURL "
|
|
||||||
:rules="[val => !!val || '* Required']"
|
|
||||||
lazy-rules
|
|
||||||
:hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`"
|
|
||||||
></q-input>
|
|
||||||
<q-select
|
|
||||||
v-if="showTicketWaveSelector"
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model="formDialog.data.ticket_wave_id"
|
|
||||||
emit-value
|
|
||||||
map-options
|
|
||||||
label="Ticket wave"
|
|
||||||
:options="
|
|
||||||
activeTicketWaves.map(wave => ({
|
|
||||||
label: `${wave.title} - ${wave.price_per_ticket} ${wave.currency}`,
|
|
||||||
value: wave.id
|
|
||||||
}))
|
|
||||||
"
|
|
||||||
></q-select>
|
|
||||||
<div class="row q-col-gutter-md q-pt-lg items-center">
|
|
||||||
<div v-if="paymentMethods.length > 1" class="col-auto">
|
|
||||||
<q-option-group
|
|
||||||
v-model="formDialog.data.payment_method"
|
|
||||||
inline
|
|
||||||
:options="paymentMethodOptions"
|
|
||||||
></q-option-group>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
:class="
|
|
||||||
paymentMethods.length > 1 ? 'col-12 col-md-3' : 'col-12'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formDialog.data.promo_code"
|
|
||||||
label="(optional) Promo Code "
|
|
||||||
></q-input>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row q-mt-lg">
|
|
||||||
<q-btn
|
|
||||||
unelevated
|
|
||||||
color="primary"
|
|
||||||
:disable="
|
|
||||||
formDialog.data.name == '' ||
|
|
||||||
formDialog.data.email == '' ||
|
|
||||||
(showTicketWaveSelector && !formDialog.data.ticket_wave_id) ||
|
|
||||||
(receive.show && Boolean(paymentReq))
|
|
||||||
"
|
|
||||||
type="submit"
|
|
||||||
>Submit</q-btn
|
|
||||||
>
|
|
||||||
<q-btn @click="resetForm" flat color="grey" class="q-ml-auto"
|
|
||||||
>Clear</q-btn
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</q-form>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
|
|
||||||
<q-card v-show="ticketLink.show" class="q-pa-lg">
|
|
||||||
<div class="text-center q-mb-lg">
|
|
||||||
<q-btn
|
|
||||||
unelevated
|
|
||||||
size="xl"
|
|
||||||
:href="ticketLink.data.link"
|
|
||||||
target="_blank"
|
|
||||||
color="primary"
|
|
||||||
type="a"
|
|
||||||
>Link to your ticket!</q-btn
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<q-dialog v-model="receive.show" position="top" @hide="closeReceiveDialog">
|
|
||||||
<q-card
|
|
||||||
v-if="!receive.paymentReq"
|
|
||||||
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
|
||||||
>
|
|
||||||
</q-card>
|
|
||||||
<q-card
|
|
||||||
v-else-if="receive.isFiat"
|
|
||||||
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
|
||||||
>
|
|
||||||
<div class="text-center q-mb-lg">
|
|
||||||
<div class="text-h6 q-mb-sm">Continue to checkout</div>
|
|
||||||
<div class="text-body2 text-grey-5 q-mb-lg">
|
|
||||||
Your fiat checkout opened in a new tab. If it did not, use the
|
|
||||||
button below.
|
|
||||||
</div>
|
|
||||||
<q-btn
|
|
||||||
unelevated
|
|
||||||
color="primary"
|
|
||||||
type="a"
|
|
||||||
:href="receive.paymentReq"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
Go to checkout
|
|
||||||
</q-btn>
|
|
||||||
</div>
|
|
||||||
<div class="row q-mt-lg">
|
|
||||||
<q-btn
|
|
||||||
outline
|
|
||||||
color="grey"
|
|
||||||
@click="utils.copyText(receive.paymentReq)"
|
|
||||||
>Copy payment link</q-btn
|
|
||||||
>
|
|
||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
|
||||||
</div>
|
|
||||||
</q-card>
|
|
||||||
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
|
||||||
<div class="text-center q-mb-lg">
|
|
||||||
<lnbits-qrcode
|
|
||||||
:href="'lightning:' + receive.paymentReq"
|
|
||||||
:value="'LIGHTNING:' + receive.paymentReq.toUpperCase()"
|
|
||||||
></lnbits-qrcode>
|
|
||||||
</div>
|
|
||||||
<div class="row q-mt-lg">
|
|
||||||
<q-btn
|
|
||||||
outline
|
|
||||||
color="grey"
|
|
||||||
@click="utils.copyText(receive.paymentReq)"
|
|
||||||
>Copy invoice</q-btn
|
|
||||||
>
|
|
||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
|
||||||
</div>
|
|
||||||
</q-card>
|
|
||||||
</q-dialog>
|
|
||||||
</div>
|
|
||||||
<div v-else class="row q-col-gutter-md justify-center">
|
|
||||||
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
|
||||||
<q-card class="q-pa-lg">
|
|
||||||
<q-card-section class="q-pa-none">
|
|
||||||
<h3 class="q-my-none q-pa-lg" v-text="eventErrorLabel"></h3>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
1015
static/js/index.js
1015
static/js/index.js
File diff suppressed because it is too large
Load diff
1186
static/js/index.vue
1186
static/js/index.vue
File diff suppressed because it is too large
Load diff
|
|
@ -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()
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
window.PageEventsTicket = {
|
|
||||||
template: '#page-events-ticket',
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
ticketId: null,
|
|
||||||
ticket: null,
|
|
||||||
printMode: false,
|
|
||||||
qrSrc: ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async printWindow() {
|
|
||||||
this.printMode = true
|
|
||||||
await this.$nextTick()
|
|
||||||
await this.waitForPrintAssets()
|
|
||||||
setTimeout(() => window.print(), 50)
|
|
||||||
},
|
|
||||||
async waitForPrintAssets() {
|
|
||||||
await this.$nextTick()
|
|
||||||
const img = document.querySelector('.ticket-print-qr')
|
|
||||||
if (!img) return
|
|
||||||
if (img.complete && img.naturalWidth > 0) return
|
|
||||||
await new Promise(resolve => {
|
|
||||||
const done = () => resolve()
|
|
||||||
img.addEventListener('load', done, {once: true})
|
|
||||||
img.addEventListener('error', done, {once: true})
|
|
||||||
setTimeout(done, 500)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async created() {
|
|
||||||
this.ticketId = this.$route.params.id
|
|
||||||
this.qrSrc = `/api/v1/qrcode?data=${encodeURIComponent(
|
|
||||||
`ticket://${this.ticketId}`
|
|
||||||
)}`
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'GET',
|
|
||||||
`/events/api/v1/tickets/${this.ticketId}`
|
|
||||||
)
|
|
||||||
this.ticket = data
|
|
||||||
} catch (error) {
|
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
}
|
|
||||||
window.addEventListener('afterprint', () => {
|
|
||||||
this.printMode = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
<template id="page-events-ticket">
|
|
||||||
<div class="row q-col-gutter-md justify-center">
|
|
||||||
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
|
||||||
<q-card class="q-pa-lg">
|
|
||||||
<q-card-section class="q-pa-none">
|
|
||||||
<center>
|
|
||||||
<h3 class="q-my-none">Ticket</h3>
|
|
||||||
<h5 v-if="ticket" v-text="ticket.name" class="q-my-none"></h5>
|
|
||||||
<br />
|
|
||||||
<h5 class="q-my-none">
|
|
||||||
Bookmark, print or screenshot this page,<br />
|
|
||||||
and present it for registration!
|
|
||||||
</h5>
|
|
||||||
<div v-if="ticket" class="row justify-center q-gutter-sm q-mb-md">
|
|
||||||
<q-btn
|
|
||||||
unelevated
|
|
||||||
:color="ticket.paid ? 'positive' : 'negative'"
|
|
||||||
:label="ticket.paid ? 'Paid' : 'Not Paid'"
|
|
||||||
></q-btn>
|
|
||||||
<q-btn
|
|
||||||
unelevated
|
|
||||||
:color="ticket.registered ? 'positive' : 'warning'"
|
|
||||||
:label="ticket.registered ? 'Checked In' : 'Not Checked In'"
|
|
||||||
></q-btn>
|
|
||||||
</div>
|
|
||||||
<lnbits-qrcode
|
|
||||||
:value="`ticket://${ticketId}`"
|
|
||||||
:options="{width: 500}"
|
|
||||||
></lnbits-qrcode>
|
|
||||||
<br />
|
|
||||||
<q-btn @click="printWindow" color="grey">
|
|
||||||
<q-icon left size="3em" name="print"></q-icon> Print
|
|
||||||
</q-btn>
|
|
||||||
</center>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Teleport to="body">
|
|
||||||
<div class="ticket-print-sheet" v-if="printMode">
|
|
||||||
<img class="ticket-print-qr" :src="qrSrc" alt="Ticket QR" v-if="qrSrc" />
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
@media print {
|
|
||||||
@page {
|
|
||||||
size: auto;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
font-size: 12px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
color: black !important;
|
|
||||||
background: white !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
body > * {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-print-sheet {
|
|
||||||
display: flex !important;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100vw;
|
|
||||||
min-height: 100vh;
|
|
||||||
margin: 0 !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
background: white !important;
|
|
||||||
-webkit-print-color-adjust: exact;
|
|
||||||
print-color-adjust: exact;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-print-qr {
|
|
||||||
display: block;
|
|
||||||
width: 320px;
|
|
||||||
height: 320px;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
55
tasks.py
55
tasks.py
|
|
@ -4,24 +4,8 @@ from lnbits.core.models import Payment
|
||||||
from lnbits.tasks import register_invoice_listener
|
from lnbits.tasks import register_invoice_listener
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from .crud import get_ticket, get_tickets_by_payment_hash
|
from .crud import get_ticket
|
||||||
from .models import Ticket
|
from .services import set_ticket_paid
|
||||||
from .services import send_ticket_notification_in_background, set_ticket_paid
|
|
||||||
|
|
||||||
payment_listeners: dict[str, list[asyncio.Queue[Ticket]]] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def register_payment_listener(payment_hash, queue: asyncio.Queue[Ticket]) -> None:
|
|
||||||
if payment_hash not in payment_listeners:
|
|
||||||
payment_listeners[payment_hash] = []
|
|
||||||
payment_listeners[payment_hash].append(queue)
|
|
||||||
|
|
||||||
|
|
||||||
def deregister_payment_listener(payment_hash, queue: asyncio.Queue[Ticket]) -> None:
|
|
||||||
if payment_hash in payment_listeners:
|
|
||||||
payment_listeners[payment_hash].remove(queue)
|
|
||||||
if not payment_listeners[payment_hash]:
|
|
||||||
del payment_listeners[payment_hash]
|
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_paid_invoices():
|
async def wait_for_paid_invoices():
|
||||||
|
|
@ -37,32 +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
|
||||||
|
|
||||||
# Multi-ticket purchases land as N rows sharing this payment_hash;
|
# Check if ticket has either name/email or user_id
|
||||||
# each one needs to be marked paid + counted against capacity, and
|
has_name_email = payment.extra.get("name") and payment.extra.get("email")
|
||||||
# each gets its own buyer notification (mostly a no-op when all
|
has_user_id = payment.extra.get("user_id")
|
||||||
# rows are owned by the same buyer, but cheap and consistent).
|
|
||||||
tickets = await get_tickets_by_payment_hash(payment.payment_hash)
|
|
||||||
if not tickets:
|
|
||||||
# Backstop for any legacy row created before the payment_hash
|
|
||||||
# column was populated by the migration backfill.
|
|
||||||
legacy = await get_ticket(payment.payment_hash)
|
|
||||||
if legacy:
|
|
||||||
tickets = [legacy]
|
|
||||||
|
|
||||||
if not tickets:
|
if not has_name_email and not has_user_id:
|
||||||
logger.warning(f"No tickets for payment {payment.payment_hash}.")
|
logger.warning(f"Ticket {payment.payment_hash} missing name/email or user_id.")
|
||||||
return
|
return
|
||||||
|
|
||||||
paid_tickets: list[Ticket] = []
|
ticket = await get_ticket(payment.payment_hash)
|
||||||
for ticket in tickets:
|
if not ticket:
|
||||||
paid_tickets.append(await set_ticket_paid(ticket))
|
logger.warning(f"Ticket for payment {payment.payment_hash} not found.")
|
||||||
|
return
|
||||||
|
|
||||||
for paid_ticket in paid_tickets:
|
await set_ticket_paid(ticket)
|
||||||
send_ticket_notification_in_background(paid_ticket)
|
|
||||||
|
|
||||||
# Wake up the WebSocket / poll listeners. Forward the first paid
|
|
||||||
# ticket so the existing single-ticket subscribers still work; the
|
|
||||||
# webapp re-fetches all ids via the polling endpoint anyway.
|
|
||||||
if payment_listeners.get(payment.payment_hash):
|
|
||||||
for paid_ticket_queue in payment_listeners[payment.payment_hash]:
|
|
||||||
paid_ticket_queue.put_nowait(paid_tickets[0])
|
|
||||||
|
|
|
||||||
25
templates/events/_api_docs.html
Normal file
25
templates/events/_api_docs.html
Normal 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>
|
||||||
116
templates/events/display.html
Normal file
116
templates/events/display.html
Normal 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 %}
|
||||||
31
templates/events/error.html
Normal file
31
templates/events/error.html
Normal 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
603
templates/events/index.html
Normal 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>
|
||||||
|
—
|
||||||
|
<span v-text="event.info.substring(0, 80)"></span><span v-if="event.info.length > 80">...</span>
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
<span v-text="event.amount_tickets"></span> tickets •
|
||||||
|
<span v-text="event.price_per_ticket"></span> <span v-text="event.currency"></span>
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section side>
|
||||||
|
<div class="row q-gutter-sm">
|
||||||
|
<q-btn
|
||||||
|
dense
|
||||||
|
color="green"
|
||||||
|
icon="check_circle"
|
||||||
|
label="Approve"
|
||||||
|
size="sm"
|
||||||
|
@click="approveEvent(event.id)"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
dense
|
||||||
|
outline
|
||||||
|
color="red"
|
||||||
|
icon="block"
|
||||||
|
label="Reject"
|
||||||
|
size="sm"
|
||||||
|
@click="rejectEvent(event.id)"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col">
|
||||||
|
<h5 class="text-subtitle1 q-my-none">Events</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-btn flat color="grey" @click="exporteventsCSV"
|
||||||
|
>Export to CSV</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:rows="events"
|
||||||
|
row-key="id"
|
||||||
|
:columns="eventsTable.columns"
|
||||||
|
v-model:pagination="eventsTable.pagination"
|
||||||
|
>
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
<q-th 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 %}
|
||||||
84
templates/events/register.html
Normal file
84
templates/events/register.html
Normal 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 %}
|
||||||
39
templates/events/ticket.html
Normal file
39
templates/events/ticket.html
Normal 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
108
tests/test_api.py
Normal 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"
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
"""`amount_tickets` is the remaining count (aiolabs/events#34).
|
|
||||||
|
|
||||||
`set_ticket_paid` decrements it on every sale and `sold` increments, so
|
|
||||||
subtracting `sold` from it removes each sale twice. That made the buyer
|
|
||||||
cap under-report and, once an event passed half its capacity, turned
|
|
||||||
`sold >= amount_tickets` true and declared it sold out with stock left.
|
|
||||||
Measured on aio-demo before the fix: 16 of 24 live events affected,
|
|
||||||
3 already refusing sales while tickets remained.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
from .. import views_api
|
|
||||||
from ..models import CreateTicket, Event
|
|
||||||
|
|
||||||
SENTINEL = object()
|
|
||||||
|
|
||||||
|
|
||||||
def _event(amount_tickets: int, sold: int) -> Event:
|
|
||||||
return Event(
|
|
||||||
id="evt",
|
|
||||||
wallet="w",
|
|
||||||
name="Capacity",
|
|
||||||
info="",
|
|
||||||
closing_date="2030-01-01",
|
|
||||||
event_start_date="2030-01-01",
|
|
||||||
event_end_date="2030-01-02",
|
|
||||||
currency="sat",
|
|
||||||
price_per_ticket=0, # free path, so the guard is all that gates us
|
|
||||||
amount_tickets=amount_tickets,
|
|
||||||
sold=sold,
|
|
||||||
time=datetime.now(timezone.utc),
|
|
||||||
status="approved",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def issued(monkeypatch):
|
|
||||||
"""Past the capacity guard the free path short-circuits to a sentinel."""
|
|
||||||
monkeypatch.setattr(
|
|
||||||
views_api, "_issue_free_tickets", AsyncMock(return_value=SENTINEL)
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
views_api, "_resolve_frontend_root", lambda data, req: "http://x"
|
|
||||||
)
|
|
||||||
return SENTINEL
|
|
||||||
|
|
||||||
|
|
||||||
async def _buy(amount_tickets: int, sold: int, quantity: int, monkeypatch):
|
|
||||||
monkeypatch.setattr(
|
|
||||||
views_api, "get_event", AsyncMock(return_value=_event(amount_tickets, sold))
|
|
||||||
)
|
|
||||||
return await views_api.api_ticket_create(
|
|
||||||
"evt", CreateTicket(user_id="u1", quantity=quantity), SimpleNamespace()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_last_ticket_still_sells(monkeypatch, issued):
|
|
||||||
"""One left, one wanted. Previously refused once sold >= remaining."""
|
|
||||||
assert await _buy(1, 99, 1, monkeypatch) is issued
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sold_out_only_when_actually_empty(monkeypatch, issued):
|
|
||||||
with pytest.raises(HTTPException) as exc:
|
|
||||||
await _buy(0, 100, 1, monkeypatch)
|
|
||||||
assert exc.value.detail == "Event is sold out."
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize("sold", [0, 49, 50, 51, 500])
|
|
||||||
async def test_remaining_alone_decides_availability(monkeypatch, issued, sold):
|
|
||||||
"""The regression proper: with 50 left the event sells, whatever
|
|
||||||
`sold` says. Because remaining + sold is the original capacity,
|
|
||||||
`sold >= amount_tickets` first flips true at the halfway point —
|
|
||||||
sold=50 here — so an event locked itself once half its seats went.
|
|
||||||
The boundary cases (49/50/51) are the ones that matter."""
|
|
||||||
assert await _buy(50, sold, 1, monkeypatch) is issued
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_bulk_order_may_take_everything_left(monkeypatch, issued):
|
|
||||||
assert await _buy(10, 40, 10, monkeypatch) is issued
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_bulk_order_over_capacity_reports_the_true_remainder(monkeypatch, issued):
|
|
||||||
"""3 left, 5 wanted. The message must name the real remainder — it
|
|
||||||
used to say `3 - 40 = -37`. (`CreateTicket.quantity` is capped at 10
|
|
||||||
by the model, so the overshoot is tested within that bound.)"""
|
|
||||||
with pytest.raises(HTTPException) as exc:
|
|
||||||
await _buy(3, 40, 5, monkeypatch)
|
|
||||||
assert exc.value.detail == "Only 3 ticket(s) remaining for this event."
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_walk_an_event_to_capacity(monkeypatch, issued):
|
|
||||||
"""50-seat event, one sale at a time, mirroring set_ticket_paid."""
|
|
||||||
remaining, sold = 50, 0
|
|
||||||
for _ in range(50):
|
|
||||||
assert await _buy(remaining, sold, 1, monkeypatch) is issued
|
|
||||||
remaining, sold = remaining - 1, sold + 1
|
|
||||||
assert (remaining, sold) == (0, 50)
|
|
||||||
with pytest.raises(HTTPException) as exc:
|
|
||||||
await _buy(remaining, sold, 1, monkeypatch)
|
|
||||||
assert exc.value.detail == "Event is sold out."
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from .. import crud
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_ticket_keeps_email_alongside_user_id(monkeypatch):
|
|
||||||
inserted = {}
|
|
||||||
|
|
||||||
async def fake_insert(table, model):
|
|
||||||
inserted["table"] = table
|
|
||||||
inserted["model"] = model
|
|
||||||
|
|
||||||
monkeypatch.setattr(crud.db, "insert", AsyncMock(side_effect=fake_insert))
|
|
||||||
|
|
||||||
ticket = await crud.create_ticket(
|
|
||||||
payment_hash="hash",
|
|
||||||
wallet="w",
|
|
||||||
event="e",
|
|
||||||
name="Ada",
|
|
||||||
email="ada@example.com",
|
|
||||||
user_id="u1",
|
|
||||||
ticket_id="t1",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert inserted["table"] == "events.ticket"
|
|
||||||
assert inserted["model"].user_id == "u1"
|
|
||||||
assert inserted["model"].email == "ada@example.com"
|
|
||||||
assert inserted["model"].name == "Ada"
|
|
||||||
assert ticket.email == "ada@example.com"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_ticket_stores_empty_string_sentinels(monkeypatch):
|
|
||||||
inserted = {}
|
|
||||||
|
|
||||||
async def fake_insert(table, model):
|
|
||||||
inserted["model"] = model
|
|
||||||
|
|
||||||
monkeypatch.setattr(crud.db, "insert", AsyncMock(side_effect=fake_insert))
|
|
||||||
|
|
||||||
await crud.create_ticket(
|
|
||||||
payment_hash="hash", wallet="w", event="e", user_id="u1", ticket_id="t2"
|
|
||||||
)
|
|
||||||
assert inserted["model"].email == ""
|
|
||||||
assert inserted["model"].name == ""
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import HTTPException
|
|
||||||
from lnbits.settings import settings
|
|
||||||
|
|
||||||
from ..models import CreateTicket
|
|
||||||
from ..views_api import _allowed_frontend_origins, _resolve_frontend_root
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def lnbits_settings(monkeypatch):
|
|
||||||
monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/")
|
|
||||||
monkeypatch.setattr(
|
|
||||||
settings, "lnbits_cors_allowed_origins", ["https://app.example"], raising=False
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
settings,
|
|
||||||
"lnbits_custom_frontend_url",
|
|
||||||
"https://Front.Example/login",
|
|
||||||
raising=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _request(base_url: str = "https://lnbits.example/"):
|
|
||||||
return SimpleNamespace(base_url=base_url)
|
|
||||||
|
|
||||||
|
|
||||||
def test_allowlist_collects_every_configured_origin(lnbits_settings):
|
|
||||||
assert _allowed_frontend_origins() == {
|
|
||||||
"https://lnbits.example",
|
|
||||||
"https://app.example",
|
|
||||||
"https://front.example",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_absent_frontend_url_falls_back_to_the_request_host(lnbits_settings):
|
|
||||||
data = CreateTicket(user_id="u1")
|
|
||||||
assert _resolve_frontend_root(data, _request()) == "https://lnbits.example"
|
|
||||||
|
|
||||||
|
|
||||||
def test_allowed_origin_is_returned_without_trailing_slash(lnbits_settings):
|
|
||||||
data = CreateTicket(user_id="u1", frontend_url="https://app.example/events/")
|
|
||||||
assert _resolve_frontend_root(data, _request()) == "https://app.example/events"
|
|
||||||
|
|
||||||
|
|
||||||
def test_unlisted_origin_is_rejected_loudly(lnbits_settings):
|
|
||||||
data = CreateTicket(user_id="u1", frontend_url="https://evil.example/events")
|
|
||||||
with pytest.raises(HTTPException) as exc:
|
|
||||||
_resolve_frontend_root(data, _request())
|
|
||||||
assert exc.value.status_code == 400
|
|
||||||
assert "frontend_url" in exc.value.detail
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
"""The `nostr_publish_pending` marker and its lifecycle.
|
|
||||||
|
|
||||||
Inventory reaches clients only through the republished NIP-52 calendar
|
|
||||||
event. These tests pin the invariant that makes drift recoverable: the
|
|
||||||
flag goes up before every attempt and comes down only on a confirmed
|
|
||||||
success, so every shape of failure — raised, skipped, never attempted —
|
|
||||||
leaves the row queryable by the sweep.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from .. import nostr_hooks, services
|
|
||||||
from ..models import Event, Ticket
|
|
||||||
|
|
||||||
|
|
||||||
def _event(**kwargs) -> Event:
|
|
||||||
defaults = {
|
|
||||||
"id": "evt",
|
|
||||||
"wallet": "w",
|
|
||||||
"name": "Test",
|
|
||||||
"info": "",
|
|
||||||
"closing_date": "2030-01-01",
|
|
||||||
"event_start_date": "2030-01-01",
|
|
||||||
"event_end_date": "2030-01-02",
|
|
||||||
"currency": "sat",
|
|
||||||
"price_per_ticket": 1000,
|
|
||||||
"amount_tickets": 10,
|
|
||||||
"time": datetime.now(timezone.utc),
|
|
||||||
"status": "approved",
|
|
||||||
}
|
|
||||||
defaults.update(kwargs)
|
|
||||||
return Event(**defaults)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def saved(monkeypatch):
|
|
||||||
"""Capture every update_event write so ordering can be asserted."""
|
|
||||||
writes: list[bool] = []
|
|
||||||
|
|
||||||
async def _update(event):
|
|
||||||
writes.append(event.nostr_publish_pending)
|
|
||||||
return event
|
|
||||||
|
|
||||||
monkeypatch.setattr(nostr_hooks, "update_event", _update)
|
|
||||||
return writes
|
|
||||||
|
|
||||||
|
|
||||||
def _signer(monkeypatch, signer):
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"lnbits.core.signers.resolve_for_wallet", AsyncMock(return_value=signer)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _publisher(monkeypatch, result):
|
|
||||||
monkeypatch.setattr(
|
|
||||||
nostr_hooks, "publish_event_to_nostr", AsyncMock(return_value=result)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_success_raises_then_clears_the_flag(monkeypatch, saved):
|
|
||||||
event = _event()
|
|
||||||
_signer(monkeypatch, SimpleNamespace(pubkey="pk"))
|
|
||||||
_publisher(monkeypatch, SimpleNamespace(id="nid", created_at=123))
|
|
||||||
|
|
||||||
assert await nostr_hooks.publish_or_delete_nostr_event(event) is True
|
|
||||||
# Flagged before the attempt, cleared after it — in that order.
|
|
||||||
assert saved == [True, False]
|
|
||||||
assert event.nostr_publish_pending is False
|
|
||||||
assert event.nostr_event_id == "nid"
|
|
||||||
assert event.nostr_event_created_at == 123
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_missing_signer_leaves_the_flag_up(monkeypatch, saved):
|
|
||||||
event = _event()
|
|
||||||
_signer(monkeypatch, None)
|
|
||||||
|
|
||||||
assert await nostr_hooks.publish_or_delete_nostr_event(event) is False
|
|
||||||
assert saved == [True]
|
|
||||||
assert event.nostr_publish_pending is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_publisher_returning_none_leaves_the_flag_up(monkeypatch, saved):
|
|
||||||
"""The no-NostrClient shape: nothing raised, nothing published."""
|
|
||||||
event = _event()
|
|
||||||
_signer(monkeypatch, SimpleNamespace(pubkey="pk"))
|
|
||||||
_publisher(monkeypatch, None)
|
|
||||||
|
|
||||||
assert await nostr_hooks.publish_or_delete_nostr_event(event) is False
|
|
||||||
assert saved == [True]
|
|
||||||
assert event.nostr_publish_pending is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_raised_publish_leaves_the_flag_up(monkeypatch, saved):
|
|
||||||
event = _event()
|
|
||||||
_signer(monkeypatch, SimpleNamespace(pubkey="pk"))
|
|
||||||
monkeypatch.setattr(
|
|
||||||
nostr_hooks,
|
|
||||||
"publish_event_to_nostr",
|
|
||||||
AsyncMock(side_effect=RuntimeError("signer timeout")),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert await nostr_hooks.publish_or_delete_nostr_event(event) is False
|
|
||||||
assert saved == [True]
|
|
||||||
assert event.nostr_publish_pending is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_already_pending_row_is_not_re_flagged(monkeypatch, saved):
|
|
||||||
"""The sweep re-publishing a flagged row writes once, not twice."""
|
|
||||||
event = _event(nostr_publish_pending=True)
|
|
||||||
_signer(monkeypatch, SimpleNamespace(pubkey="pk"))
|
|
||||||
_publisher(monkeypatch, SimpleNamespace(id="nid", created_at=123))
|
|
||||||
|
|
||||||
assert await nostr_hooks.publish_or_delete_nostr_event(event) is True
|
|
||||||
assert saved == [False]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_delete_clears_the_flag_without_touching_the_coordinate(
|
|
||||||
monkeypatch, saved
|
|
||||||
):
|
|
||||||
"""A take-down must not overwrite the id/created_at of the event it
|
|
||||||
just deleted — the kind-5 has its own."""
|
|
||||||
event = _event(nostr_event_id="old", nostr_event_created_at=100)
|
|
||||||
_signer(monkeypatch, SimpleNamespace(pubkey="pk"))
|
|
||||||
_publisher(monkeypatch, SimpleNamespace(id="del", created_at=999))
|
|
||||||
|
|
||||||
assert await nostr_hooks.publish_or_delete_nostr_event(event, delete=True) is True
|
|
||||||
assert event.nostr_publish_pending is False
|
|
||||||
assert event.nostr_event_id == "old"
|
|
||||||
assert event.nostr_event_created_at == 100
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sale_flags_the_event_in_the_same_write(monkeypatch):
|
|
||||||
"""`set_ticket_paid` must flag inside its own update, so the counters
|
|
||||||
and "the relay doesn't know yet" land atomically."""
|
|
||||||
event = _event(sold=4, amount_tickets=6)
|
|
||||||
seen: list[tuple[int, int, bool]] = []
|
|
||||||
|
|
||||||
async def _update_event(ev):
|
|
||||||
seen.append((ev.sold, ev.amount_tickets, ev.nostr_publish_pending))
|
|
||||||
return ev
|
|
||||||
|
|
||||||
monkeypatch.setattr(services, "update_ticket", AsyncMock())
|
|
||||||
monkeypatch.setattr(services, "get_event", AsyncMock(return_value=event))
|
|
||||||
monkeypatch.setattr(services, "update_event", _update_event)
|
|
||||||
monkeypatch.setattr(services, "publish_or_delete_nostr_event", AsyncMock())
|
|
||||||
|
|
||||||
ticket = Ticket(
|
|
||||||
id="t1",
|
|
||||||
wallet="w",
|
|
||||||
event="evt",
|
|
||||||
name="A",
|
|
||||||
email="a@example.com",
|
|
||||||
registered=False,
|
|
||||||
paid=False,
|
|
||||||
time=datetime.now(timezone.utc),
|
|
||||||
reg_timestamp=datetime.now(timezone.utc),
|
|
||||||
)
|
|
||||||
await services.set_ticket_paid(ticket)
|
|
||||||
|
|
||||||
assert seen == [(5, 5, True)]
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
from itertools import pairwise
|
|
||||||
|
|
||||||
from ..nostr_timestamp import monotonic_created_at
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_prior_uses_now():
|
|
||||||
assert monotonic_created_at(None, now=1000) == 1000
|
|
||||||
|
|
||||||
|
|
||||||
def test_same_second_bumps_past_prior():
|
|
||||||
# now == last: a naive int(time.time()) would tie and the relay would
|
|
||||||
# drop the update; we must produce a strictly newer stamp.
|
|
||||||
assert monotonic_created_at(1000, now=1000) == 1001
|
|
||||||
|
|
||||||
|
|
||||||
def test_tracks_wallclock_once_seconds_elapse():
|
|
||||||
assert monotonic_created_at(1000, now=1005) == 1005
|
|
||||||
|
|
||||||
|
|
||||||
def test_steps_past_future_dated_prior():
|
|
||||||
# clock skew / rapid bursts left the stored value ahead of now
|
|
||||||
assert monotonic_created_at(2000, now=1000) == 2001
|
|
||||||
|
|
||||||
|
|
||||||
def test_strictly_increasing_same_second_burst():
|
|
||||||
last = None
|
|
||||||
stamps = []
|
|
||||||
for _ in range(5):
|
|
||||||
last = monotonic_created_at(last, now=1000) # clock frozen at 1000
|
|
||||||
stamps.append(last)
|
|
||||||
assert stamps == [1000, 1001, 1002, 1003, 1004]
|
|
||||||
assert all(b > a for a, b in pairwise(stamps))
|
|
||||||
|
|
@ -1,218 +0,0 @@
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from pydantic import ValidationError
|
|
||||||
|
|
||||||
from ..models import (
|
|
||||||
Event,
|
|
||||||
EventExtra,
|
|
||||||
PromoCode,
|
|
||||||
PublicEvent,
|
|
||||||
Ticket,
|
|
||||||
TicketWave,
|
|
||||||
ensure_ticket_waves,
|
|
||||||
)
|
|
||||||
from ..promo import basket_totals, normalize_code, promo_usage, remaining_uses
|
|
||||||
|
|
||||||
|
|
||||||
def _event(currency="sat", price=1000.0, codes=None) -> Event:
|
|
||||||
return Event(
|
|
||||||
id="evt",
|
|
||||||
wallet="w",
|
|
||||||
name="Test",
|
|
||||||
info="",
|
|
||||||
closing_date="2030-01-01",
|
|
||||||
event_start_date="2030-01-01",
|
|
||||||
event_end_date="2030-01-02",
|
|
||||||
currency=currency,
|
|
||||||
price_per_ticket=price,
|
|
||||||
amount_tickets=10,
|
|
||||||
time=datetime.now(timezone.utc),
|
|
||||||
extra=EventExtra(promo_codes=codes or []),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _wave(event: Event) -> TicketWave:
|
|
||||||
"""Primary wave synthesized from the event's own price/currency.
|
|
||||||
|
|
||||||
These tests exercise promo arithmetic rather than wave selection, so
|
|
||||||
pricing against the primary wave keeps their original meaning.
|
|
||||||
"""
|
|
||||||
return ensure_ticket_waves(event)[0]
|
|
||||||
|
|
||||||
|
|
||||||
def _ticket(code, paid=True) -> Ticket:
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
return Ticket(
|
|
||||||
id=f"t-{code}-{paid}",
|
|
||||||
wallet="w",
|
|
||||||
event="evt",
|
|
||||||
registered=False,
|
|
||||||
paid=paid,
|
|
||||||
time=now,
|
|
||||||
reg_timestamp=now,
|
|
||||||
extra={"applied_promo_code": code},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --- model -----------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_code_is_stripped_and_uppercased():
|
|
||||||
assert PromoCode(code=" half ", discount_percent=50).code == "HALF"
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_code_is_rejected():
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
PromoCode(code=" ", discount_percent=10)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"raw,expected", [(None, None), ("", None), (0, None), ("0", None), (3, 3), ("7", 7)]
|
|
||||||
)
|
|
||||||
def test_max_uses_normalisation(raw, expected):
|
|
||||||
assert PromoCode(code="X", max_uses=raw).max_uses == expected
|
|
||||||
|
|
||||||
|
|
||||||
def test_max_uses_below_one_rejected():
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
PromoCode(code="X", max_uses=-1)
|
|
||||||
|
|
||||||
|
|
||||||
def test_discount_bounds():
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
PromoCode(code="X", discount_percent=101)
|
|
||||||
|
|
||||||
|
|
||||||
def test_public_event_projection_drops_promo_codes():
|
|
||||||
event = _event(codes=[PromoCode(code="SECRET", discount_percent=100)])
|
|
||||||
public = PublicEvent.parse_obj(event.dict()).dict()
|
|
||||||
assert "promo_codes" not in public["extra"]
|
|
||||||
assert public["extra"]["payment_methods"] == []
|
|
||||||
# the full model keeps them
|
|
||||||
assert Event.parse_obj(event.dict()).extra.promo_codes[0].code == "SECRET"
|
|
||||||
|
|
||||||
|
|
||||||
# --- helpers ---------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_code():
|
|
||||||
assert normalize_code(" save20 ") == "SAVE20"
|
|
||||||
assert normalize_code("") is None
|
|
||||||
assert normalize_code(None) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_promo_usage_counts_paid_rows_only_per_ticket():
|
|
||||||
usage = promo_usage(
|
|
||||||
[_ticket("HALF"), _ticket("HALF"), _ticket("HALF", paid=False), _ticket(None)]
|
|
||||||
)
|
|
||||||
assert usage == {"HALF": 2}
|
|
||||||
|
|
||||||
|
|
||||||
def test_remaining_uses():
|
|
||||||
assert remaining_uses(PromoCode(code="X"), 5) is None
|
|
||||||
assert remaining_uses(PromoCode(code="X", max_uses=3), 1) == 2
|
|
||||||
assert remaining_uses(PromoCode(code="X", max_uses=3), 9) == 0
|
|
||||||
|
|
||||||
|
|
||||||
# --- basket_totals -----------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_sat_totals_round_to_whole_sats():
|
|
||||||
event = _event(price=333, codes=[PromoCode(code="OFF15", discount_percent=15)])
|
|
||||||
totals = basket_totals(event, ["off15"], 1, {}, _wave(event))
|
|
||||||
assert (totals.subtotal, totals.total, totals.discount) == (333, 283, 50)
|
|
||||||
assert totals.currency == "sat"
|
|
||||||
assert totals.discounts_applied[0].dict() == {
|
|
||||||
"code": "OFF15",
|
|
||||||
"discount_percent": 15,
|
|
||||||
"discount_fixed": None,
|
|
||||||
"amount_saved": 50,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_fiat_totals_round_to_cents_and_scale_by_quantity():
|
|
||||||
event = _event(
|
|
||||||
currency="EUR",
|
|
||||||
price=19.99,
|
|
||||||
codes=[PromoCode(code="THIRD", discount_percent=33)],
|
|
||||||
)
|
|
||||||
totals = basket_totals(event, ["THIRD"], 3, {}, _wave(event))
|
|
||||||
assert totals.subtotal == 59.97
|
|
||||||
assert totals.total == 40.18
|
|
||||||
assert totals.discount == 19.79
|
|
||||||
assert totals.discount + totals.total == totals.subtotal
|
|
||||||
|
|
||||||
|
|
||||||
def test_first_applicable_code_wins():
|
|
||||||
event = _event(
|
|
||||||
codes=[
|
|
||||||
PromoCode(code="A", discount_percent=10),
|
|
||||||
PromoCode(code="B", discount_percent=50),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
basket_totals(event, ["NOPE", "B", "A"], 1, {}, _wave(event))
|
|
||||||
.discounts_applied[0]
|
|
||||||
.code
|
|
||||||
== "B"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_inactive_unknown_zero_and_exhausted_codes_are_absent():
|
|
||||||
event = _event(
|
|
||||||
codes=[
|
|
||||||
PromoCode(code="OLD", discount_percent=20, active=False),
|
|
||||||
PromoCode(code="ZERO", discount_percent=0),
|
|
||||||
PromoCode(code="TWO", discount_percent=50, max_uses=2),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
for codes, usage, qty in (
|
|
||||||
(["OLD"], {}, 1),
|
|
||||||
(["ZERO"], {}, 1),
|
|
||||||
(["NOPE"], {}, 1),
|
|
||||||
(["TWO"], {"TWO": 2}, 1),
|
|
||||||
(["TWO"], {"TWO": 1}, 2), # not enough left for the whole quantity
|
|
||||||
):
|
|
||||||
totals = basket_totals(event, codes, qty, usage, _wave(event))
|
|
||||||
assert totals.discounts_applied == []
|
|
||||||
assert totals.total == totals.subtotal and totals.discount == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_unlimited_and_partially_used_codes_apply():
|
|
||||||
event = _event(
|
|
||||||
codes=[
|
|
||||||
PromoCode(code="TWO", discount_percent=50, max_uses=2),
|
|
||||||
PromoCode(code="INF", discount_percent=10),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
assert basket_totals(event, ["TWO"], 1, {"TWO": 1}, _wave(event)).total == 500
|
|
||||||
assert basket_totals(event, ["INF"], 10, {"INF": 999}, _wave(event)).total == 9000
|
|
||||||
|
|
||||||
|
|
||||||
def test_full_discount_prices_to_zero():
|
|
||||||
event = _event(codes=[PromoCode(code="FREE", discount_percent=100)])
|
|
||||||
assert basket_totals(event, ["FREE"], 2, {}, _wave(event)).total == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_price_comes_from_the_selected_wave_not_the_event():
|
|
||||||
"""Regression guard for the v1.6.8 merge.
|
|
||||||
|
|
||||||
`sync_event_ticket_waves` makes `event.price_per_ticket` a roll-up of the
|
|
||||||
PRIMARY wave, so pricing off the event charged every buyer the first
|
|
||||||
wave's price no matter which wave they picked.
|
|
||||||
"""
|
|
||||||
event = _event(price=1000.0, codes=[PromoCode(code="HALF", discount_percent=50)])
|
|
||||||
late = TicketWave(
|
|
||||||
id="late",
|
|
||||||
title="Late",
|
|
||||||
opening_date="2030-01-01",
|
|
||||||
closing_date="2030-02-01",
|
|
||||||
currency="sat",
|
|
||||||
price_per_ticket=2500.0,
|
|
||||||
amount_tickets=10,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert basket_totals(event, [], 2, {}, _wave(event)).total == 2000
|
|
||||||
assert basket_totals(event, [], 2, {}, late).total == 5000
|
|
||||||
assert basket_totals(event, ["HALF"], 2, {}, late).total == 2500
|
|
||||||
|
|
@ -1,178 +0,0 @@
|
||||||
from datetime import datetime, timezone
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
from .. import views_api
|
|
||||||
from ..models import CreateTicket, Event, EventExtra, PromoCode, PromoValidateRequest
|
|
||||||
|
|
||||||
|
|
||||||
def _event(codes) -> Event:
|
|
||||||
return Event(
|
|
||||||
id="evt",
|
|
||||||
wallet="w",
|
|
||||||
name="Test",
|
|
||||||
info="",
|
|
||||||
closing_date="2030-01-01",
|
|
||||||
event_start_date="2030-01-01",
|
|
||||||
event_end_date="2030-01-02",
|
|
||||||
currency="sat",
|
|
||||||
price_per_ticket=1000,
|
|
||||||
amount_tickets=10,
|
|
||||||
time=datetime.now(timezone.utc),
|
|
||||||
extra=EventExtra(promo_codes=codes),
|
|
||||||
status="approved",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def event(monkeypatch):
|
|
||||||
ev = _event(
|
|
||||||
[
|
|
||||||
PromoCode(code="HALF", discount_percent=50, max_uses=2),
|
|
||||||
PromoCode(code="OLD", discount_percent=20, active=False),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=ev))
|
|
||||||
monkeypatch.setattr(
|
|
||||||
views_api, "event_promo_usage", AsyncMock(return_value={"HALF": 1})
|
|
||||||
)
|
|
||||||
return ev
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_validate_returns_v2_shaped_totals(event):
|
|
||||||
totals = await views_api.api_validate_promo_codes(
|
|
||||||
"evt", PromoValidateRequest(codes=["half"], quantity=1)
|
|
||||||
)
|
|
||||||
assert totals.dict() == {
|
|
||||||
"subtotal": 1000,
|
|
||||||
"discount": 500,
|
|
||||||
"total": 500,
|
|
||||||
"currency": "sat",
|
|
||||||
"discounts_applied": [
|
|
||||||
{
|
|
||||||
"code": "HALF",
|
|
||||||
"discount_percent": 50,
|
|
||||||
"discount_fixed": None,
|
|
||||||
"amount_saved": 500,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_validate_is_advisory_for_bad_codes(event):
|
|
||||||
for codes, qty in ((["OLD"], 1), (["NOPE"], 1), (["HALF"], 2)):
|
|
||||||
totals = await views_api.api_validate_promo_codes(
|
|
||||||
"evt", PromoValidateRequest(codes=codes, quantity=qty)
|
|
||||||
)
|
|
||||||
assert totals.discounts_applied == [] and totals.total == totals.subtotal
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_validate_unknown_event_is_404(monkeypatch):
|
|
||||||
monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=None))
|
|
||||||
with pytest.raises(HTTPException) as exc:
|
|
||||||
await views_api.api_validate_promo_codes(
|
|
||||||
"nope", PromoValidateRequest(codes=["X"])
|
|
||||||
)
|
|
||||||
assert exc.value.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"code,quantity,detail",
|
|
||||||
[
|
|
||||||
("NOPE", 1, "Invalid promo code."),
|
|
||||||
("old", 1, "Promo code is not active."),
|
|
||||||
("HALF", 2, "Only 1 use(s) left on this promo code."),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def test_purchase_rejects_bad_codes_before_any_invoice(
|
|
||||||
event, monkeypatch, code, quantity, detail
|
|
||||||
):
|
|
||||||
monkeypatch.setattr(
|
|
||||||
views_api,
|
|
||||||
"create_payment_request",
|
|
||||||
AsyncMock(side_effect=AssertionError("must not be called")),
|
|
||||||
)
|
|
||||||
data = CreateTicket(user_id="u1", promo_code=code, quantity=quantity)
|
|
||||||
with pytest.raises(HTTPException) as exc:
|
|
||||||
await views_api.api_ticket_create(
|
|
||||||
"evt", data, SimpleNamespace(base_url="http://lnbits.local/")
|
|
||||||
)
|
|
||||||
assert exc.value.status_code == 400
|
|
||||||
assert exc.value.detail == detail
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_purchase_reports_fully_redeemed(event, monkeypatch):
|
|
||||||
monkeypatch.setattr(
|
|
||||||
views_api, "event_promo_usage", AsyncMock(return_value={"HALF": 2})
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
views_api,
|
|
||||||
"create_payment_request",
|
|
||||||
AsyncMock(side_effect=AssertionError("must not be called")),
|
|
||||||
)
|
|
||||||
with pytest.raises(HTTPException) as exc:
|
|
||||||
await views_api.api_ticket_create(
|
|
||||||
"evt",
|
|
||||||
CreateTicket(user_id="u1", promo_code="HALF"),
|
|
||||||
SimpleNamespace(base_url="http://lnbits.local/"),
|
|
||||||
)
|
|
||||||
assert exc.value.detail == "Promo code has been fully redeemed."
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def update_env(monkeypatch):
|
|
||||||
stored = _event([PromoCode(code="KEEP", discount_percent=10)])
|
|
||||||
monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=stored))
|
|
||||||
monkeypatch.setattr(
|
|
||||||
views_api,
|
|
||||||
"get_settings",
|
|
||||||
AsyncMock(return_value=SimpleNamespace(auto_approve=True)),
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(views_api, "update_event", AsyncMock(side_effect=lambda e: e))
|
|
||||||
monkeypatch.setattr(views_api, "publish_or_delete_nostr_event", AsyncMock())
|
|
||||||
return stored
|
|
||||||
|
|
||||||
|
|
||||||
def _update_payload(extra: dict) -> dict:
|
|
||||||
return {
|
|
||||||
"wallet": "w",
|
|
||||||
"name": "Test",
|
|
||||||
"info": "",
|
|
||||||
"closing_date": "2030-01-01",
|
|
||||||
"event_start_date": "2030-01-01",
|
|
||||||
"event_end_date": "2030-01-02",
|
|
||||||
"amount_tickets": 10,
|
|
||||||
"price_per_ticket": 1000,
|
|
||||||
"extra": extra,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _wallet():
|
|
||||||
return SimpleNamespace(wallet=SimpleNamespace(id="w", user="u1"))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_without_promo_key_keeps_stored_codes(update_env):
|
|
||||||
from ..models import CreateEvent
|
|
||||||
|
|
||||||
data = CreateEvent.parse_obj(_update_payload({"email_notifications": True}))
|
|
||||||
event = await views_api.api_event_update("evt", data, _wallet())
|
|
||||||
assert [pc.code for pc in event.extra.promo_codes] == ["KEEP"]
|
|
||||||
assert event.extra.email_notifications is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_with_empty_promo_list_clears(update_env):
|
|
||||||
from ..models import CreateEvent
|
|
||||||
|
|
||||||
data = CreateEvent.parse_obj(_update_payload({"promo_codes": []}))
|
|
||||||
event = await views_api.api_event_update("evt", data, _wallet())
|
|
||||||
assert event.extra.promo_codes == []
|
|
||||||
|
|
@ -1,195 +0,0 @@
|
||||||
"""The NIP-52 tags describe the ACTIVE ticket wave, not the roll-up (#61).
|
|
||||||
|
|
||||||
Upstream v1.6.8 moved price, currency and inventory onto time-boxed waves
|
|
||||||
and made the event-level fields derived: `price_per_ticket` and `currency`
|
|
||||||
become the PRIMARY (first) wave's, `amount_tickets` the SUM across every
|
|
||||||
wave. Publishing those would advertise the early-bird price after early
|
|
||||||
bird closed and count stock in waves that have not opened yet.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from ..models import (
|
|
||||||
Event,
|
|
||||||
EventExtra,
|
|
||||||
TicketWave,
|
|
||||||
advertised_wave_key,
|
|
||||||
sync_event_ticket_waves,
|
|
||||||
)
|
|
||||||
from ..nostr_publisher import build_nip52_event
|
|
||||||
|
|
||||||
PUBKEY = "a" * 64
|
|
||||||
TODAY = datetime.now(timezone.utc).date()
|
|
||||||
|
|
||||||
|
|
||||||
def _day(offset: int) -> str:
|
|
||||||
return (TODAY + timedelta(days=offset)).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def _wave(
|
|
||||||
wave_id: str,
|
|
||||||
price: float,
|
|
||||||
stock: int,
|
|
||||||
opens: int,
|
|
||||||
closes: int,
|
|
||||||
*,
|
|
||||||
currency: str = "EUR",
|
|
||||||
allow_fiat: bool = False,
|
|
||||||
) -> TicketWave:
|
|
||||||
return TicketWave(
|
|
||||||
id=wave_id,
|
|
||||||
title=wave_id,
|
|
||||||
opening_date=_day(opens),
|
|
||||||
closing_date=_day(closes),
|
|
||||||
currency=currency,
|
|
||||||
price_per_ticket=price,
|
|
||||||
amount_tickets=stock,
|
|
||||||
allow_fiat=allow_fiat,
|
|
||||||
fiat_currency="GBP",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _event(waves: list[TicketWave], sold: int = 0) -> Event:
|
|
||||||
event = Event(
|
|
||||||
id="evt",
|
|
||||||
wallet="w",
|
|
||||||
name="Waves",
|
|
||||||
info="",
|
|
||||||
closing_date=_day(30),
|
|
||||||
event_start_date=_day(30),
|
|
||||||
event_end_date=_day(30),
|
|
||||||
currency="sat",
|
|
||||||
price_per_ticket=0,
|
|
||||||
amount_tickets=0,
|
|
||||||
sold=sold,
|
|
||||||
time=datetime.now(timezone.utc),
|
|
||||||
status="approved",
|
|
||||||
extra=EventExtra(ticket_waves=waves),
|
|
||||||
)
|
|
||||||
# Mirrors the CRUD layer, which syncs on every read and write.
|
|
||||||
return cast(Event, sync_event_ticket_waves(event))
|
|
||||||
|
|
||||||
|
|
||||||
def _tags(event: Event) -> dict[str, str]:
|
|
||||||
return {t[0]: t[1] for t in build_nip52_event(event, PUBKEY).tags if len(t) > 1}
|
|
||||||
|
|
||||||
|
|
||||||
CLOSED_EARLY_BIRD = _wave("early", 10.0, 5, -10, -1)
|
|
||||||
OPEN_REGULAR = _wave("regular", 25.0, 40, 0, 20)
|
|
||||||
UNOPENED_VIP = _wave("vip", 50.0, 10, 5, 25)
|
|
||||||
|
|
||||||
|
|
||||||
def test_closed_wave_price_is_not_advertised():
|
|
||||||
"""The defect this fixes: early bird closed yesterday."""
|
|
||||||
event = _event([CLOSED_EARLY_BIRD, OPEN_REGULAR, UNOPENED_VIP])
|
|
||||||
|
|
||||||
# What the event-level roll-up would have published.
|
|
||||||
assert event.price_per_ticket == 10.0
|
|
||||||
assert event.amount_tickets == 55
|
|
||||||
|
|
||||||
tags = _tags(event)
|
|
||||||
assert tags["tickets_price"] == "25.0"
|
|
||||||
assert tags["tickets_available"] == "40"
|
|
||||||
|
|
||||||
|
|
||||||
def test_unopened_wave_stock_is_not_counted():
|
|
||||||
event = _event([OPEN_REGULAR, UNOPENED_VIP])
|
|
||||||
assert _tags(event)["tickets_available"] == "40"
|
|
||||||
|
|
||||||
|
|
||||||
def test_cheapest_open_wave_wins_when_several_are_open():
|
|
||||||
"""A publisher cannot ask which wave the buyer wants, so it advertises
|
|
||||||
the price a buyer is actually able to obtain."""
|
|
||||||
cheaper = _wave("cheap", 15.0, 3, 0, 10)
|
|
||||||
tags = _tags(_event([OPEN_REGULAR, cheaper]))
|
|
||||||
assert tags["tickets_price"] == "15.0"
|
|
||||||
assert tags["tickets_available"] == "3"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"waves",
|
|
||||||
[
|
|
||||||
pytest.param([CLOSED_EARLY_BIRD], id="all-closed"),
|
|
||||||
pytest.param([UNOPENED_VIP], id="not-yet-open"),
|
|
||||||
pytest.param([_wave("only", 25.0, 0, 0, 20)], id="sold-out"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_no_open_wave_publishes_zero_availability(waves):
|
|
||||||
"""Never omit the tag: omission meant "unlimited" (#34, #62)."""
|
|
||||||
tags = _tags(_event(waves))
|
|
||||||
assert tags["tickets_available"] == "0"
|
|
||||||
|
|
||||||
|
|
||||||
def test_currency_and_fiat_follow_the_advertised_wave():
|
|
||||||
fiat_primary = _wave("a", 10.0, 0, -10, -1, currency="GBP", allow_fiat=True)
|
|
||||||
sats_open = _wave("b", 2500.0, 9, 0, 20, currency="sat", allow_fiat=False)
|
|
||||||
tags = _tags(_event([fiat_primary, sats_open]))
|
|
||||||
|
|
||||||
assert tags["tickets_currency"] == "sat"
|
|
||||||
assert "tickets_allow_fiat" not in tags
|
|
||||||
# Must agree with tickets_allow_fiat — they are the same fact, and a
|
|
||||||
# client rendering a fiat button here would hit a purchase-time refusal.
|
|
||||||
assert tags["tickets_payment_methods"] == "lightning"
|
|
||||||
|
|
||||||
|
|
||||||
def test_payment_methods_offer_fiat_when_the_advertised_wave_accepts_it():
|
|
||||||
tags = _tags(
|
|
||||||
_event(
|
|
||||||
[
|
|
||||||
_wave("a", 10.0, 0, -10, -1, allow_fiat=False),
|
|
||||||
_wave("b", 25.0, 9, 0, 20, allow_fiat=True),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert tags["tickets_allow_fiat"] == "true"
|
|
||||||
assert tags["tickets_payment_methods"] == "lightning,fiat"
|
|
||||||
|
|
||||||
|
|
||||||
def test_sold_stays_event_level():
|
|
||||||
"""`tickets_sold` is the total paid across all waves."""
|
|
||||||
assert _tags(_event([OPEN_REGULAR], sold=7))["tickets_sold"] == "7"
|
|
||||||
|
|
||||||
|
|
||||||
def test_advertised_wave_key_tracks_the_published_wave():
|
|
||||||
assert advertised_wave_key(_event([CLOSED_EARLY_BIRD, OPEN_REGULAR])) == "regular"
|
|
||||||
# Published while nothing is on sale — a real state, distinct from the
|
|
||||||
# NULL that means "never published".
|
|
||||||
assert advertised_wave_key(_event([CLOSED_EARLY_BIRD])) == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_published_rails_drop_fiat_when_the_advertised_wave_cannot_take_it():
|
|
||||||
"""The webapp always sets `extra.payment_methods`, so the explicit-list
|
|
||||||
path is the normal one — and it used to ignore the wave entirely.
|
|
||||||
|
|
||||||
That published `tickets_payment_methods: lightning,fiat` beside an
|
|
||||||
absent `tickets_allow_fiat`, and a card button the purchase endpoint
|
|
||||||
then refused ("Fiat payments are not enabled for this ticket wave").
|
|
||||||
"""
|
|
||||||
event = _event(
|
|
||||||
[
|
|
||||||
_wave("a", 10.0, 0, -10, -1, allow_fiat=True),
|
|
||||||
_wave("b", 25.0, 9, 0, 20, allow_fiat=False),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
event.extra.payment_methods = ["lightning", "fiat"]
|
|
||||||
tags = _tags(event)
|
|
||||||
|
|
||||||
assert "tickets_allow_fiat" not in tags
|
|
||||||
assert tags["tickets_payment_methods"] == "lightning"
|
|
||||||
|
|
||||||
|
|
||||||
def test_published_rails_keep_fiat_when_the_advertised_wave_takes_it():
|
|
||||||
event = _event(
|
|
||||||
[
|
|
||||||
_wave("a", 10.0, 0, -10, -1, allow_fiat=False),
|
|
||||||
_wave("b", 25.0, 9, 0, 20, allow_fiat=True),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
event.extra.payment_methods = ["lightning", "fiat"]
|
|
||||||
tags = _tags(event)
|
|
||||||
|
|
||||||
assert tags["tickets_allow_fiat"] == "true"
|
|
||||||
assert tags["tickets_payment_methods"] == "lightning,fiat"
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
"""`tickets_available` is always published, including zero (#34).
|
|
||||||
|
|
||||||
Omitting the tag used to mean "unlimited", which contradicted every other
|
|
||||||
reader: `api_get_event` and `api_ticket_create` both treat
|
|
||||||
`amount_tickets < 1` as sold out. On aio-demo three zero-capacity events
|
|
||||||
advertised "Unlimited tickets" on the card while the detail endpoint and
|
|
||||||
the purchase both returned 410.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from ..models import Event
|
|
||||||
from ..nostr_publisher import build_nip52_event
|
|
||||||
|
|
||||||
PUBKEY = "a" * 64
|
|
||||||
|
|
||||||
|
|
||||||
def _event(amount_tickets: int, sold: int = 0) -> Event:
|
|
||||||
return Event(
|
|
||||||
id="evt",
|
|
||||||
wallet="w",
|
|
||||||
name="Availability",
|
|
||||||
info="",
|
|
||||||
closing_date="2030-01-01",
|
|
||||||
event_start_date="2030-01-01T18:00",
|
|
||||||
event_end_date="2030-01-01T22:00",
|
|
||||||
currency="sat",
|
|
||||||
price_per_ticket=0,
|
|
||||||
amount_tickets=amount_tickets,
|
|
||||||
sold=sold,
|
|
||||||
time=datetime.now(timezone.utc),
|
|
||||||
status="approved",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _tags(event: Event) -> dict[str, str]:
|
|
||||||
return {t[0]: t[1] for t in build_nip52_event(event, PUBKEY).tags if len(t) > 1}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("amount", [0, 1, 50])
|
|
||||||
def test_tickets_available_is_always_present(amount):
|
|
||||||
assert _tags(_event(amount))["tickets_available"] == str(amount)
|
|
||||||
|
|
||||||
|
|
||||||
def test_zero_capacity_reads_as_sold_out_not_unlimited():
|
|
||||||
"""The regression: an absent tag is what clients render as unlimited."""
|
|
||||||
tags = _tags(_event(0, sold=0))
|
|
||||||
assert "tickets_available" in tags
|
|
||||||
assert tags["tickets_available"] == "0"
|
|
||||||
|
|
||||||
|
|
||||||
def test_sold_out_after_selling_through_still_publishes_zero():
|
|
||||||
assert _tags(_event(0, sold=25))["tickets_available"] == "0"
|
|
||||||
assert _tags(_event(0, sold=25))["tickets_sold"] == "25"
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
"""Publish confirmation against the relay's `OK` (aiolabs/events#56).
|
|
||||||
|
|
||||||
Queueing is not delivery. nostrclient drops an EVENT outright when no
|
|
||||||
relay is connected and answers `OK false`; before this, that reply was
|
|
||||||
discarded and the publish reported success, which once cleared the
|
|
||||||
`nostr_publish_pending` flag on a republish that never left the
|
|
||||||
building.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from ..nostr import nostr_client as nc
|
|
||||||
from ..nostr.event import NostrEvent
|
|
||||||
|
|
||||||
|
|
||||||
def _event(event_id: str = "a" * 64) -> NostrEvent:
|
|
||||||
e = NostrEvent(pubkey="b" * 64, created_at=0, kind=31923)
|
|
||||||
e.id = event_id
|
|
||||||
return e
|
|
||||||
|
|
||||||
|
|
||||||
async def _publish_and_reply(client, event, reply, delay=0.01):
|
|
||||||
"""Start a publish, then feed `reply` through the receive path."""
|
|
||||||
task = asyncio.create_task(client.publish_nostr_event(event))
|
|
||||||
await asyncio.sleep(delay) # let the future register
|
|
||||||
if reply is not None:
|
|
||||||
client.receive_event_queue.put_nowait(reply)
|
|
||||||
consumer = asyncio.create_task(client.get_event())
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
consumer.cancel()
|
|
||||||
return await task
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_accepted_publish_returns_true():
|
|
||||||
client = nc.NostrClient()
|
|
||||||
event = _event()
|
|
||||||
ok = json.dumps(["OK", event.id, True, ""])
|
|
||||||
assert await _publish_and_reply(client, event, ok) is True
|
|
||||||
assert client._pending_oks == {}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_rejected_publish_returns_false():
|
|
||||||
"""The shape that bit us: no relay connected, so nostrclient's
|
|
||||||
router answers `OK false` without the event ever being sent."""
|
|
||||||
client = nc.NostrClient()
|
|
||||||
event = _event()
|
|
||||||
ok = json.dumps(["OK", event.id, False, "error: no relays connected"])
|
|
||||||
assert await _publish_and_reply(client, event, ok) is False
|
|
||||||
assert client._pending_oks == {}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_missing_ok_times_out_as_unconfirmed(monkeypatch):
|
|
||||||
monkeypatch.setattr(nc, "PUBLISH_OK_TIMEOUT_SECONDS", 0.05)
|
|
||||||
client = nc.NostrClient()
|
|
||||||
assert await _publish_and_reply(client, _event(), None) is False
|
|
||||||
assert client._pending_oks == {}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ok_for_a_different_event_does_not_settle_ours(monkeypatch):
|
|
||||||
monkeypatch.setattr(nc, "PUBLISH_OK_TIMEOUT_SECONDS", 0.05)
|
|
||||||
client = nc.NostrClient()
|
|
||||||
other = json.dumps(["OK", "c" * 64, True, ""])
|
|
||||||
assert await _publish_and_reply(client, _event(), other) is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_event_swallows_ok_and_forwards_everything_else():
|
|
||||||
client = nc.NostrClient()
|
|
||||||
client.receive_event_queue.put_nowait(json.dumps(["OK", "d" * 64, True, ""]))
|
|
||||||
forwarded = json.dumps(["EVENT", "sub", {"id": "e" * 64}])
|
|
||||||
client.receive_event_queue.put_nowait(forwarded)
|
|
||||||
assert await client.get_event() == forwarded
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_disconnect_settles_inflight_publishes_immediately(monkeypatch):
|
|
||||||
"""A dropped socket must not leave the caller waiting the full
|
|
||||||
timeout for an OK that can no longer arrive."""
|
|
||||||
monkeypatch.setattr(nc, "PUBLISH_OK_TIMEOUT_SECONDS", 30)
|
|
||||||
client = nc.NostrClient()
|
|
||||||
event = _event()
|
|
||||||
task = asyncio.create_task(client.publish_nostr_event(event))
|
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
|
|
||||||
client.receive_event_queue.put_nowait(ValueError("WebSocket closed"))
|
|
||||||
consumer = asyncio.create_task(client.get_event())
|
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
consumer.cancel()
|
|
||||||
|
|
||||||
assert await asyncio.wait_for(task, 1) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_settle_ok_ignores_non_ok_frames():
|
|
||||||
client = nc.NostrClient()
|
|
||||||
assert client._settle_ok(json.dumps(["EVENT", "sub", {}])) is False
|
|
||||||
assert client._settle_ok(json.dumps(["EOSE", "sub"])) is False
|
|
||||||
assert client._settle_ok("not json") is False
|
|
||||||
assert client._settle_ok(json.dumps(["OK", "f" * 64, True, ""])) is True
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from lnbits.settings import settings
|
|
||||||
|
|
||||||
from ..models import Event, Ticket
|
|
||||||
from ..services import _ticket_notification_payload, build_ticket_email
|
|
||||||
|
|
||||||
|
|
||||||
def _event(**overrides) -> Event:
|
|
||||||
data = {
|
|
||||||
"id": "evt1",
|
|
||||||
"wallet": "w",
|
|
||||||
"name": "Test Event",
|
|
||||||
"info": "",
|
|
||||||
"closing_date": "2030-01-01",
|
|
||||||
"event_start_date": "2030-01-01T16:00:00+01:00",
|
|
||||||
"event_end_date": "2030-01-01T20:00:00+01:00",
|
|
||||||
"location": "The Chateau",
|
|
||||||
"amount_tickets": 10,
|
|
||||||
"price_per_ticket": 5,
|
|
||||||
"time": datetime.now(timezone.utc),
|
|
||||||
}
|
|
||||||
data.update(overrides)
|
|
||||||
return Event(**data)
|
|
||||||
|
|
||||||
|
|
||||||
def _ticket(**overrides) -> Ticket:
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
data = {
|
|
||||||
"id": "tkt1",
|
|
||||||
"wallet": "w",
|
|
||||||
"event": "evt1",
|
|
||||||
"name": "Ada",
|
|
||||||
"email": "ada@example.com",
|
|
||||||
"registered": False,
|
|
||||||
"paid": True,
|
|
||||||
"time": now,
|
|
||||||
"reg_timestamp": now,
|
|
||||||
}
|
|
||||||
data.update(overrides)
|
|
||||||
return Ticket(**data)
|
|
||||||
|
|
||||||
|
|
||||||
def test_email_carries_date_message_id_and_display_name(monkeypatch):
|
|
||||||
monkeypatch.setattr(settings, "lnbits_site_title", "Oyez!")
|
|
||||||
msg = build_ticket_email(
|
|
||||||
"tickets@example.org", ["ada@example.com"], "Subj", "text", "<p>html</p>"
|
|
||||||
)
|
|
||||||
assert msg["Date"]
|
|
||||||
assert msg["Message-ID"].endswith("@example.org>")
|
|
||||||
assert msg["From"] == "Oyez! <tickets@example.org>"
|
|
||||||
parts = [p.get_content_type() for p in msg.get_payload()]
|
|
||||||
assert parts == ["text/plain", "text/html"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_email_attaches_the_ticket_card(monkeypatch):
|
|
||||||
monkeypatch.setattr(settings, "lnbits_site_title", "Oyez!")
|
|
||||||
png = b"\x89PNG\r\n\x1a\n" + b"0" * 32
|
|
||||||
msg = build_ticket_email(
|
|
||||||
"tickets@example.org",
|
|
||||||
["ada@example.com"],
|
|
||||||
"Subj",
|
|
||||||
"text",
|
|
||||||
"<p>html</p>",
|
|
||||||
attachments=[("ticket-test-8auNuuaB.png", png)],
|
|
||||||
)
|
|
||||||
assert msg.get_content_type() == "multipart/mixed"
|
|
||||||
body, attachment = msg.get_payload()
|
|
||||||
assert body.get_content_type() == "multipart/alternative"
|
|
||||||
assert attachment.get_content_type() == "image/png"
|
|
||||||
assert attachment.get_filename() == "ticket-test-8auNuuaB.png"
|
|
||||||
assert attachment.get_payload(decode=True) == png
|
|
||||||
|
|
||||||
|
|
||||||
def test_payload_includes_event_details_and_qr(monkeypatch):
|
|
||||||
monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/")
|
|
||||||
subject, text, html = _ticket_notification_payload(_ticket(), _event())
|
|
||||||
assert "Test Event" in subject
|
|
||||||
for needle in (
|
|
||||||
"Event: Test Event",
|
|
||||||
"Where: The Chateau",
|
|
||||||
"Name on ticket: Ada",
|
|
||||||
"Ticket ID: tkt1",
|
|
||||||
"at the door",
|
|
||||||
):
|
|
||||||
assert needle in text
|
|
||||||
assert "/events/api/v1/ticket-card/tkt1" in text
|
|
||||||
assert "<img" not in html and "Ticket ID: tkt1" in html
|
|
||||||
assert '<a href="https://lnbits.example/events/api/v1/ticket-card/tkt1">' in html
|
|
||||||
|
|
@ -1,219 +0,0 @@
|
||||||
import pytest
|
|
||||||
from pydantic import ValidationError
|
|
||||||
|
|
||||||
from ..models import (
|
|
||||||
CreateEvent,
|
|
||||||
CreateTicket,
|
|
||||||
EventExtra,
|
|
||||||
PromoCode,
|
|
||||||
PublicEvent,
|
|
||||||
PublicEventExtra,
|
|
||||||
TicketWave,
|
|
||||||
effective_payment_methods,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _ticket(**kwargs) -> CreateTicket:
|
|
||||||
return CreateTicket(**kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def test_user_id_only_is_a_valid_identity():
|
|
||||||
assert _ticket(user_id="u1").user_id == "u1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_name_and_email_is_a_valid_guest_identity():
|
|
||||||
ticket = _ticket(name="Guest", email="guest@example.com")
|
|
||||||
assert ticket.user_id is None
|
|
||||||
assert ticket.email == "guest@example.com"
|
|
||||||
|
|
||||||
|
|
||||||
def test_user_id_may_carry_an_email_for_delivery():
|
|
||||||
ticket = _ticket(user_id="u1", email="me@example.com")
|
|
||||||
assert ticket.user_id == "u1"
|
|
||||||
assert ticket.email == "me@example.com"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"kwargs",
|
|
||||||
[
|
|
||||||
{},
|
|
||||||
{"name": "Guest"},
|
|
||||||
{"email": "guest@example.com"},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_missing_identity_is_rejected(kwargs):
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
_ticket(**kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"url,expected",
|
|
||||||
[
|
|
||||||
("https://app.example/events", "https://app.example/events"),
|
|
||||||
("https://app.example/events/", "https://app.example/events"),
|
|
||||||
("http://localhost:5173/", "http://localhost:5173"),
|
|
||||||
(" ", None),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_frontend_url_is_normalised(url, expected):
|
|
||||||
assert _ticket(user_id="u1", frontend_url=url).frontend_url == expected
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"url",
|
|
||||||
[
|
|
||||||
"/events", # relative
|
|
||||||
"ftp://app.example/events",
|
|
||||||
"https://app.example/events?x=1",
|
|
||||||
"https://app.example/events#top",
|
|
||||||
"https://app.example/../events",
|
|
||||||
"https://" + "a" * 520,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_frontend_url_rejects_unsafe_values(url):
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
_ticket(user_id="u1", frontend_url=url)
|
|
||||||
|
|
||||||
|
|
||||||
def _event(**overrides) -> CreateEvent:
|
|
||||||
data = {
|
|
||||||
"wallet": "w",
|
|
||||||
"name": "Test",
|
|
||||||
"info": "",
|
|
||||||
"closing_date": "2030-01-01",
|
|
||||||
"event_start_date": "2030-01-01",
|
|
||||||
"event_end_date": "2030-01-02",
|
|
||||||
"amount_tickets": 10,
|
|
||||||
"price_per_ticket": 5,
|
|
||||||
}
|
|
||||||
data.update(overrides)
|
|
||||||
return CreateEvent(**data)
|
|
||||||
|
|
||||||
|
|
||||||
def test_effective_payment_methods_legacy_rule():
|
|
||||||
assert effective_payment_methods(_event()) == ["lightning"]
|
|
||||||
assert effective_payment_methods(_event(allow_fiat=True)) == ["lightning", "fiat"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_effective_payment_methods_explicit_list_wins():
|
|
||||||
event = _event(allow_fiat=True, extra=EventExtra(payment_methods=["fiat"]))
|
|
||||||
assert effective_payment_methods(event) == ["fiat"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_payment_methods_are_normalised_and_deduplicated():
|
|
||||||
extra = EventExtra(payment_methods=["Fiat", " lightning ", "fiat"])
|
|
||||||
assert extra.payment_methods == ["fiat", "lightning"]
|
|
||||||
assert EventExtra(payment_methods="lightning,fiat").payment_methods == [
|
|
||||||
"lightning",
|
|
||||||
"fiat",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_payment_method_is_rejected():
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
EventExtra(payment_methods=["cash"])
|
|
||||||
|
|
||||||
|
|
||||||
# --- public projection ------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_public_extra_exposes_waves_but_never_promo_codes():
|
|
||||||
"""A buyer needs a wave id to choose a tier, so waves are public; promo
|
|
||||||
codes stay organizer-only (aiolabs/events#61, v1.6.1-aio.12)."""
|
|
||||||
organizer = EventExtra(
|
|
||||||
promo_codes=[PromoCode(code="SECRET", discount_percent=50)],
|
|
||||||
ticket_waves=[
|
|
||||||
TicketWave(
|
|
||||||
id="early",
|
|
||||||
title="Early",
|
|
||||||
opening_date="2030-01-01",
|
|
||||||
closing_date="2030-02-01",
|
|
||||||
currency="sat",
|
|
||||||
price_per_ticket=10,
|
|
||||||
amount_tickets=5,
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
public = PublicEventExtra(**organizer.dict())
|
|
||||||
|
|
||||||
assert [wave.id for wave in public.ticket_waves] == ["early"]
|
|
||||||
assert public.ticket_waves[0].price_per_ticket == 10
|
|
||||||
assert not hasattr(public, "promo_codes")
|
|
||||||
assert "promo_codes" not in public.dict()
|
|
||||||
|
|
||||||
|
|
||||||
def test_public_event_response_carries_waves():
|
|
||||||
"""End of the chain: what `GET /events/{id}` actually serialises."""
|
|
||||||
wave = TicketWave(
|
|
||||||
id="regular",
|
|
||||||
title="Regular",
|
|
||||||
opening_date="2030-01-01",
|
|
||||||
closing_date="2030-02-01",
|
|
||||||
currency="sat",
|
|
||||||
price_per_ticket=25,
|
|
||||||
amount_tickets=40,
|
|
||||||
)
|
|
||||||
organizer_extra = EventExtra(
|
|
||||||
ticket_waves=[wave], promo_codes=[PromoCode(code="SECRET")]
|
|
||||||
)
|
|
||||||
public = PublicEvent(
|
|
||||||
id="evt",
|
|
||||||
name="Test",
|
|
||||||
info="",
|
|
||||||
canceled=False,
|
|
||||||
event_start_date="2030-01-01",
|
|
||||||
currency="sat",
|
|
||||||
price_per_ticket=25,
|
|
||||||
banner=None,
|
|
||||||
extra=PublicEventExtra(**organizer_extra.dict()),
|
|
||||||
)
|
|
||||||
body = public.dict()
|
|
||||||
|
|
||||||
assert [w["id"] for w in body["extra"]["ticket_waves"]] == ["regular"]
|
|
||||||
assert "promo_codes" not in body["extra"]
|
|
||||||
|
|
||||||
|
|
||||||
# --- rails vs per-wave fiat --------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _fiat_wave(allow_fiat: bool):
|
|
||||||
from ..models import TicketWave
|
|
||||||
|
|
||||||
return TicketWave(
|
|
||||||
id="w",
|
|
||||||
title="w",
|
|
||||||
opening_date="2030-01-01",
|
|
||||||
closing_date="2030-02-01",
|
|
||||||
currency="EUR",
|
|
||||||
price_per_ticket=10,
|
|
||||||
amount_tickets=5,
|
|
||||||
allow_fiat=allow_fiat,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_explicit_rails_drop_fiat_for_a_wave_that_cannot_take_it():
|
|
||||||
"""The organiser's rail list is event-level; fiat is per-wave.
|
|
||||||
|
|
||||||
Without this the NIP-52 tag advertises fiat and the checkout renders a
|
|
||||||
card button that `api_ticket_create` refuses with "Fiat payments are
|
|
||||||
not enabled for this ticket wave" (reported on aio-demo).
|
|
||||||
"""
|
|
||||||
event = _event()
|
|
||||||
event.extra.payment_methods = ["lightning", "fiat"]
|
|
||||||
|
|
||||||
assert effective_payment_methods(event, _fiat_wave(True)) == ["lightning", "fiat"]
|
|
||||||
assert effective_payment_methods(event, _fiat_wave(False)) == ["lightning"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_event_level_question_still_reports_every_rail():
|
|
||||||
"""No wave means "what did the organiser enable at all" — unfiltered."""
|
|
||||||
event = _event()
|
|
||||||
event.extra.payment_methods = ["lightning", "fiat"]
|
|
||||||
assert effective_payment_methods(event) == ["lightning", "fiat"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_fiat_only_rails_on_a_non_fiat_wave_leave_nothing_purchasable():
|
|
||||||
event = _event()
|
|
||||||
event.extra.payment_methods = ["fiat"]
|
|
||||||
assert effective_payment_methods(event, _fiat_wave(False)) == []
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
from ..qr import make_qr_png
|
|
||||||
|
|
||||||
|
|
||||||
def test_make_qr_png_renders_requested_size():
|
|
||||||
img = make_qr_png("ticket://abc123", size=200)
|
|
||||||
assert img.size == (200, 200)
|
|
||||||
|
|
||||||
|
|
||||||
def test_make_qr_png_pastes_a_centred_logo():
|
|
||||||
logo = Image.new("RGBA", (64, 64), (255, 0, 0, 255))
|
|
||||||
img = make_qr_png("ticket://abc123", size=300, logo=logo)
|
|
||||||
assert img.size == (300, 300)
|
|
||||||
# The centre pixel is inside the pasted logo, so it is red — a plain
|
|
||||||
# QR would only ever have black or white there.
|
|
||||||
assert img.getpixel((150, 150))[:3] == (255, 0, 0)
|
|
||||||
out = BytesIO()
|
|
||||||
img.save(out, format="PNG")
|
|
||||||
assert out.getvalue().startswith(b"\x89PNG")
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_ticket_card_is_self_describing():
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from ..models import Event, Ticket
|
|
||||||
from ..qr import format_event_when, render_ticket_card, ticket_card_filename
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
event = Event(
|
|
||||||
id="evt1",
|
|
||||||
wallet="w",
|
|
||||||
name="Château du Faune | Uru Ecstatic Dance",
|
|
||||||
info="",
|
|
||||||
closing_date="2027-02-19",
|
|
||||||
event_start_date="2027-02-19T16:00:00+01:00",
|
|
||||||
event_end_date="2027-02-19T20:00:00+01:00",
|
|
||||||
location="The Chateau",
|
|
||||||
amount_tickets=10,
|
|
||||||
price_per_ticket=15,
|
|
||||||
time=now,
|
|
||||||
)
|
|
||||||
ticket = Ticket(
|
|
||||||
id="8auNuuaBv7TFGj7BYoVnTN",
|
|
||||||
wallet="w",
|
|
||||||
event="evt1",
|
|
||||||
name="Guest Test",
|
|
||||||
email="g@example.com",
|
|
||||||
registered=False,
|
|
||||||
paid=True,
|
|
||||||
time=now,
|
|
||||||
reg_timestamp=now,
|
|
||||||
)
|
|
||||||
assert format_event_when(event) == "Fri 19 Feb 2027, 16:00 - 20:00"
|
|
||||||
assert (
|
|
||||||
ticket_card_filename(ticket, event)
|
|
||||||
== "ticket-ch-teau-du-faune-uru-ecstatic-dance-8auNuuaB.png"
|
|
||||||
)
|
|
||||||
card = render_ticket_card(ticket, event, site_title="Oyez!")
|
|
||||||
assert card.width == 800 and card.height > 800
|
|
||||||
# QR area is centred; its finder pattern is black
|
|
||||||
assert card.getpixel((400, card.height // 2)) in ((0, 0, 0), (255, 255, 255))
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
"""Capacity is required per ticket wave (#34, #62).
|
|
||||||
|
|
||||||
"Capacity is always required, there is no unlimited" was settled when
|
|
||||||
capacity was one number on the event. Since v1.6.8 it is per-wave and
|
|
||||||
`event.amount_tickets` is a derived roll-up, so the rule has to bite where
|
|
||||||
the organiser sets it. A zero-capacity wave can never be active
|
|
||||||
(`get_active_ticket_waves` requires `> 0`), so it is the wave-level form of
|
|
||||||
the trap #62 removed: an event that looks on sale but refuses every
|
|
||||||
purchase.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from http import HTTPStatus
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
from ..models import CreateEvent, Event, EventExtra, TicketWave
|
|
||||||
from ..views_api import _validate_wave_capacity
|
|
||||||
|
|
||||||
TODAY = datetime.now(timezone.utc).date()
|
|
||||||
|
|
||||||
|
|
||||||
def _day(offset: int) -> str:
|
|
||||||
return (TODAY + timedelta(days=offset)).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def _wave(wave_id: str, stock: int) -> TicketWave:
|
|
||||||
return TicketWave(
|
|
||||||
id=wave_id,
|
|
||||||
title=wave_id,
|
|
||||||
opening_date=_day(0),
|
|
||||||
closing_date=_day(20),
|
|
||||||
currency="sat",
|
|
||||||
price_per_ticket=10,
|
|
||||||
amount_tickets=stock,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _create(waves=None, amount_tickets=10) -> CreateEvent:
|
|
||||||
return CreateEvent(
|
|
||||||
wallet="w",
|
|
||||||
name="Fete",
|
|
||||||
info="",
|
|
||||||
event_start_date=_day(30),
|
|
||||||
currency="sat",
|
|
||||||
price_per_ticket=10,
|
|
||||||
amount_tickets=amount_tickets,
|
|
||||||
extra=EventExtra(ticket_waves=waves or []),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _stored(waves) -> Event:
|
|
||||||
return Event(
|
|
||||||
id="evt",
|
|
||||||
wallet="w",
|
|
||||||
name="Fete",
|
|
||||||
info="",
|
|
||||||
closing_date=_day(30),
|
|
||||||
event_start_date=_day(30),
|
|
||||||
currency="sat",
|
|
||||||
price_per_ticket=10,
|
|
||||||
amount_tickets=sum(w.amount_tickets for w in waves),
|
|
||||||
time=datetime.now(timezone.utc),
|
|
||||||
extra=EventExtra(ticket_waves=waves),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _detail(exc_info) -> str:
|
|
||||||
assert exc_info.value.status_code == HTTPStatus.BAD_REQUEST
|
|
||||||
return exc_info.value.detail
|
|
||||||
|
|
||||||
|
|
||||||
def test_wave_with_capacity_is_accepted():
|
|
||||||
_validate_wave_capacity(_create([_wave("early", 5), _wave("late", 10)]))
|
|
||||||
|
|
||||||
|
|
||||||
def test_zero_capacity_wave_is_rejected_on_create():
|
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
|
||||||
_validate_wave_capacity(_create([_wave("early", 5), _wave("late", 0)]))
|
|
||||||
assert "late" in _detail(exc_info)
|
|
||||||
|
|
||||||
|
|
||||||
def test_event_submitted_without_waves_is_checked_through_its_primary_wave():
|
|
||||||
"""No waves means the event gets a synthesized primary wave seeded from
|
|
||||||
`amount_tickets`, so the rule must see that rather than an empty list."""
|
|
||||||
_validate_wave_capacity(_create(waves=None, amount_tickets=10))
|
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
|
||||||
_validate_wave_capacity(_create(waves=None, amount_tickets=0))
|
|
||||||
assert "Primary wave" in _detail(exc_info)
|
|
||||||
|
|
||||||
|
|
||||||
def test_selling_a_wave_out_does_not_block_later_edits():
|
|
||||||
"""Zero is where a wave legitimately ends up. Rejecting it on edit would
|
|
||||||
make a sold-out event uneditable."""
|
|
||||||
sold_out = _wave("early", 0)
|
|
||||||
existing = _stored([sold_out, _wave("late", 10)])
|
|
||||||
|
|
||||||
_validate_wave_capacity(_create([sold_out, _wave("late", 10)]), existing)
|
|
||||||
|
|
||||||
|
|
||||||
def test_new_wave_added_by_an_edit_still_needs_capacity():
|
|
||||||
existing = _stored([_wave("early", 5)])
|
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
|
||||||
_validate_wave_capacity(
|
|
||||||
_create([_wave("early", 5), _wave("brand-new", 0)]), existing
|
|
||||||
)
|
|
||||||
assert "brand-new" in _detail(exc_info)
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_zero_capacity_event_stays_editable():
|
|
||||||
"""An event stored before the rule existed keeps its primary wave id, so
|
|
||||||
an edit is not blocked — otherwise the only way to fix it would be
|
|
||||||
barred."""
|
|
||||||
existing = _stored([_wave("primary", 0)])
|
|
||||||
_validate_wave_capacity(_create([_wave("primary", 0)]), existing)
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
"""Editing an event must not destroy its ticket waves.
|
|
||||||
|
|
||||||
`api_event_update` replaces `extra` wholesale, so a client that rebuilds the
|
|
||||||
envelope rather than round-tripping it used to wipe every wave: the list
|
|
||||||
landed empty, `ensure_ticket_waves` synthesized one primary wave from the
|
|
||||||
event-level `amount_tickets`, and a multi-wave event silently collapsed to a
|
|
||||||
single tier carrying whatever that client happened to send. Same hazard the
|
|
||||||
`promo_codes` guard already covered.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from ..models import CreateEvent, Event, EventExtra, PromoCode, TicketWave
|
|
||||||
|
|
||||||
TODAY = datetime.now(timezone.utc).date()
|
|
||||||
|
|
||||||
|
|
||||||
def _day(offset: int) -> str:
|
|
||||||
return (TODAY + timedelta(days=offset)).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def _wave(wave_id: str, price: float, stock: int) -> TicketWave:
|
|
||||||
return TicketWave(
|
|
||||||
id=wave_id,
|
|
||||||
title=wave_id,
|
|
||||||
opening_date=_day(0),
|
|
||||||
closing_date=_day(20),
|
|
||||||
currency="sat",
|
|
||||||
price_per_ticket=price,
|
|
||||||
amount_tickets=stock,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
STORED = [_wave("early", 10, 5), _wave("regular", 25, 40)]
|
|
||||||
|
|
||||||
|
|
||||||
def _stored_event() -> Event:
|
|
||||||
return Event(
|
|
||||||
id="evt",
|
|
||||||
wallet="w",
|
|
||||||
name="Fete",
|
|
||||||
info="",
|
|
||||||
closing_date=_day(20),
|
|
||||||
event_start_date=_day(30),
|
|
||||||
currency="sat",
|
|
||||||
price_per_ticket=10,
|
|
||||||
amount_tickets=45,
|
|
||||||
time=datetime.now(timezone.utc),
|
|
||||||
extra=EventExtra(
|
|
||||||
ticket_waves=list(STORED), promo_codes=[PromoCode(code="KEEP")]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _incoming(extra_payload: dict) -> CreateEvent:
|
|
||||||
"""A request built from raw JSON, so `__fields_set__` reflects exactly
|
|
||||||
which keys the client actually sent."""
|
|
||||||
return CreateEvent(
|
|
||||||
wallet="w",
|
|
||||||
name="Fete",
|
|
||||||
info="",
|
|
||||||
event_start_date=_day(30),
|
|
||||||
currency="sat",
|
|
||||||
price_per_ticket=77,
|
|
||||||
amount_tickets=999,
|
|
||||||
extra=EventExtra(**extra_payload),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_guard(data: CreateEvent, event: Event) -> CreateEvent:
|
|
||||||
"""The carry-over as `api_event_update` performs it."""
|
|
||||||
if "ticket_waves" not in data.extra.__fields_set__:
|
|
||||||
data.extra.ticket_waves = event.extra.ticket_waves
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def test_client_that_omits_waves_keeps_them():
|
|
||||||
"""The regression: a client rebuilding `extra` from scratch."""
|
|
||||||
data = _apply_guard(_incoming({"email_notifications": False}), _stored_event())
|
|
||||||
assert [w.id for w in data.extra.ticket_waves] == ["early", "regular"]
|
|
||||||
assert [w.price_per_ticket for w in data.extra.ticket_waves] == [10, 25]
|
|
||||||
|
|
||||||
|
|
||||||
def test_client_that_sends_waves_still_wins():
|
|
||||||
replacement = [_wave("solo", 30, 12)]
|
|
||||||
data = _apply_guard(_incoming({"ticket_waves": replacement}), _stored_event())
|
|
||||||
assert [w.id for w in data.extra.ticket_waves] == ["solo"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_explicit_empty_list_still_resets():
|
|
||||||
"""Matches the promo_codes contract: naming the key means you meant it."""
|
|
||||||
data = _apply_guard(_incoming({"ticket_waves": []}), _stored_event())
|
|
||||||
assert data.extra.ticket_waves == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_guard_runs_before_capacity_validation():
|
|
||||||
"""Validation must see the carried-over waves.
|
|
||||||
|
|
||||||
Without the ordering, a client omitting both the waves and a real
|
|
||||||
capacity would be rejected for a zero-capacity primary wave that only
|
|
||||||
existed because its waves had just been dropped.
|
|
||||||
"""
|
|
||||||
from ..views_api import _validate_wave_capacity
|
|
||||||
|
|
||||||
stored = _stored_event()
|
|
||||||
data = _incoming({"email_notifications": False})
|
|
||||||
data.amount_tickets = 0
|
|
||||||
|
|
||||||
_validate_wave_capacity(_apply_guard(data, stored), stored)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("key", ["promo_codes", "ticket_waves"])
|
|
||||||
def test_both_organiser_owned_extra_fields_are_guarded(key):
|
|
||||||
"""Regression net: `extra` holds organiser state a client need not know
|
|
||||||
about, and every such field needs the same carry-over."""
|
|
||||||
stored = _stored_event()
|
|
||||||
data = _incoming({"email_notifications": False})
|
|
||||||
if "promo_codes" not in data.extra.__fields_set__:
|
|
||||||
data.extra.promo_codes = stored.extra.promo_codes
|
|
||||||
_apply_guard(data, stored)
|
|
||||||
|
|
||||||
assert getattr(data.extra, key), f"{key} was dropped on edit"
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
"""Wave boundaries trigger a republish (#61).
|
|
||||||
|
|
||||||
Every republish this extension performs is sale-driven. A wave boundary is
|
|
||||||
a *date* boundary, so when early bird closes at midnight nothing fires and
|
|
||||||
the relay keeps serving the closed wave's price until the next ticket
|
|
||||||
happens to sell. `flag_wave_transitions` closes that gap by comparing the
|
|
||||||
wave a row would advertise now against the one its last successful publish
|
|
||||||
did, handing the work to the existing reconciliation sweep.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from .. import crud, nostr_hooks
|
|
||||||
from ..models import Event, EventExtra, TicketWave
|
|
||||||
|
|
||||||
TODAY = datetime.now(timezone.utc).date()
|
|
||||||
|
|
||||||
|
|
||||||
def _day(offset: int) -> str:
|
|
||||||
return (TODAY + timedelta(days=offset)).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def _wave(wave_id: str, price: float, stock: int, opens: int, closes: int):
|
|
||||||
return TicketWave(
|
|
||||||
id=wave_id,
|
|
||||||
title=wave_id,
|
|
||||||
opening_date=_day(opens),
|
|
||||||
closing_date=_day(closes),
|
|
||||||
currency="EUR",
|
|
||||||
price_per_ticket=price,
|
|
||||||
amount_tickets=stock,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _event(waves, published_wave_id, pending=False) -> Event:
|
|
||||||
return Event(
|
|
||||||
id="evt",
|
|
||||||
wallet="w",
|
|
||||||
name="Waves",
|
|
||||||
info="",
|
|
||||||
closing_date=_day(30),
|
|
||||||
event_start_date=_day(30),
|
|
||||||
event_end_date=_day(30),
|
|
||||||
currency="sat",
|
|
||||||
price_per_ticket=0,
|
|
||||||
amount_tickets=0,
|
|
||||||
time=datetime.now(timezone.utc),
|
|
||||||
status="approved",
|
|
||||||
nostr_publish_pending=pending,
|
|
||||||
nostr_published_wave_id=published_wave_id,
|
|
||||||
extra=EventExtra(ticket_waves=waves),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def swept(monkeypatch):
|
|
||||||
"""Capture rows the detector writes back."""
|
|
||||||
written: list[Event] = []
|
|
||||||
|
|
||||||
async def _update(event):
|
|
||||||
written.append(event)
|
|
||||||
return event
|
|
||||||
|
|
||||||
monkeypatch.setattr(crud, "update_event", _update)
|
|
||||||
return written
|
|
||||||
|
|
||||||
|
|
||||||
def _rows(monkeypatch, events):
|
|
||||||
monkeypatch.setattr(crud.db, "fetchall", AsyncMock(return_value=events))
|
|
||||||
|
|
||||||
|
|
||||||
CLOSED = _wave("early", 10.0, 5, -10, -1)
|
|
||||||
OPEN = _wave("regular", 25.0, 40, 0, 20)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_moved_wave_is_flagged(monkeypatch, swept):
|
|
||||||
"""Early bird closed; the relay still advertises it."""
|
|
||||||
_rows(monkeypatch, [_event([CLOSED, OPEN], published_wave_id="early")])
|
|
||||||
|
|
||||||
assert await crud.flag_wave_transitions() == 1
|
|
||||||
assert [e.nostr_publish_pending for e in swept] == [True]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_unchanged_wave_is_left_alone(monkeypatch, swept):
|
|
||||||
_rows(monkeypatch, [_event([CLOSED, OPEN], published_wave_id="regular")])
|
|
||||||
|
|
||||||
assert await crud.flag_wave_transitions() == 0
|
|
||||||
assert swept == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_selling_out_the_open_wave_is_a_transition(monkeypatch, swept):
|
|
||||||
"""No wave open is itself an advertisable state, and a different one."""
|
|
||||||
sold_out = _wave("regular", 25.0, 0, 0, 20)
|
|
||||||
_rows(monkeypatch, [_event([sold_out], published_wave_id="regular")])
|
|
||||||
|
|
||||||
assert await crud.flag_wave_transitions() == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_already_advertising_nothing_is_not_reflagged(monkeypatch, swept):
|
|
||||||
""" "" means "published while nothing was on sale" — not drift."""
|
|
||||||
_rows(monkeypatch, [_event([CLOSED], published_wave_id="")])
|
|
||||||
|
|
||||||
assert await crud.flag_wave_transitions() == 0
|
|
||||||
assert swept == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_never_published_rows_are_skipped(monkeypatch, swept):
|
|
||||||
"""NULL is no evidence of drift. Flagging these would republish the
|
|
||||||
whole table on the first boot after the upgrade."""
|
|
||||||
_rows(monkeypatch, []) # the SQL filters them out
|
|
||||||
|
|
||||||
assert await crud.flag_wave_transitions() == 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_publish_records_the_advertised_wave(monkeypatch):
|
|
||||||
"""The sweep can only detect drift if a success writes the wave down."""
|
|
||||||
event = _event([CLOSED, OPEN], published_wave_id="early")
|
|
||||||
monkeypatch.setattr(nostr_hooks, "update_event", AsyncMock(side_effect=lambda e: e))
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"lnbits.core.signers.resolve_for_wallet",
|
|
||||||
AsyncMock(return_value=SimpleNamespace(pubkey="pk")),
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
nostr_hooks,
|
|
||||||
"publish_event_to_nostr",
|
|
||||||
AsyncMock(return_value=SimpleNamespace(id="nid", created_at=123)),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert await nostr_hooks.publish_or_delete_nostr_event(event) is True
|
|
||||||
assert event.nostr_published_wave_id == "regular"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_delete_does_not_record_a_wave(monkeypatch):
|
|
||||||
"""A takedown advertises nothing, so it must not claim a wave."""
|
|
||||||
event = _event([CLOSED, OPEN], published_wave_id="early")
|
|
||||||
monkeypatch.setattr(nostr_hooks, "update_event", AsyncMock(side_effect=lambda e: e))
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"lnbits.core.signers.resolve_for_wallet",
|
|
||||||
AsyncMock(return_value=SimpleNamespace(pubkey="pk")),
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
nostr_hooks,
|
|
||||||
"publish_event_to_nostr",
|
|
||||||
AsyncMock(return_value=SimpleNamespace(id="nid", created_at=123)),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert await nostr_hooks.publish_or_delete_nostr_event(event, delete=True) is True
|
|
||||||
assert event.nostr_published_wave_id == "early"
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
"""
|
|
||||||
Nostr-transport RPC handlers for the aiolabs/events extension.
|
|
||||||
|
|
||||||
Each handler is registered with `lnbits.core.services.nostr_transport.
|
|
||||||
dispatcher.register_rpc` in `events_start()`. The dispatcher resolves
|
|
||||||
the caller's Nostr pubkey to an LNbits Account → wallet (`AUTH_WALLET`)
|
|
||||||
and passes a `WalletTypeInfo` as the first argument; handlers verify
|
|
||||||
event-level ownership on top.
|
|
||||||
|
|
||||||
Errors raise `PermissionError` / `ValueError` so the dispatcher maps
|
|
||||||
them into `{status: "ERROR", error: <msg>}` responses; any other
|
|
||||||
exception falls through to a generic "Internal error" reply.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from lnbits.core.crud import get_user
|
|
||||||
from lnbits.core.models import WalletTypeInfo
|
|
||||||
from lnbits.core.services.nostr_transport.models import NostrRpcRequest
|
|
||||||
|
|
||||||
from .crud import get_event, get_ticket, get_tickets_by_event, update_ticket
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_events_ticket_register(
|
|
||||||
auth: WalletTypeInfo,
|
|
||||||
request: NostrRpcRequest,
|
|
||||||
) -> dict:
|
|
||||||
"""Mark a ticket as registered at the door (organizer flow).
|
|
||||||
|
|
||||||
The Nostr-transport dispatcher already verified the caller signed
|
|
||||||
the kind-21000 RPC event and bound them to `auth.wallet`. This
|
|
||||||
handler adds the event-level check: the ticket's event must be
|
|
||||||
owned by one of the caller's wallets.
|
|
||||||
|
|
||||||
Idempotence mirrors the HTTP endpoint: scanning the same ticket
|
|
||||||
twice fails with "Ticket already registered". The buyer-side flow
|
|
||||||
(notifications etc.) reuses whatever the legacy register endpoint
|
|
||||||
does — we just flip the flag + timestamp.
|
|
||||||
"""
|
|
||||||
body = request.body or {}
|
|
||||||
event_id = body.get("event_id")
|
|
||||||
ticket_id = body.get("ticket_id")
|
|
||||||
if not event_id or not ticket_id:
|
|
||||||
raise ValueError("event_id and ticket_id are required")
|
|
||||||
|
|
||||||
ticket = await get_ticket(ticket_id)
|
|
||||||
if not ticket or ticket.event != event_id:
|
|
||||||
raise ValueError("Ticket does not exist on this event")
|
|
||||||
if not ticket.paid:
|
|
||||||
raise PermissionError("Ticket not paid for")
|
|
||||||
if ticket.registered:
|
|
||||||
raise PermissionError("Ticket already registered")
|
|
||||||
|
|
||||||
event = await get_event(event_id)
|
|
||||||
if not event:
|
|
||||||
raise ValueError("Event does not exist")
|
|
||||||
|
|
||||||
user = await get_user(auth.wallet.user)
|
|
||||||
owned_wallet_ids = user.wallet_ids if user else [auth.wallet.id]
|
|
||||||
if event.wallet not in owned_wallet_ids:
|
|
||||||
raise PermissionError("You do not own this event")
|
|
||||||
|
|
||||||
ticket.registered = True
|
|
||||||
ticket.reg_timestamp = datetime.now(timezone.utc)
|
|
||||||
await update_ticket(ticket)
|
|
||||||
return ticket.dict()
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_events_list_event_tickets(
|
|
||||||
auth: WalletTypeInfo,
|
|
||||||
request: NostrRpcRequest,
|
|
||||||
) -> dict:
|
|
||||||
"""Return paid + registered counts plus the per-ticket roster for
|
|
||||||
one calendar event, organizer-only.
|
|
||||||
|
|
||||||
Backs the door scanner's counts strip and "All scanned" tab so the
|
|
||||||
UI reads authoritative state from the backend instead of relying
|
|
||||||
on per-device localStorage (which diverges the moment a second
|
|
||||||
organizer scans, or the operator switches devices).
|
|
||||||
|
|
||||||
The roster only includes paid tickets — proposed/unpaid rows are
|
|
||||||
irrelevant at the door.
|
|
||||||
"""
|
|
||||||
body = request.body or {}
|
|
||||||
event_id = body.get("event_id")
|
|
||||||
if not event_id:
|
|
||||||
raise ValueError("event_id is required")
|
|
||||||
|
|
||||||
event = await get_event(event_id)
|
|
||||||
if not event:
|
|
||||||
raise ValueError("Event does not exist")
|
|
||||||
|
|
||||||
user = await get_user(auth.wallet.user)
|
|
||||||
owned_wallet_ids = user.wallet_ids if user else [auth.wallet.id]
|
|
||||||
if event.wallet not in owned_wallet_ids:
|
|
||||||
raise PermissionError("You do not own this event")
|
|
||||||
|
|
||||||
tickets = await get_tickets_by_event(event_id)
|
|
||||||
paid_tickets = [t for t in tickets if t.paid]
|
|
||||||
registered_count = sum(1 for t in paid_tickets if t.registered)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"event_id": event_id,
|
|
||||||
"sold": len(paid_tickets),
|
|
||||||
"registered": registered_count,
|
|
||||||
"remaining": len(paid_tickets) - registered_count,
|
|
||||||
"tickets": [
|
|
||||||
{
|
|
||||||
"id": t.id,
|
|
||||||
"name": t.name,
|
|
||||||
"registered": t.registered,
|
|
||||||
"registered_at": (
|
|
||||||
t.reg_timestamp.isoformat() if t.reg_timestamp else None
|
|
||||||
),
|
|
||||||
}
|
|
||||||
for t in paid_tickets
|
|
||||||
],
|
|
||||||
}
|
|
||||||
151
views.py
151
views.py
|
|
@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
|
||||||
1394
views_api.py
1394
views_api.py
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue