Compare commits
48 commits
feat/organ
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cadf30b704 | |||
| 0e9cc76fef | |||
| b4ca9fe65d | |||
| 69c9a4f757 | |||
| 33501dfc90 | |||
| b55d6866d6 | |||
| fe40d2ab14 | |||
| 26c0a5c429 | |||
| e706b46003 | |||
| 209a895415 | |||
| 064795c62a | |||
| 6bdebe4562 | |||
| 09c81c377b | |||
| 317a474816 | |||
| ecd05b4168 | |||
| 15c2276e57 | |||
| d90a0f8322 | |||
| ae5affa44f | |||
| ab4175a89a | |||
| e97b9b2134 | |||
| adfd4529f5 | |||
| 68a11bb50e | |||
| 17ec84c59d | |||
| 13eb549066 | |||
| ad8aa2cf00 | |||
| cc730256ab | |||
| cac7d16ece | |||
| 114f3406a6 | |||
| dc2a296bad | |||
| 643322131e | |||
| 5d52a231d3 | |||
| d2b8550d7f | |||
| 165787d134 | |||
| 7b66588d18 | |||
| 50c40439b7 | |||
| 93cc95fe18 | |||
| 07519bec1b | |||
| 8602bd71e3 | |||
| 4c3b1bca31 | |||
| c230e61031 | |||
| 87c74ce13f | |||
|
|
f745370c33 |
||
|
|
99b43ff851 |
||
|
|
da16b209cd |
||
|
|
e0ea0e30e7 |
||
|
|
35c20eda72 | ||
|
|
8c1538f5fc | ||
|
|
777c107c63 |
32 changed files with 4211 additions and 320 deletions
|
|
@ -62,6 +62,13 @@ Events includes a shareable ticket scanner, which can be used to register attend
|
||||||
- **Stripe session.** The buyer's email is passed as `customer_email`
|
- **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
|
(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.
|
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**
|
- **Email.** Multipart text + HTML (links, no images) with the **ticket card**
|
||||||
attached — a self-describing PNG (site, event, when, where, QR with the
|
attached — a self-describing PNG (site, event, when, where, QR with the
|
||||||
instance logo, name on ticket, ticket id) also served at
|
instance logo, name on ticket, ticket id) also served at
|
||||||
|
|
|
||||||
67
__init__.py
67
__init__.py
|
|
@ -6,13 +6,19 @@ from loguru import logger
|
||||||
from .crud import db
|
from .crud import db
|
||||||
from .tasks import wait_for_paid_invoices
|
from .tasks import wait_for_paid_invoices
|
||||||
from .views import events_generic_router
|
from .views import events_generic_router
|
||||||
from .views_api import events_api_router, qr_api_router, tickets_api_router
|
from .views_api import (
|
||||||
|
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(tickets_api_router)
|
||||||
events_ext.include_router(qr_api_router)
|
events_ext.include_router(qr_api_router)
|
||||||
|
events_ext.include_router(promo_api_router)
|
||||||
|
|
||||||
events_static_files = [
|
events_static_files = [
|
||||||
{
|
{
|
||||||
|
|
@ -28,6 +34,15 @@ scheduled_tasks: list[asyncio.Task] = []
|
||||||
# from nostr_hooks.publish_or_delete_nostr_event.
|
# 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:
|
||||||
|
|
@ -111,5 +126,55 @@ def events_start():
|
||||||
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"]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"id": "events",
|
"id": "events",
|
||||||
"version": "1.6.1-aio.13",
|
"version": "1.6.8-aio.4",
|
||||||
"name": "Events",
|
"name": "Events",
|
||||||
"repo": "https://git.atitlan.io/aiolabs/events",
|
"repo": "https://git.atitlan.io/aiolabs/events",
|
||||||
"short_description": "Sell and register event tickets",
|
"short_description": "Sell and register event tickets",
|
||||||
|
|
|
||||||
143
crud.py
143
crud.py
|
|
@ -1,10 +1,20 @@
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from lnbits.db import Database
|
from lnbits.db import Database, Filters, Page
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
|
||||||
from .models import CreateEvent, Event, EventsSettings, Ticket, TicketExtra
|
from .models import (
|
||||||
|
CreateEvent,
|
||||||
|
Event,
|
||||||
|
EventsSettings,
|
||||||
|
Ticket,
|
||||||
|
TicketExtra,
|
||||||
|
TicketFilters,
|
||||||
|
advertised_wave_key,
|
||||||
|
sync_event_ticket_waves,
|
||||||
|
)
|
||||||
|
|
||||||
db = Database("ext_events")
|
db = Database("ext_events")
|
||||||
|
|
||||||
|
|
@ -155,6 +165,31 @@ async def get_tickets_by_user_id(user_id: str) -> list[Ticket]:
|
||||||
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})
|
||||||
|
|
||||||
|
|
@ -184,44 +219,55 @@ async def create_event(data: CreateEvent) -> Event:
|
||||||
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:
|
||||||
return await db.fetchone(
|
event = 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])
|
||||||
return await db.fetchall(
|
events = 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."""
|
"""All events, no wallet filter. Admin-only callers."""
|
||||||
return await db.fetchall(
|
events = 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."""
|
"""Approved, non-canceled events for the public listing."""
|
||||||
return await db.fetchall(
|
events = await db.fetchall(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM events.events
|
SELECT * FROM events.events
|
||||||
WHERE status = 'approved' AND canceled = FALSE
|
WHERE status = 'approved' AND canceled = FALSE
|
||||||
|
|
@ -229,14 +275,97 @@ 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."""
|
"""Proposed events awaiting admin approval."""
|
||||||
return await db.fetchall(
|
events = 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:
|
||||||
|
|
|
||||||
173
docs/rebase-playbook.md
Normal file
173
docs/rebase-playbook.md
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
# 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`
|
||||||
|
|
@ -4,16 +4,18 @@ 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
|
`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.
|
in an upstream-compatible form; strike it when the PR merges upstream.
|
||||||
|
|
||||||
| Feature | Where | Upstream target | Readiness |
|
| 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 |
|
| `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 |
|
| 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.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 |
|
| `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 |
|
| `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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| `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) |
|
| 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 |
|
||||||
|
|
|
||||||
|
|
@ -127,3 +127,61 @@ async def m002_ticket_payment_hash(db):
|
||||||
"UPDATE events.ticket SET payment_hash = id "
|
"UPDATE events.ticket SET payment_hash = id "
|
||||||
"WHERE payment_hash IS NULL OR payment_hash = ''"
|
"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",
|
||||||
|
)
|
||||||
|
|
|
||||||
269
models.py
269
models.py
|
|
@ -1,7 +1,9 @@
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import date, datetime
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from lnbits.db import FilterModel
|
||||||
from pydantic import BaseModel, EmailStr, Field, root_validator, validator
|
from pydantic import BaseModel, EmailStr, Field, root_validator, validator
|
||||||
|
|
||||||
PAYMENT_METHODS = ("lightning", "fiat")
|
PAYMENT_METHODS = ("lightning", "fiat")
|
||||||
|
|
@ -11,20 +13,56 @@ 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
|
||||||
|
|
||||||
# make the promo code uppercase
|
# stored form: stripped + upper-case, never empty
|
||||||
@validator("code")
|
@validator("code")
|
||||||
def uppercase_code(cls, v):
|
def uppercase_code(cls, v):
|
||||||
return v.upper()
|
v = (v or "").strip().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
|
email_notifications: bool = False
|
||||||
|
|
@ -36,6 +74,19 @@ class EventExtra(BaseModel):
|
||||||
# `effective_payment_methods`. Same field name/shape as upstream v2 so the
|
# `effective_payment_methods`. Same field name/shape as upstream v2 so the
|
||||||
# eventual rebase (#33) merges cleanly.
|
# eventual rebase (#33) merges cleanly.
|
||||||
payment_methods: list[str] = Field(default_factory=list)
|
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)
|
@validator("payment_methods", pre=True)
|
||||||
def normalize_payment_methods(cls, v):
|
def normalize_payment_methods(cls, v):
|
||||||
|
|
@ -53,6 +104,13 @@ class EventExtra(BaseModel):
|
||||||
return seen
|
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: str | None = None # filled from caller's wallet if absent
|
||||||
name: str # title (required)
|
name: str # title (required)
|
||||||
|
|
@ -65,7 +123,12 @@ class CreateEvent(BaseModel):
|
||||||
currency: str = "sat"
|
currency: str = "sat"
|
||||||
allow_fiat: bool = False
|
allow_fiat: bool = False
|
||||||
fiat_currency: str = "GBP"
|
fiat_currency: str = "GBP"
|
||||||
amount_tickets: int = 0 # 0 = unlimited / not ticketed
|
# 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: str | None = None
|
||||||
location: str | None = None # venue/address (NIP-52 'location' tag)
|
location: str | None = None # venue/address (NIP-52 'location' tag)
|
||||||
|
|
@ -97,6 +160,16 @@ class Event(BaseModel):
|
||||||
status: str = "approved"
|
status: str = "approved"
|
||||||
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):
|
||||||
|
|
@ -120,7 +193,9 @@ class PublicEvent(BaseModel):
|
||||||
banner: str | None
|
banner: str | None
|
||||||
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)
|
# 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
|
status: str = "approved" # surfaces "proposed"/"rejected" so SFC can render banner
|
||||||
|
|
||||||
@validator("categories", pre=True)
|
@validator("categories", pre=True)
|
||||||
|
|
@ -130,22 +205,69 @@ class PublicEvent(BaseModel):
|
||||||
return v or []
|
return v or []
|
||||||
|
|
||||||
|
|
||||||
def effective_payment_methods(event: "Event | PublicEvent | CreateEvent") -> list[str]:
|
def effective_payment_methods(
|
||||||
|
event: "Event | PublicEvent | CreateEvent",
|
||||||
|
wave: "TicketWave | None" = None,
|
||||||
|
) -> list[str]:
|
||||||
"""Rails a buyer may pick for `event`.
|
"""Rails a buyer may pick for `event`.
|
||||||
|
|
||||||
Explicit `extra.payment_methods` wins; an empty list falls back to the
|
Explicit `extra.payment_methods` wins; an empty list falls back to the
|
||||||
pre-#payment-methods rule so events created before the field existed
|
pre-#payment-methods rule so events created before the field existed
|
||||||
keep behaving the same (Lightning always, fiat iff `allow_fiat`).
|
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 [])
|
explicit = list(getattr(event.extra, "payment_methods", []) or [])
|
||||||
if explicit:
|
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
|
return explicit
|
||||||
methods = ["lightning"]
|
methods = ["lightning"]
|
||||||
if event.allow_fiat:
|
if wave.allow_fiat if wave is not None else event.allow_fiat:
|
||||||
methods.append("fiat")
|
methods.append("fiat")
|
||||||
return methods
|
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."""
|
||||||
|
|
||||||
|
|
@ -154,6 +276,8 @@ class EventsSettings(BaseModel):
|
||||||
|
|
||||||
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
|
nostr_identifier: str | None = None
|
||||||
|
|
@ -161,12 +285,18 @@ class TicketExtra(BaseModel):
|
||||||
email_notification_sent: bool = False
|
email_notification_sent: bool = False
|
||||||
nostr_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):
|
class CreateTicket(BaseModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
email: EmailStr | None = None
|
email: EmailStr | None = None
|
||||||
user_id: str | None = None # LNbits user id (alternative to name+email)
|
user_id: str | None = None # LNbits user id (alternative to name+email)
|
||||||
|
ticket_wave_id: str | None = None
|
||||||
promo_code: str | None = None
|
promo_code: str | None = None
|
||||||
refund_address: str | None = None
|
refund_address: str | None = None
|
||||||
nostr_identifier: str | None = None
|
nostr_identifier: str | None = None
|
||||||
|
|
@ -268,3 +398,124 @@ class TicketPaymentRequest(BaseModel):
|
||||||
# the door). Buyers fetch these after payment to render N QRs in
|
# the door). Buyers fetch these after payment to render N QRs in
|
||||||
# My Tickets.
|
# My Tickets.
|
||||||
ticket_ids: list[str] = Field(default_factory=list)
|
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
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,14 @@ 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:
|
||||||
|
|
@ -29,6 +37,10 @@ class NostrClient:
|
||||||
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):
|
||||||
|
|
@ -78,17 +90,37 @@ 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)
|
||||||
|
|
||||||
req = await self.send_req_queue.get()
|
if held_req is not None:
|
||||||
|
req = held_req
|
||||||
|
else:
|
||||||
|
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:
|
||||||
|
|
@ -101,14 +133,82 @@ class NostrClient:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def get_event(self):
|
async def get_event(self):
|
||||||
"""Get next event from the receive queue."""
|
"""Get the next relay message, consuming `OK` frames on the way.
|
||||||
value = await self.receive_event_queue.get()
|
|
||||||
if isinstance(value, ValueError):
|
|
||||||
raise value
|
|
||||||
return value
|
|
||||||
|
|
||||||
async def publish_nostr_event(self, e: NostrEvent):
|
This is the only place relay messages cross from the websocket
|
||||||
await self.send_req_queue.put(["EVENT", e.dict()])
|
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()
|
||||||
|
if isinstance(value, ValueError):
|
||||||
|
self._fail_pending_oks("connection closed")
|
||||||
|
raise value
|
||||||
|
if self._settle_ok(value):
|
||||||
|
continue
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _settle_ok(self, message) -> bool:
|
||||||
|
"""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()])
|
||||||
|
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."""
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,11 @@ import cycle (views_api -> nostr_hooks -> nostr_publisher -> models).
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from .crud import update_event
|
from .crud import update_event
|
||||||
from .models import Event
|
from .models import Event, advertised_wave_key
|
||||||
from .nostr_publisher import publish_event_to_nostr
|
from .nostr_publisher import publish_event_to_nostr
|
||||||
|
|
||||||
|
|
||||||
async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -> None:
|
async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -> bool:
|
||||||
"""Publish or delete the NIP-52 calendar event for `event`.
|
"""Publish or delete the NIP-52 calendar event for `event`.
|
||||||
|
|
||||||
Resolves a `NostrSigner` for the wallet owner — backend-agnostic
|
Resolves a `NostrSigner` for the wallet owner — backend-agnostic
|
||||||
|
|
@ -22,7 +22,22 @@ async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -
|
||||||
`await signer.sign_event(...)` for signing. Failures are logged
|
`await signer.sign_event(...)` for signing. Failures are logged
|
||||||
and swallowed so a Nostr outage doesn't break the HTTP flow that
|
and swallowed so a Nostr outage doesn't break the HTTP flow that
|
||||||
triggered the publish.
|
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:
|
try:
|
||||||
from lnbits.core.signers import resolve_for_wallet
|
from lnbits.core.signers import resolve_for_wallet
|
||||||
|
|
||||||
|
|
@ -32,17 +47,40 @@ async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -
|
||||||
if signer is None:
|
if signer is None:
|
||||||
# Wallet missing, account missing, unclassified row, or
|
# Wallet missing, account missing, unclassified row, or
|
||||||
# ClientSideOnlySigner account (server can't sign for them).
|
# ClientSideOnlySigner account (server can't sign for them).
|
||||||
# Soft-fail: skip the publish silently. The user can still
|
# Soft-fail: the HTTP / payment flow that triggered this must
|
||||||
# publish kind-31922/31923 events client-side once we have
|
# not break. The user can still publish kind-31922/31923
|
||||||
# that path.
|
# events client-side once we have that path.
|
||||||
return
|
#
|
||||||
|
# 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_event = await publish_event_to_nostr(
|
||||||
nostr_client, event, signer, delete=delete
|
nostr_client, event, signer, delete=delete
|
||||||
)
|
)
|
||||||
if nostr_event and not 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_id = nostr_event.id
|
||||||
event.nostr_event_created_at = nostr_event.created_at
|
event.nostr_event_created_at = nostr_event.created_at
|
||||||
await update_event(event)
|
# 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:
|
except Exception as exc:
|
||||||
logger.warning(f"[EVENTS] Nostr publish failed: {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
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,12 @@ from datetime import datetime, timezone
|
||||||
from lnbits.core.signers import NostrSigner
|
from lnbits.core.signers import NostrSigner
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from .models import Event, effective_payment_methods
|
from .models import (
|
||||||
|
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
|
from .nostr_timestamp import monotonic_created_at
|
||||||
|
|
||||||
|
|
@ -46,11 +51,13 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
|
||||||
start - unix timestamp (31923) or YYYY-MM-DD (31922)
|
start - unix timestamp (31923) or YYYY-MM-DD (31922)
|
||||||
end - same encoding (optional)
|
end - same encoding (optional)
|
||||||
image, location, t (categories) - optional
|
image, location, t (categories) - optional
|
||||||
tickets_available - current remaining capacity (omitted when unlimited)
|
tickets_available - remaining capacity of the advertised wave
|
||||||
tickets_sold - running paid-count (always emitted; clients can
|
(always emitted; 0 = nothing on sale now)
|
||||||
derive original_capacity = available + sold)
|
tickets_sold - running paid-count across all waves (always
|
||||||
tickets_price - price_per_ticket (always emitted; 0 means free)
|
emitted)
|
||||||
tickets_currency - the currency string
|
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_allow_fiat - "true" when fiat checkout is enabled (omitted otherwise)
|
||||||
tickets_fiat_currency - the fiat settle currency (only when allow_fiat)
|
tickets_fiat_currency - the fiat settle currency (only when allow_fiat)
|
||||||
Content: event.info
|
Content: event.info
|
||||||
|
|
@ -96,25 +103,59 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
|
||||||
for cat in event.categories or []:
|
for cat in event.categories or []:
|
||||||
tags.append(["t", cat])
|
tags.append(["t", cat])
|
||||||
|
|
||||||
# `amount_tickets == 0` means unlimited capacity in this extension's
|
# Always emitted, including zero. Omitting it used to mean "unlimited",
|
||||||
# schema. Omitting the tag is how clients distinguish unlimited from
|
# which contradicted every other reader: `api_get_event` and
|
||||||
# "0 left" (sold out).
|
# `api_ticket_create` both treat `amount_tickets < 1` as sold out, so a
|
||||||
if event.amount_tickets > 0:
|
# zero-capacity event advertised "Unlimited tickets" on the card while
|
||||||
tags.append(["tickets_available", str(event.amount_tickets)])
|
# 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_sold", str(event.sold)])
|
||||||
tags.append(["tickets_price", str(event.price_per_ticket)])
|
tags.append(["tickets_price", str(advertised.price_per_ticket)])
|
||||||
tags.append(["tickets_currency", event.currency])
|
tags.append(["tickets_currency", advertised.currency])
|
||||||
# Fiat-checkout config — only emitted when allow_fiat is on so
|
# Fiat-checkout config — only emitted when allow_fiat is on so
|
||||||
# clients can branch the buy UI without re-reading the schema.
|
# clients can branch the buy UI without re-reading the schema.
|
||||||
if event.allow_fiat:
|
if advertised.allow_fiat:
|
||||||
tags.append(["tickets_allow_fiat", "true"])
|
tags.append(["tickets_allow_fiat", "true"])
|
||||||
if event.fiat_currency:
|
if advertised.fiat_currency:
|
||||||
tags.append(["tickets_fiat_currency", event.fiat_currency])
|
tags.append(["tickets_fiat_currency", advertised.fiat_currency])
|
||||||
# Rails the organizer accepts, resolved through the same helper the
|
# Rails the organizer accepts, resolved through the same helper the
|
||||||
# ticket endpoint enforces with, so a client can render exactly 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
|
# buttons that will be accepted (e.g. a card-only event) without a REST
|
||||||
# round-trip. Comma-separated, lowercase.
|
# round-trip. Comma-separated, lowercase.
|
||||||
tags.append(["tickets_payment_methods", ",".join(effective_payment_methods(event))])
|
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
|
# NIP-52 calendar events are replaceable: this d-tag is republished
|
||||||
# whenever inventory changes (a ticket sells). Use a strictly-monotonic
|
# whenever inventory changes (a ticket sells). Use a strictly-monotonic
|
||||||
|
|
@ -172,7 +213,14 @@ async def publish_event_to_nostr(
|
||||||
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:
|
||||||
logger.debug("[EVENTS] No NostrClient available, skipping publish")
|
# WARNING, not debug: with no client the event is never queued, so
|
||||||
|
# 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:
|
||||||
|
|
@ -196,7 +244,16 @@ async def publish_event_to_nostr(
|
||||||
nostr_event.pubkey = signed["pubkey"]
|
nostr_event.pubkey = signed["pubkey"]
|
||||||
nostr_event.sig = signed["sig"]
|
nostr_event.sig = signed["sig"]
|
||||||
|
|
||||||
await nostr_client.publish_nostr_event(nostr_event)
|
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'} "
|
||||||
|
|
@ -205,5 +262,8 @@ async def publish_event_to_nostr(
|
||||||
return nostr_event
|
return nostr_event
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[EVENTS] Failed to publish to Nostr: {e}")
|
# ERROR, not warning: this is the signer-outage shape of
|
||||||
|
# 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
|
||||||
|
|
|
||||||
117
promo.py
Normal file
117
promo.py
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
"""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
|
||||||
148
services.py
148
services.py
|
|
@ -26,8 +26,15 @@ from .crud import (
|
||||||
update_event,
|
update_event,
|
||||||
update_ticket,
|
update_ticket,
|
||||||
)
|
)
|
||||||
from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult
|
from .models import (
|
||||||
|
Event,
|
||||||
|
NotificationDeliveryResult,
|
||||||
|
Ticket,
|
||||||
|
TicketResendResult,
|
||||||
|
ensure_ticket_waves,
|
||||||
|
)
|
||||||
from .nostr_hooks import publish_or_delete_nostr_event
|
from .nostr_hooks import publish_or_delete_nostr_event
|
||||||
|
from .promo import promo_usage
|
||||||
from .qr import (
|
from .qr import (
|
||||||
format_event_when,
|
format_event_when,
|
||||||
image_png_bytes,
|
image_png_bytes,
|
||||||
|
|
@ -69,7 +76,29 @@ async def set_ticket_paid(ticket: 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
|
||||||
event.amount_tickets -= 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
|
||||||
|
# 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
|
# Republish the NIP-52 calendar event so connected clients see
|
||||||
|
|
@ -82,6 +111,22 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket:
|
||||||
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:
|
def send_ticket_notification_in_background(ticket: Ticket) -> None:
|
||||||
create_task(_send_ticket_notification(ticket))
|
create_task(_send_ticket_notification(ticket))
|
||||||
|
|
||||||
|
|
@ -95,10 +140,17 @@ async def _send_ticket_notification(ticket: Ticket) -> None:
|
||||||
await _deliver_ticket_notifications(ticket, event)
|
await _deliver_ticket_notifications(ticket, event)
|
||||||
|
|
||||||
|
|
||||||
async def resend_ticket_email_notification(ticket: Ticket) -> TicketResendResult:
|
async def resend_ticket_email_notification(
|
||||||
|
ticket: Ticket, base_url: str | None = None
|
||||||
|
) -> TicketResendResult:
|
||||||
"""Organizer-triggered re-delivery of the ticket email. Bypasses the
|
"""Organizer-triggered re-delivery of the ticket email. Bypasses the
|
||||||
per-event `email_notifications` opt-in (the organizer asked explicitly)
|
per-event `email_notifications` opt-in (the organizer asked explicitly)
|
||||||
but still needs the instance mailer and an address on the ticket."""
|
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)
|
event = await get_event(ticket.event)
|
||||||
if not event:
|
if not event:
|
||||||
raise ValueError("Event does not exist.")
|
raise ValueError("Event does not exist.")
|
||||||
|
|
@ -106,7 +158,12 @@ async def resend_ticket_email_notification(ticket: Ticket) -> TicketResendResult
|
||||||
raise ValueError("Email notifications are not enabled.")
|
raise ValueError("Email notifications are not enabled.")
|
||||||
if not ticket.email:
|
if not ticket.email:
|
||||||
raise ValueError("Ticket does not have an email address.")
|
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)
|
return await _deliver_ticket_notifications(ticket, event, email=True, nostr=False)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -142,17 +199,35 @@ def _ticket_details(ticket: Ticket, event: Event) -> str:
|
||||||
|
|
||||||
|
|
||||||
def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) -> str:
|
def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) -> str:
|
||||||
return (
|
"""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"{base_message}\n\n{_ticket_details(ticket, event)}"
|
||||||
f"\n\nTicket image: {_ticket_image_url(ticket)}"
|
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:
|
def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) -> str:
|
||||||
"""HTML twin of the text part. Deliberately no <img>: the card travels
|
"""HTML twin of the text part.
|
||||||
as an attachment (renders inline in most clients, works offline, and
|
|
||||||
keeps SpamAssassin's HTML_IMAGE_ONLY rules quiet), and URLs become
|
Deliberately no <img> for our own ticket card: it travels as an
|
||||||
links."""
|
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)
|
text_message = _ticket_delivery_message(ticket, event, base_message)
|
||||||
html = escape(text_message)
|
html = escape(text_message)
|
||||||
html = re.sub(
|
html = re.sub(
|
||||||
|
|
@ -160,7 +235,17 @@ def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str)
|
||||||
lambda m: f'<a href="{m.group(1)}">{m.group(1)}</a>',
|
lambda m: f'<a href="{m.group(1)}">{m.group(1)}</a>',
|
||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
return f"<p>{html.replace(chr(10), '<br />')}</p>"
|
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]:
|
def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]:
|
||||||
|
|
@ -179,7 +264,16 @@ async def _deliver_ticket_notifications(
|
||||||
) -> TicketResendResult:
|
) -> TicketResendResult:
|
||||||
"""Send the ticket by every configured channel and report per-channel
|
"""Send the ticket by every configured channel and report per-channel
|
||||||
outcome (upstream v1.6.8 shape). `email` / `nostr` override the event's
|
outcome (upstream v1.6.8 shape). `email` / `nostr` override the event's
|
||||||
opt-ins when not None; the instance-level prerequisites always apply."""
|
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)
|
subject, text_message, html_message = _ticket_notification_payload(ticket, event)
|
||||||
updated = False
|
updated = False
|
||||||
|
|
||||||
|
|
@ -355,16 +449,36 @@ def _ticket_url(ticket: Ticket) -> str:
|
||||||
return f"{base_url}/events/ticket/{ticket.id}"
|
return f"{base_url}/events/ticket/{ticket.id}"
|
||||||
|
|
||||||
|
|
||||||
def _ticket_image_url(ticket: Ticket) -> str:
|
def _ticket_card_url(ticket: Ticket) -> str:
|
||||||
"""The ticket card PNG is served by THIS extension on the LNbits host, so
|
"""Our rendered ticket-card PNG — always available, and served by THIS
|
||||||
it is built from `lnbits_baseurl` even when `ticket_base_url` points at
|
extension on the LNbits host, so it is built from `lnbits_baseurl` even
|
||||||
a separate web app (deviation from upstream, which assumes both are the
|
when `ticket_base_url` points at a separate web app (deviation from
|
||||||
same host)."""
|
upstream, which assumes both are the same host)."""
|
||||||
return (
|
return (
|
||||||
f"{settings.lnbits_baseurl.rstrip('/')}/events/api/v1/ticket-card/{ticket.id}"
|
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.
|
||||||
|
|
|
||||||
BIN
static/image/ticket.jpg
Normal file
BIN
static/image/ticket.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
|
|
@ -13,6 +13,7 @@ window.PageEventsDisplay = {
|
||||||
email: '',
|
email: '',
|
||||||
refund: '',
|
refund: '',
|
||||||
nostr_identifier: '',
|
nostr_identifier: '',
|
||||||
|
ticket_wave_id: null,
|
||||||
payment_method: 'lightning'
|
payment_method: 'lightning'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -46,26 +47,62 @@ window.PageEventsDisplay = {
|
||||||
paymentMethods() {
|
paymentMethods() {
|
||||||
// Mirrors `effective_payment_methods` on the backend: an explicit
|
// Mirrors `effective_payment_methods` on the backend: an explicit
|
||||||
// extra.payment_methods list wins, else Lightning + fiat-if-allow_fiat.
|
// 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 || []
|
const explicit = this.event?.extra?.payment_methods || []
|
||||||
if (explicit.length) return explicit
|
if (explicit.length) return explicit
|
||||||
return ['lightning', ...(this.event?.allow_fiat ? ['fiat'] : [])]
|
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() {
|
allowFiatCheckout() {
|
||||||
return this.paymentMethods.includes('fiat')
|
return this.paymentMethods.includes('fiat')
|
||||||
},
|
},
|
||||||
paymentMethodOptions() {
|
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 => ({
|
return this.paymentMethods.map(method => ({
|
||||||
value: method,
|
value: method,
|
||||||
label: method === 'fiat' ? this.fiatCheckoutLabel : 'Lightning'
|
label: labels[method] || method
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
fiatCheckoutLabel() {
|
fiatCheckoutLabel() {
|
||||||
if (!this.allowFiatCheckout) return 'Fiat'
|
if (!this.allowFiatCheckout) return 'Fiat'
|
||||||
const unit = ['sat', 'sats'].includes(
|
const unit = ['sat', 'sats'].includes(
|
||||||
(this.event?.currency || '').toLowerCase()
|
(this.selectedTicketWave?.currency || '').toLowerCase()
|
||||||
)
|
)
|
||||||
? this.event?.fiat_currency
|
? this.selectedTicketWave?.fiat_currency
|
||||||
: this.event?.currency
|
: this.selectedTicketWave?.currency
|
||||||
return `Fiat (${(unit || 'GBP').toUpperCase()})`
|
return `Fiat (${(unit || 'GBP').toUpperCase()})`
|
||||||
},
|
},
|
||||||
allowEmailNotifications() {
|
allowEmailNotifications() {
|
||||||
|
|
@ -82,6 +119,16 @@ window.PageEventsDisplay = {
|
||||||
'GET',
|
'GET',
|
||||||
`/events/api/v1/events/${this.eventId}`
|
`/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
|
return data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.eventErrorLabel = 'Event unavailable.'
|
this.eventErrorLabel = 'Event unavailable.'
|
||||||
|
|
@ -94,6 +141,11 @@ window.PageEventsDisplay = {
|
||||||
this.formDialog.data.email = ''
|
this.formDialog.data.email = ''
|
||||||
this.formDialog.data.refund = ''
|
this.formDialog.data.refund = ''
|
||||||
this.formDialog.data.nostr_identifier = ''
|
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.formDialog.data.payment_method =
|
||||||
this.paymentMethods[0] || 'lightning'
|
this.paymentMethods[0] || 'lightning'
|
||||||
},
|
},
|
||||||
|
|
@ -107,6 +159,13 @@ window.PageEventsDisplay = {
|
||||||
this.paymentWebsocket.close()
|
this.paymentWebsocket.close()
|
||||||
this.paymentWebsocket = null
|
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
|
||||||
|
|
@ -129,6 +188,10 @@ window.PageEventsDisplay = {
|
||||||
this.formDialog.data.email = ''
|
this.formDialog.data.email = ''
|
||||||
this.formDialog.data.refund = ''
|
this.formDialog.data.refund = ''
|
||||||
this.formDialog.data.nostr_identifier = ''
|
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.formDialog.data.payment_method =
|
||||||
this.paymentMethods[0] || 'lightning'
|
this.paymentMethods[0] || 'lightning'
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
|
|
@ -159,10 +222,19 @@ window.PageEventsDisplay = {
|
||||||
{
|
{
|
||||||
name: this.formDialog.data.name,
|
name: this.formDialog.data.name,
|
||||||
email: this.formDialog.data.email,
|
email: this.formDialog.data.email,
|
||||||
|
ticket_wave_id: this.formDialog.data.ticket_wave_id || null,
|
||||||
promo_code: this.formDialog.data.promo_code || null,
|
promo_code: this.formDialog.data.promo_code || null,
|
||||||
refund_address: this.formDialog.data.refund || null,
|
refund_address: this.formDialog.data.refund || null,
|
||||||
nostr_identifier: this.formDialog.data.nostr_identifier || null,
|
nostr_identifier: this.formDialog.data.nostr_identifier || null,
|
||||||
payment_method: this.formDialog.data.payment_method
|
// 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)
|
const isFiat = Boolean(data.is_fiat)
|
||||||
|
|
@ -196,7 +268,7 @@ window.PageEventsDisplay = {
|
||||||
|
|
||||||
const url = new URL(window.location)
|
const url = new URL(window.location)
|
||||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
url.pathname = `/api/v1/ws/${paymentHash}`
|
url.pathname = `/events/api/v1/tickets/ws/${paymentHash}`
|
||||||
url.search = ''
|
url.search = ''
|
||||||
url.hash = ''
|
url.hash = ''
|
||||||
|
|
||||||
|
|
@ -205,7 +277,7 @@ window.PageEventsDisplay = {
|
||||||
|
|
||||||
ws.onmessage = event => {
|
ws.onmessage = event => {
|
||||||
const data = JSON.parse(event.data)
|
const data = JSON.parse(event.data)
|
||||||
if (data.pending === false) {
|
if (data.paid === true) {
|
||||||
this.paymentSuccess(paymentHash)
|
this.paymentSuccess(paymentHash)
|
||||||
ws.close()
|
ws.close()
|
||||||
}
|
}
|
||||||
|
|
@ -214,8 +286,12 @@ window.PageEventsDisplay = {
|
||||||
console.error('WebSocket error:', error)
|
console.error('WebSocket error:', error)
|
||||||
}
|
}
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
if (this.paymentWebsocket === ws) {
|
if (this.paymentWebsocket !== ws) return
|
||||||
this.paymentWebsocket = null
|
this.paymentWebsocket = null
|
||||||
|
if (this.receive.show) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.receive.show) this.paymentWatcher(paymentHash)
|
||||||
|
}, 3000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.trim="formDialog.data.nostr_identifier"
|
v-model.trim="formDialog.data.nostr_identifier"
|
||||||
label="(optional) Nostr NIP-05 or npub"
|
label="(optional) Nostr NIP-05"
|
||||||
hint="If provided, we'll DM your ticket link after payment."
|
hint="If provided, we'll DM your ticket link after payment."
|
||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -89,6 +89,21 @@
|
||||||
lazy-rules
|
lazy-rules
|
||||||
:hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`"
|
:hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`"
|
||||||
></q-input>
|
></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 class="row q-col-gutter-md q-pt-lg items-center">
|
||||||
<div v-if="paymentMethods.length > 1" class="col-auto">
|
<div v-if="paymentMethods.length > 1" class="col-auto">
|
||||||
<q-option-group
|
<q-option-group
|
||||||
|
|
@ -117,7 +132,8 @@
|
||||||
:disable="
|
:disable="
|
||||||
formDialog.data.name == '' ||
|
formDialog.data.name == '' ||
|
||||||
formDialog.data.email == '' ||
|
formDialog.data.email == '' ||
|
||||||
Boolean(paymentReq)
|
(showTicketWaveSelector && !formDialog.data.ticket_wave_id) ||
|
||||||
|
(receive.show && Boolean(paymentReq))
|
||||||
"
|
"
|
||||||
type="submit"
|
type="submit"
|
||||||
>Submit</q-btn
|
>Submit</q-btn
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@ window.PageEvents = {
|
||||||
return {
|
return {
|
||||||
events: [],
|
events: [],
|
||||||
tickets: [],
|
tickets: [],
|
||||||
|
allPaidTickets: [],
|
||||||
resendingTicketEmails: [],
|
resendingTicketEmails: [],
|
||||||
|
isUploadingTicketTemplate: false,
|
||||||
|
ticketImageUploadTarget: null,
|
||||||
currencies: [],
|
currencies: [],
|
||||||
pendingEvents: [],
|
pendingEvents: [],
|
||||||
allUserEvents: [],
|
allUserEvents: [],
|
||||||
|
|
@ -25,7 +28,12 @@ window.PageEvents = {
|
||||||
label: 'Owner',
|
label: 'Owner',
|
||||||
field: 'wallet_user_id'
|
field: 'wallet_user_id'
|
||||||
},
|
},
|
||||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
{
|
||||||
|
name: 'id',
|
||||||
|
align: 'left',
|
||||||
|
label: 'ID',
|
||||||
|
field: row => this.shortenId(row.id)
|
||||||
|
},
|
||||||
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||||
{
|
{
|
||||||
name: 'event_start_date',
|
name: 'event_start_date',
|
||||||
|
|
@ -39,12 +47,6 @@ window.PageEvents = {
|
||||||
label: 'End date',
|
label: 'End date',
|
||||||
field: 'event_end_date'
|
field: 'event_end_date'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'closing_date',
|
|
||||||
align: 'left',
|
|
||||||
label: 'Ticket close',
|
|
||||||
field: 'closing_date'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'canceled',
|
name: 'canceled',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
|
|
@ -129,16 +131,41 @@ window.PageEvents = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
ticketsTable: {
|
ticketsTable: {
|
||||||
|
loading: false,
|
||||||
columns: [
|
columns: [
|
||||||
{name: 'event', align: 'left', label: 'Event', field: 'event'},
|
{
|
||||||
|
name: 'event',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Event',
|
||||||
|
field: row => this.shortenId(row.event)
|
||||||
|
},
|
||||||
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||||
{name: 'email', align: 'left', label: 'Email', field: 'email'},
|
{name: 'email', align: 'left', label: 'Email', field: 'email'},
|
||||||
|
{
|
||||||
|
name: 'email_sent',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Email sent',
|
||||||
|
field: row =>
|
||||||
|
!row.email
|
||||||
|
? 'no email'
|
||||||
|
: row.extra?.email_notification_sent
|
||||||
|
? '\u2713 sent'
|
||||||
|
: row.paid
|
||||||
|
? 'not sent'
|
||||||
|
: 'unpaid'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'registered',
|
name: 'registered',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
label: 'Registered',
|
label: 'Registered',
|
||||||
field: 'registered'
|
field: 'registered'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'nostr',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Nostr',
|
||||||
|
field: row => row.extra?.nostr_identifier || ''
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'promo_code',
|
name: 'promo_code',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
|
|
@ -148,7 +175,11 @@ window.PageEvents = {
|
||||||
{name: 'id', align: 'left', label: 'ID', field: 'id'}
|
{name: 'id', align: 'left', label: 'ID', field: 'id'}
|
||||||
],
|
],
|
||||||
pagination: {
|
pagination: {
|
||||||
rowsPerPage: 10
|
sortBy: 'time',
|
||||||
|
descending: true,
|
||||||
|
page: 1,
|
||||||
|
rowsPerPage: 10,
|
||||||
|
rowsNumber: 10
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Rails an organizer can enable per event. Mirrors the webapp's
|
// Rails an organizer can enable per event. Mirrors the webapp's
|
||||||
|
|
@ -205,11 +236,42 @@ window.PageEvents = {
|
||||||
categories: [],
|
categories: [],
|
||||||
extra: {
|
extra: {
|
||||||
payment_methods: ['lightning'],
|
payment_methods: ['lightning'],
|
||||||
|
ticket_waves: [],
|
||||||
promo_codes: [],
|
promo_codes: [],
|
||||||
notification_subject: '',
|
notification_subject: '',
|
||||||
notification_body: ''
|
notification_body: ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
ticketWaveDialog: {
|
||||||
|
show: false,
|
||||||
|
eventId: null,
|
||||||
|
wallet: null,
|
||||||
|
editingWaveId: null,
|
||||||
|
data: {
|
||||||
|
id: null,
|
||||||
|
title: '',
|
||||||
|
opening_date: '',
|
||||||
|
closing_date: '',
|
||||||
|
currency: 'sats',
|
||||||
|
use_ticket_image: false,
|
||||||
|
ticket_image_id: null,
|
||||||
|
allow_fiat: false,
|
||||||
|
fiat_currency: 'GBP',
|
||||||
|
amount_tickets: 0,
|
||||||
|
price_per_ticket: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
promoCodesDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {
|
||||||
|
id: null,
|
||||||
|
wallet: null,
|
||||||
|
name: '',
|
||||||
|
extra: {
|
||||||
|
promo_codes: []
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -235,19 +297,180 @@ window.PageEvents = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
shortenId(value) {
|
||||||
|
if (!value) return ''
|
||||||
|
return value.length > 4 ? `${value.slice(0, 4)}...` : value
|
||||||
|
},
|
||||||
|
primaryTicketWave(data = this.formDialog.data) {
|
||||||
|
if (!data.extra) data.extra = {}
|
||||||
|
if (!data.extra.ticket_waves || data.extra.ticket_waves.length === 0) {
|
||||||
|
data.extra.ticket_waves = [
|
||||||
|
{
|
||||||
|
id: 'primary',
|
||||||
|
title: 'Primary wave',
|
||||||
|
opening_date: data.closing_date || '',
|
||||||
|
closing_date: data.closing_date || '',
|
||||||
|
currency: data.currency || 'sats',
|
||||||
|
use_ticket_image: false,
|
||||||
|
ticket_image_id: null,
|
||||||
|
allow_fiat: Boolean(data.allow_fiat),
|
||||||
|
fiat_currency: data.fiat_currency || 'GBP',
|
||||||
|
amount_tickets: data.amount_tickets || 0,
|
||||||
|
price_per_ticket: data.price_per_ticket || 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return data.extra.ticket_waves[0]
|
||||||
|
},
|
||||||
|
syncPrimaryWaveFromForm(data = this.formDialog.data) {
|
||||||
|
const primaryWave = this.primaryTicketWave(data)
|
||||||
|
primaryWave.title = primaryWave.title || 'Primary wave'
|
||||||
|
primaryWave.opening_date = primaryWave.opening_date || ''
|
||||||
|
primaryWave.closing_date = data.closing_date || ''
|
||||||
|
primaryWave.currency = data.currency || 'sats'
|
||||||
|
primaryWave.use_ticket_image = Boolean(primaryWave.use_ticket_image)
|
||||||
|
primaryWave.ticket_image_id = primaryWave.ticket_image_id || null
|
||||||
|
primaryWave.allow_fiat = Boolean(data.allow_fiat)
|
||||||
|
primaryWave.fiat_currency = data.fiat_currency || 'GBP'
|
||||||
|
primaryWave.amount_tickets = Number(data.amount_tickets || 0)
|
||||||
|
primaryWave.price_per_ticket = Number(data.price_per_ticket || 0)
|
||||||
|
return primaryWave
|
||||||
|
},
|
||||||
|
hydrateEventForm(data) {
|
||||||
|
const formData = {
|
||||||
|
...data,
|
||||||
|
extra: {
|
||||||
|
...(data.extra || {}),
|
||||||
|
ticket_waves: [...((data.extra && data.extra.ticket_waves) || [])]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const primaryWave = this.primaryTicketWave(formData)
|
||||||
|
formData.currency = primaryWave.currency || formData.currency || 'sats'
|
||||||
|
formData.allow_fiat = Boolean(primaryWave.allow_fiat)
|
||||||
|
formData.fiat_currency = primaryWave.fiat_currency || 'GBP'
|
||||||
|
formData.amount_tickets = primaryWave.amount_tickets
|
||||||
|
formData.price_per_ticket = primaryWave.price_per_ticket
|
||||||
|
formData.closing_date =
|
||||||
|
primaryWave.closing_date || formData.closing_date || ''
|
||||||
|
return formData
|
||||||
|
},
|
||||||
isFiatCurrency(currency) {
|
isFiatCurrency(currency) {
|
||||||
return !['sat', 'sats'].includes((currency || '').toLowerCase())
|
return !['sat', 'sats'].includes((currency || '').toLowerCase())
|
||||||
},
|
},
|
||||||
getTickets() {
|
normalizePromoCodes(promoCodes = []) {
|
||||||
LNbits.api
|
return promoCodes
|
||||||
.request(
|
.filter(code => code.code?.trim() !== '')
|
||||||
|
.map(code => ({
|
||||||
|
...code,
|
||||||
|
code: code.code.trim().toUpperCase(),
|
||||||
|
// fork-only: blank / 0 = unlimited; used_count is derived server-side
|
||||||
|
max_uses: code.max_uses ? Number(code.max_uses) : null
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
templateDownloadUrl() {
|
||||||
|
return '/events/static/image/ticket.jpg'
|
||||||
|
},
|
||||||
|
async uploadAssetFile(file) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
form.append('public_asset', 'true')
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'POST',
|
||||||
|
'/api/v1/assets?public_asset=true',
|
||||||
|
null,
|
||||||
|
form
|
||||||
|
)
|
||||||
|
return data.id
|
||||||
|
},
|
||||||
|
triggerTicketImageUpload(target) {
|
||||||
|
this.ticketImageUploadTarget = target
|
||||||
|
this.$refs.ticketImageUpload.value = null
|
||||||
|
this.$refs.ticketImageUpload.click()
|
||||||
|
},
|
||||||
|
async handleTicketImageSelected(event) {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file || !this.ticketImageUploadTarget) return
|
||||||
|
|
||||||
|
this.isUploadingTicketTemplate = true
|
||||||
|
try {
|
||||||
|
const assetId = await this.uploadAssetFile(file)
|
||||||
|
if (this.ticketImageUploadTarget === 'primary') {
|
||||||
|
const wave = this.primaryTicketWave()
|
||||||
|
wave.use_ticket_image = true
|
||||||
|
wave.ticket_image_id = assetId
|
||||||
|
} else if (this.ticketImageUploadTarget === 'dialog') {
|
||||||
|
this.ticketWaveDialog.data.use_ticket_image = true
|
||||||
|
this.ticketWaveDialog.data.ticket_image_id = assetId
|
||||||
|
}
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Ticket template uploaded.',
|
||||||
|
icon: null
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
} finally {
|
||||||
|
this.isUploadingTicketTemplate = false
|
||||||
|
this.ticketImageUploadTarget = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
waveSummary(eventId, wave) {
|
||||||
|
// Built here, not in the template. Vue resolves template expressions
|
||||||
|
// against the component instance, where the `LNbits` global is NOT
|
||||||
|
// in scope — upstream's v1.6.8 chip called `LNbits.utils` inline and
|
||||||
|
// threw "Cannot read properties of undefined (reading 'utils')",
|
||||||
|
// which killed the whole v-for and left the wave list looking empty.
|
||||||
|
// No other template in this extension touches `LNbits` directly.
|
||||||
|
const price = this.isFiatCurrency(wave.currency)
|
||||||
|
? LNbits.utils.formatCurrency(
|
||||||
|
Number(wave.price_per_ticket || 0).toFixed(2),
|
||||||
|
wave.currency
|
||||||
|
)
|
||||||
|
: `${wave.price_per_ticket} sats`
|
||||||
|
// Wave dates can carry a time (closing_date defaults from
|
||||||
|
// event_end_date); show the day only.
|
||||||
|
const opens = String(wave.opening_date || '').slice(0, 10)
|
||||||
|
const closes = String(wave.closing_date || '').slice(0, 10)
|
||||||
|
const sold = this.soldTicketsForWave(eventId, wave.id)
|
||||||
|
return `${wave.title} - ${opens} to ${closes} - ${price} - ${wave.amount_tickets} tickets - ${sold} sold`
|
||||||
|
},
|
||||||
|
soldTicketsForWave(eventId, waveId) {
|
||||||
|
return this.allPaidTickets.filter(
|
||||||
|
ticket =>
|
||||||
|
ticket.event === eventId &&
|
||||||
|
ticket.paid &&
|
||||||
|
(ticket.extra?.ticket_wave_id === waveId ||
|
||||||
|
(!ticket.extra?.ticket_wave_id && waveId === 'primary'))
|
||||||
|
).length
|
||||||
|
},
|
||||||
|
async getAllTickets() {
|
||||||
|
try {
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
'/events/api/v1/tickets?all_wallets=true',
|
'/events/api/v1/tickets?all_wallets=true',
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
.then(response => {
|
this.allPaidTickets = data.filter(ticket => ticket.paid)
|
||||||
this.tickets = response.data.filter(e => e.paid)
|
} catch (error) {
|
||||||
})
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getTickets(props) {
|
||||||
|
try {
|
||||||
|
this.ticketsTable.loading = true
|
||||||
|
const params = LNbits.utils.prepareFilterQuery(this.ticketsTable, props)
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
`/events/api/v1/tickets/paginated?all_wallets=true&${params}`,
|
||||||
|
this.g.user.wallets[0].adminkey
|
||||||
|
)
|
||||||
|
this.tickets = data.data
|
||||||
|
this.ticketsTable.pagination.rowsNumber = data.total
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
} finally {
|
||||||
|
this.ticketsTable.loading = false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
deleteTicket(ticketId) {
|
deleteTicket(ticketId) {
|
||||||
const tickets = _.findWhere(this.tickets, {id: ticketId})
|
const tickets = _.findWhere(this.tickets, {id: ticketId})
|
||||||
|
|
@ -262,10 +485,9 @@ window.PageEvents = {
|
||||||
'/events/api/v1/tickets/' + ticketId,
|
'/events/api/v1/tickets/' + ticketId,
|
||||||
wallet.adminkey
|
wallet.adminkey
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then(async () => {
|
||||||
this.tickets = _.reject(this.tickets, function (obj) {
|
await this.getTickets()
|
||||||
return obj.id == ticketId
|
await this.getAllTickets()
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.catch(LNbits.utils.notifyApiError)
|
.catch(LNbits.utils.notifyApiError)
|
||||||
})
|
})
|
||||||
|
|
@ -283,14 +505,30 @@ window.PageEvents = {
|
||||||
wallet.adminkey
|
wallet.adminkey
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
|
const result = response.data
|
||||||
this.tickets = this.tickets.map(obj =>
|
this.tickets = this.tickets.map(obj =>
|
||||||
obj.id === ticket.id ? response.data : obj
|
obj.id === ticket.id ? result.ticket : obj
|
||||||
)
|
)
|
||||||
Quasar.Notify.create({
|
|
||||||
type: 'positive',
|
if (result.email?.attempted) {
|
||||||
message: 'Ticket email resent.',
|
Quasar.Notify.create({
|
||||||
icon: null
|
type: result.email.sent ? 'positive' : 'negative',
|
||||||
})
|
message: result.email.sent
|
||||||
|
? 'Ticket email resent.'
|
||||||
|
: `Ticket email failed: ${result.email.error || 'Unknown error.'}`,
|
||||||
|
icon: null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.nostr?.attempted) {
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: result.nostr.sent ? 'positive' : 'negative',
|
||||||
|
message: result.nostr.sent
|
||||||
|
? 'Ticket Nostr DM resent.'
|
||||||
|
: `Ticket Nostr DM failed: ${result.nostr.error || 'Unknown error.'}`,
|
||||||
|
icon: null
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(LNbits.utils.notifyApiError)
|
.catch(LNbits.utils.notifyApiError)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|
@ -300,7 +538,7 @@ window.PageEvents = {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
exportticketsCSV() {
|
exportticketsCSV() {
|
||||||
LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets)
|
LNbits.utils.exportCSV(this.ticketsTable.columns, this.allPaidTickets)
|
||||||
},
|
},
|
||||||
getEvents() {
|
getEvents() {
|
||||||
LNbits.api
|
LNbits.api
|
||||||
|
|
@ -507,14 +745,14 @@ window.PageEvents = {
|
||||||
data.location = (data.location || '').trim() || null
|
data.location = (data.location || '').trim() || null
|
||||||
data.categories = data.categories || []
|
data.categories = data.categories || []
|
||||||
data.closing_date = data.closing_date || null
|
data.closing_date = data.closing_date || null
|
||||||
|
// Fold the form's day+time pairs first, then let upstream's helper
|
||||||
|
// mirror the form fields onto the primary wave — order matters, the
|
||||||
|
// sync reads closing_date/currency/amount_tickets off `data`.
|
||||||
|
this.syncPrimaryWaveFromForm(data)
|
||||||
if (data.extra?.promo_codes) {
|
if (data.extra?.promo_codes) {
|
||||||
data.extra.promo_codes = data.extra.promo_codes
|
data.extra.promo_codes = this.normalizePromoCodes(
|
||||||
.filter(code => code.code?.trim() !== '')
|
data.extra.promo_codes
|
||||||
.map(code => ({
|
)
|
||||||
...code,
|
|
||||||
code: code.code.trim().toUpperCase()
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
const methods = data.extra?.payment_methods || []
|
const methods = data.extra?.payment_methods || []
|
||||||
if (methods.length === 0) {
|
if (methods.length === 0) {
|
||||||
|
|
@ -534,6 +772,7 @@ window.PageEvents = {
|
||||||
} else if (!data.allow_fiat) {
|
} else if (!data.allow_fiat) {
|
||||||
data.fiat_currency = 'GBP'
|
data.fiat_currency = 'GBP'
|
||||||
}
|
}
|
||||||
|
this.syncPrimaryWaveFromForm(data)
|
||||||
|
|
||||||
if (data.id) {
|
if (data.id) {
|
||||||
this.updateEvent(wallet, data)
|
this.updateEvent(wallet, data)
|
||||||
|
|
@ -546,10 +785,14 @@ window.PageEvents = {
|
||||||
if (data && data.id) {
|
if (data && data.id) {
|
||||||
const start = this.splitDateTime(data.event_start_date)
|
const start = this.splitDateTime(data.event_start_date)
|
||||||
const end = this.splitDateTime(data.event_end_date)
|
const end = this.splitDateTime(data.event_end_date)
|
||||||
|
// Seed from upstream's hydrator (it materialises ticket_waves and
|
||||||
|
// mirrors the primary wave onto the flat form fields), then layer
|
||||||
|
// our fork-only fields on top.
|
||||||
|
const hydrated = this.hydrateEventForm(data)
|
||||||
this.formDialog.data = {
|
this.formDialog.data = {
|
||||||
...data,
|
...hydrated,
|
||||||
extra: {
|
extra: {
|
||||||
...(data.extra || {}),
|
...(hydrated.extra || {}),
|
||||||
// Events created before extra.payment_methods existed carry an
|
// Events created before extra.payment_methods existed carry an
|
||||||
// empty list; show the rails the backend actually accepts for
|
// empty list; show the rails the backend actually accepts for
|
||||||
// them (Lightning always, fiat when allow_fiat).
|
// them (Lightning always, fiat when allow_fiat).
|
||||||
|
|
@ -581,6 +824,21 @@ window.PageEvents = {
|
||||||
min_tickets: 1,
|
min_tickets: 1,
|
||||||
email_notifications: false,
|
email_notifications: false,
|
||||||
nostr_notifications: false,
|
nostr_notifications: false,
|
||||||
|
ticket_waves: [
|
||||||
|
{
|
||||||
|
id: 'primary',
|
||||||
|
title: 'Primary wave',
|
||||||
|
opening_date: '',
|
||||||
|
closing_date: '',
|
||||||
|
currency: 'sats',
|
||||||
|
use_ticket_image: false,
|
||||||
|
ticket_image_id: null,
|
||||||
|
allow_fiat: false,
|
||||||
|
fiat_currency: 'GBP',
|
||||||
|
amount_tickets: 0,
|
||||||
|
price_per_ticket: 0
|
||||||
|
}
|
||||||
|
],
|
||||||
promo_codes: [],
|
promo_codes: [],
|
||||||
notification_subject: '',
|
notification_subject: '',
|
||||||
notification_body: ''
|
notification_body: ''
|
||||||
|
|
@ -599,8 +857,25 @@ window.PageEvents = {
|
||||||
categories: [],
|
categories: [],
|
||||||
extra: {
|
extra: {
|
||||||
payment_methods: ['lightning'],
|
payment_methods: ['lightning'],
|
||||||
|
conditional: false,
|
||||||
|
min_tickets: 1,
|
||||||
email_notifications: false,
|
email_notifications: false,
|
||||||
nostr_notifications: false,
|
nostr_notifications: false,
|
||||||
|
ticket_waves: [
|
||||||
|
{
|
||||||
|
id: 'primary',
|
||||||
|
title: 'Primary wave',
|
||||||
|
opening_date: '',
|
||||||
|
closing_date: '',
|
||||||
|
currency: 'sats',
|
||||||
|
use_ticket_image: false,
|
||||||
|
ticket_image_id: null,
|
||||||
|
allow_fiat: false,
|
||||||
|
fiat_currency: 'GBP',
|
||||||
|
amount_tickets: 0,
|
||||||
|
price_per_ticket: 0
|
||||||
|
}
|
||||||
|
],
|
||||||
promo_codes: [],
|
promo_codes: [],
|
||||||
notification_subject: '',
|
notification_subject: '',
|
||||||
notification_body: ''
|
notification_body: ''
|
||||||
|
|
@ -621,6 +896,183 @@ window.PageEvents = {
|
||||||
const link = _.findWhere(this.events, {id: formId})
|
const link = _.findWhere(this.events, {id: formId})
|
||||||
this.openEventDialog(link)
|
this.openEventDialog(link)
|
||||||
},
|
},
|
||||||
|
openTicketWaveDialog(event, wave = null) {
|
||||||
|
const primaryWave = (event.extra?.ticket_waves || [])[0] || {}
|
||||||
|
const isEditing = Boolean(wave)
|
||||||
|
this.ticketWaveDialog = {
|
||||||
|
show: true,
|
||||||
|
eventId: event.id,
|
||||||
|
wallet: event.wallet,
|
||||||
|
editingWaveId: wave?.id || null,
|
||||||
|
data: {
|
||||||
|
id: wave?.id || null,
|
||||||
|
title: wave?.title || '',
|
||||||
|
opening_date: wave?.opening_date || '',
|
||||||
|
closing_date: wave?.closing_date || '',
|
||||||
|
currency:
|
||||||
|
wave?.currency || primaryWave.currency || event.currency || 'sats',
|
||||||
|
use_ticket_image: Boolean(wave?.use_ticket_image),
|
||||||
|
ticket_image_id: wave?.ticket_image_id || null,
|
||||||
|
allow_fiat: isEditing
|
||||||
|
? Boolean(wave?.allow_fiat)
|
||||||
|
: Boolean(primaryWave.allow_fiat ?? event.allow_fiat),
|
||||||
|
fiat_currency:
|
||||||
|
wave?.fiat_currency ||
|
||||||
|
primaryWave.fiat_currency ||
|
||||||
|
event.fiat_currency ||
|
||||||
|
'GBP',
|
||||||
|
// `??` not `||`: a sold-out wave legitimately holds 0 and must
|
||||||
|
// show it rather than silently regaining stock on save. A NEW
|
||||||
|
// wave opens at 1, the smallest capacity the backend accepts —
|
||||||
|
// there is no unlimited (#34).
|
||||||
|
amount_tickets: isEditing ? (wave?.amount_tickets ?? 0) : 1,
|
||||||
|
price_per_ticket:
|
||||||
|
wave?.price_per_ticket ||
|
||||||
|
primaryWave.price_per_ticket ||
|
||||||
|
event.price_per_ticket ||
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resetTicketWaveDialog() {
|
||||||
|
this.ticketWaveDialog = {
|
||||||
|
show: false,
|
||||||
|
eventId: null,
|
||||||
|
wallet: null,
|
||||||
|
editingWaveId: null,
|
||||||
|
data: {
|
||||||
|
id: null,
|
||||||
|
title: '',
|
||||||
|
opening_date: '',
|
||||||
|
closing_date: '',
|
||||||
|
currency: 'sats',
|
||||||
|
use_ticket_image: false,
|
||||||
|
ticket_image_id: null,
|
||||||
|
allow_fiat: false,
|
||||||
|
fiat_currency: 'GBP',
|
||||||
|
amount_tickets: 0,
|
||||||
|
price_per_ticket: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
saveTicketWave() {
|
||||||
|
const event = _.findWhere(this.events, {
|
||||||
|
id: this.ticketWaveDialog.eventId
|
||||||
|
})
|
||||||
|
const wallet = _.findWhere(this.g.user.wallets, {
|
||||||
|
id: this.ticketWaveDialog.wallet
|
||||||
|
})
|
||||||
|
if (!event || !wallet) return
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
...event,
|
||||||
|
extra: {
|
||||||
|
...event.extra,
|
||||||
|
ticket_waves: (event.extra?.ticket_waves || []).map(existingWave =>
|
||||||
|
existingWave.id === this.ticketWaveDialog.editingWaveId
|
||||||
|
? {...this.ticketWaveDialog.data}
|
||||||
|
: existingWave
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.ticketWaveDialog.editingWaveId) {
|
||||||
|
payload.extra.ticket_waves.push({...this.ticketWaveDialog.data})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.extra?.promo_codes) {
|
||||||
|
payload.extra.promo_codes = this.normalizePromoCodes(
|
||||||
|
payload.extra.promo_codes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'PUT',
|
||||||
|
'/events/api/v1/events/' + payload.id,
|
||||||
|
wallet.adminkey,
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
this.events = this.events.map(item =>
|
||||||
|
item.id === payload.id ? response.data : item
|
||||||
|
)
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: this.ticketWaveDialog.editingWaveId
|
||||||
|
? 'Ticket wave updated.'
|
||||||
|
: 'Ticket wave added.',
|
||||||
|
icon: null
|
||||||
|
})
|
||||||
|
this.resetTicketWaveDialog()
|
||||||
|
})
|
||||||
|
.catch(LNbits.utils.notifyApiError)
|
||||||
|
},
|
||||||
|
openPromoCodesDialog(event) {
|
||||||
|
this.promoCodesDialog.data = {
|
||||||
|
...event,
|
||||||
|
extra: {
|
||||||
|
...event.extra,
|
||||||
|
promo_codes: [...(event.extra?.promo_codes || [])]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.promoCodesDialog.show = true
|
||||||
|
},
|
||||||
|
resetPromoCodesDialog() {
|
||||||
|
this.promoCodesDialog.show = false
|
||||||
|
this.promoCodesDialog.data = {
|
||||||
|
id: null,
|
||||||
|
wallet: null,
|
||||||
|
name: '',
|
||||||
|
extra: {
|
||||||
|
promo_codes: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addPromoCodeToDialog() {
|
||||||
|
this.promoCodesDialog.data.extra.promo_codes.push({
|
||||||
|
code: '',
|
||||||
|
discount_percent: 0,
|
||||||
|
active: true,
|
||||||
|
// fork-only: null = unlimited uses
|
||||||
|
max_uses: null
|
||||||
|
})
|
||||||
|
},
|
||||||
|
savePromoCodes() {
|
||||||
|
const data = this.promoCodesDialog.data
|
||||||
|
const wallet = _.findWhere(this.g.user.wallets, {
|
||||||
|
id: data.wallet
|
||||||
|
})
|
||||||
|
if (!wallet) return
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
...data,
|
||||||
|
extra: {
|
||||||
|
...data.extra,
|
||||||
|
promo_codes: this.normalizePromoCodes(data.extra?.promo_codes || [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'PUT',
|
||||||
|
'/events/api/v1/events/' + data.id,
|
||||||
|
wallet.adminkey,
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
this.events = this.events.map(event =>
|
||||||
|
event.id === data.id ? response.data : event
|
||||||
|
)
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Promo codes updated.',
|
||||||
|
icon: null
|
||||||
|
})
|
||||||
|
this.resetPromoCodesDialog()
|
||||||
|
})
|
||||||
|
.catch(LNbits.utils.notifyApiError)
|
||||||
|
},
|
||||||
updateEvent(wallet, data) {
|
updateEvent(wallet, data) {
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request(
|
.request(
|
||||||
|
|
@ -687,6 +1139,7 @@ window.PageEvents = {
|
||||||
async created() {
|
async created() {
|
||||||
if (this.g.user.wallets.length) {
|
if (this.g.user.wallets.length) {
|
||||||
this.getTickets()
|
this.getTickets()
|
||||||
|
this.getAllTickets()
|
||||||
this.getEvents()
|
this.getEvents()
|
||||||
this.getSettings()
|
this.getSettings()
|
||||||
this.getPendingEvents()
|
this.getPendingEvents()
|
||||||
|
|
|
||||||
|
|
@ -243,45 +243,86 @@
|
||||||
<q-tr v-show="props.expand" :props="props">
|
<q-tr v-show="props.expand" :props="props">
|
||||||
<q-td colspan="100%">
|
<q-td colspan="100%">
|
||||||
<div class="q-pa-md">
|
<div class="q-pa-md">
|
||||||
<div class="text-subtitle1 q-mb-md">Promo codes</div>
|
<div class="row items-center q-mb-md">
|
||||||
|
<div class="text-subtitle1">Ticket waves</div>
|
||||||
|
<q-btn
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
icon="add"
|
||||||
|
class="q-ml-sm"
|
||||||
|
@click="openTicketWaveDialog(props.row)"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
<div class="column q-mb-lg">
|
||||||
|
<div class="column">
|
||||||
|
<div
|
||||||
|
v-for="(wave, index) in props.row.extra.ticket_waves"
|
||||||
|
:key="wave.id || index"
|
||||||
|
class="q-mb-sm"
|
||||||
|
>
|
||||||
|
<q-chip
|
||||||
|
square
|
||||||
|
clickable
|
||||||
|
class="q-py-xs"
|
||||||
|
style="height: auto"
|
||||||
|
@click="openTicketWaveDialog(props.row, wave)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style="white-space: normal; line-height: 1.3"
|
||||||
|
v-text="waveSummary(props.row.id, wave)"
|
||||||
|
></span>
|
||||||
|
</q-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row items-center q-mb-md">
|
||||||
|
<div class="text-subtitle1">Promo codes</div>
|
||||||
|
<q-btn
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
icon="edit"
|
||||||
|
class="q-ml-sm"
|
||||||
|
@click="openPromoCodesDialog(props.row)"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<div
|
<div
|
||||||
v-if="props.row.extra.promo_codes.length == 0"
|
v-if="
|
||||||
|
props.row.extra.promo_codes.filter(
|
||||||
|
code => code.active
|
||||||
|
).length == 0
|
||||||
|
"
|
||||||
class="text-caption"
|
class="text-caption"
|
||||||
>
|
>
|
||||||
No promo codes for this event.
|
No active promo codes for this event.
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="row q-col-gutter-sm">
|
||||||
v-for="(code, index) in props.row.extra.promo_codes"
|
<div
|
||||||
:key="index"
|
v-for="(
|
||||||
class="row items-center q-col-gutter-sm q-mb-sm"
|
code, index
|
||||||
>
|
) in props.row.extra.promo_codes.filter(
|
||||||
<div class="col-auto">
|
code => code.active
|
||||||
|
)"
|
||||||
|
:key="index"
|
||||||
|
class="col-auto q-mb-sm"
|
||||||
|
>
|
||||||
<q-chip
|
<q-chip
|
||||||
square
|
square
|
||||||
size="md"
|
|
||||||
clickable
|
clickable
|
||||||
@click="utils.copyText(code.code.toUpperCase())"
|
@click="utils.copyText(code.code.toUpperCase())"
|
||||||
>
|
>
|
||||||
<q-avatar
|
<span
|
||||||
icon="bookmark"
|
v-text="
|
||||||
:color="code.active ? 'green' : 'grey'"
|
`${code.code.toUpperCase()} - ${code.discount_percent}%`
|
||||||
text-color="white"
|
"
|
||||||
></q-avatar>
|
></span>
|
||||||
<span v-text="code.code.toUpperCase()"></span>
|
|
||||||
</q-chip>
|
</q-chip>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -308,9 +349,11 @@
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
:rows="tickets"
|
:rows="tickets"
|
||||||
|
:loading="ticketsTable.loading"
|
||||||
row-key="id"
|
row-key="id"
|
||||||
:columns="ticketsTable.columns"
|
:columns="ticketsTable.columns"
|
||||||
v-model:pagination="ticketsTable.pagination"
|
v-model:pagination="ticketsTable.pagination"
|
||||||
|
@request="getTickets"
|
||||||
>
|
>
|
||||||
<template v-slot:header="props">
|
<template v-slot:header="props">
|
||||||
<q-tr :props="props">
|
<q-tr :props="props">
|
||||||
|
|
@ -597,6 +640,39 @@
|
||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<q-separator class="q-my-md"></q-separator>
|
||||||
|
<div class="text-subtitle1 q-mt-lg q-mb-md">
|
||||||
|
Primary ticket wave (you can add other waves later)
|
||||||
|
</div>
|
||||||
|
<div class="row q-col-gutter-sm">
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="primaryTicketWave().title"
|
||||||
|
type="text"
|
||||||
|
label="Wave title"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="primaryTicketWave().opening_date"
|
||||||
|
type="date"
|
||||||
|
label="Ticket opening date"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.closing_date"
|
||||||
|
type="date"
|
||||||
|
label="Ticket closing date"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row q-col-gutter-sm">
|
<div class="row q-col-gutter-sm">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<q-select
|
<q-select
|
||||||
|
|
@ -614,9 +690,9 @@
|
||||||
dense
|
dense
|
||||||
v-model.number="formDialog.data.amount_tickets"
|
v-model.number="formDialog.data.amount_tickets"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="1"
|
||||||
label="Amount of tickets"
|
label="Amount of tickets"
|
||||||
hint="0 = unlimited"
|
hint="Total tickets on sale"
|
||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
|
|
@ -674,6 +750,49 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<q-toggle
|
||||||
|
v-model="primaryTicketWave().use_ticket_image"
|
||||||
|
label="Use ticket image"
|
||||||
|
left-label
|
||||||
|
></q-toggle>
|
||||||
|
<div
|
||||||
|
v-if="primaryTicketWave().use_ticket_image"
|
||||||
|
class="row items-center q-col-gutter-sm q-mb-sm"
|
||||||
|
>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
type="a"
|
||||||
|
:href="templateDownloadUrl()"
|
||||||
|
download="ticket.jpg"
|
||||||
|
>
|
||||||
|
Download template
|
||||||
|
<q-tooltip>400/733 jpg</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="primary"
|
||||||
|
:loading="isUploadingTicketTemplate"
|
||||||
|
@click="triggerTicketImageUpload('primary')"
|
||||||
|
>Replace</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="primaryTicketWave().ticket_image_id"
|
||||||
|
class="col-12 text-caption"
|
||||||
|
>
|
||||||
|
Custom ticket template uploaded.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-toggle
|
||||||
|
v-model="formDialog.data.allow_fiat"
|
||||||
|
label="Allow fiat checkout"
|
||||||
|
left-label
|
||||||
|
hint="Lets attendees pay through a configured fiat provider using the event currency."
|
||||||
|
></q-toggle>
|
||||||
<q-select
|
<q-select
|
||||||
v-if="acceptsFiat && isSatPrice"
|
v-if="acceptsFiat && isSatPrice"
|
||||||
filled
|
filled
|
||||||
|
|
@ -718,112 +837,50 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<q-separator class="q-my-md"></q-separator>
|
<q-separator class="q-my-md"></q-separator>
|
||||||
<div class="text-subtitle1 q-mb-md">Promo Codes</div>
|
|
||||||
<div class="text-caption">
|
|
||||||
Allow users to enter a promo code for discounts.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="(code, index) in formDialog.data.extra.promo_codes"
|
|
||||||
:key="index"
|
|
||||||
class="row q-col-gutter-sm q-mt-md"
|
|
||||||
>
|
|
||||||
<q-input
|
|
||||||
class="col-8"
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formDialog.data.extra.promo_codes[index].code"
|
|
||||||
type="text"
|
|
||||||
label="Promo Code"
|
|
||||||
>
|
|
||||||
<template v-slot:before>
|
|
||||||
<q-checkbox
|
|
||||||
left-label
|
|
||||||
v-model="formDialog.data.extra.promo_codes[index].active"
|
|
||||||
checked-icon="radio_button_checked"
|
|
||||||
unchecked-icon="radio_button_unchecked"
|
|
||||||
></q-checkbox>
|
|
||||||
<q-tooltip>
|
|
||||||
<span
|
|
||||||
v-text="
|
|
||||||
formDialog.data.extra.promo_codes[index].active
|
|
||||||
? 'Active'
|
|
||||||
: 'Inactive'
|
|
||||||
"
|
|
||||||
></span>
|
|
||||||
</q-tooltip>
|
|
||||||
</template>
|
|
||||||
</q-input>
|
|
||||||
<q-input
|
|
||||||
class="col-4"
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.number="
|
|
||||||
formDialog.data.extra.promo_codes[index].discount_percent
|
|
||||||
"
|
|
||||||
type="number"
|
|
||||||
label="Discount (%)"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
>
|
|
||||||
<template v-slot:after>
|
|
||||||
<q-btn
|
|
||||||
round
|
|
||||||
dense
|
|
||||||
flat
|
|
||||||
icon="delete"
|
|
||||||
@click="formDialog.data.extra.promo_codes.splice(index, 1)"
|
|
||||||
></q-btn>
|
|
||||||
</template>
|
|
||||||
</q-input>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 q-mt-md">
|
|
||||||
<q-btn
|
|
||||||
@click="
|
|
||||||
formDialog.data.extra.promo_codes.push({
|
|
||||||
code: '',
|
|
||||||
discount_percent: 0,
|
|
||||||
active: true
|
|
||||||
})
|
|
||||||
"
|
|
||||||
>Add Promo Code</q-btn
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<q-separator class="q-my-md"></q-separator>
|
|
||||||
<div class="text-subtitle1 q-mb-md">Ticket Delivery</div>
|
<div class="text-subtitle1 q-mb-md">Ticket Delivery</div>
|
||||||
<div class="text-caption">
|
<div class="text-caption">
|
||||||
Send the paid ticket link automatically by email or Nostr DM.
|
Send the paid ticket link automatically by email or Nostr DM.
|
||||||
</div>
|
</div>
|
||||||
<q-toggle
|
<div class="row items-center q-col-gutter-md">
|
||||||
v-model="formDialog.data.extra.email_notifications"
|
<div class="col-auto">
|
||||||
label="Email notifications"
|
<q-toggle
|
||||||
left-label
|
v-model="formDialog.data.extra.email_notifications"
|
||||||
></q-toggle>
|
label="Email notifications"
|
||||||
<q-toggle
|
left-label
|
||||||
v-model="formDialog.data.extra.nostr_notifications"
|
></q-toggle>
|
||||||
label="Nostr notifications"
|
</div>
|
||||||
left-label
|
<div class="col-auto">
|
||||||
></q-toggle>
|
<q-toggle
|
||||||
|
v-model="formDialog.data.extra.nostr_notifications"
|
||||||
|
label="Nostr notifications"
|
||||||
|
left-label
|
||||||
|
></q-toggle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="formDialog.data.extra.email_notifications"
|
||||||
|
class="q-mt-md"
|
||||||
|
>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.extra.notification_subject"
|
||||||
|
type="text"
|
||||||
|
label="Ticket notification subject"
|
||||||
|
hint="Used as the email subject when sending paid ticket links."
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
class="q-mt-md"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.extra.notification_body"
|
||||||
|
type="textarea"
|
||||||
|
label="Ticket notification body"
|
||||||
|
hint="Shown before the ticket link in the paid ticket notification."
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
|
|
||||||
<q-separator class="q-my-md"></q-separator>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formDialog.data.extra.notification_subject"
|
|
||||||
type="text"
|
|
||||||
label="Ticket notification subject"
|
|
||||||
hint="Used as the email subject when sending paid ticket links."
|
|
||||||
></q-input>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formDialog.data.extra.notification_body"
|
|
||||||
type="textarea"
|
|
||||||
label="Ticket notification body"
|
|
||||||
hint="Shown before the ticket link in the paid ticket notification."
|
|
||||||
></q-input>
|
|
||||||
|
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="formDialog.data.id"
|
v-if="formDialog.data.id"
|
||||||
|
|
@ -839,7 +896,9 @@
|
||||||
:disable="
|
:disable="
|
||||||
!formDialog.data.wallet ||
|
!formDialog.data.wallet ||
|
||||||
!formDialog.data.name ||
|
!formDialog.data.name ||
|
||||||
!formDialog.data.event_start_day
|
!formDialog.data.event_start_day ||
|
||||||
|
primaryTicketWave().title == null ||
|
||||||
|
primaryTicketWave().opening_date == null
|
||||||
"
|
"
|
||||||
type="submit"
|
type="submit"
|
||||||
>Create Event</q-btn
|
>Create Event</q-btn
|
||||||
|
|
@ -851,5 +910,277 @@
|
||||||
</q-form>
|
</q-form>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
||||||
|
<q-dialog v-model="promoCodesDialog.show" position="top">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
<q-form @submit="savePromoCodes" class="q-gutter-md">
|
||||||
|
<div class="text-subtitle1">Promo Codes</div>
|
||||||
|
<div class="text-caption">
|
||||||
|
Allow users to enter a promo code for discounts.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(code, index) in promoCodesDialog.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="promoCodesDialog.data.extra.promo_codes[index].code"
|
||||||
|
type="text"
|
||||||
|
label="Promo Code"
|
||||||
|
>
|
||||||
|
<template v-slot:before>
|
||||||
|
<q-checkbox
|
||||||
|
left-label
|
||||||
|
v-model="
|
||||||
|
promoCodesDialog.data.extra.promo_codes[index].active
|
||||||
|
"
|
||||||
|
checked-icon="radio_button_checked"
|
||||||
|
unchecked-icon="radio_button_unchecked"
|
||||||
|
></q-checkbox>
|
||||||
|
<q-tooltip>
|
||||||
|
<span
|
||||||
|
v-text="
|
||||||
|
promoCodesDialog.data.extra.promo_codes[index].active
|
||||||
|
? 'Active'
|
||||||
|
: 'Inactive'
|
||||||
|
"
|
||||||
|
></span>
|
||||||
|
</q-tooltip>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
<q-input
|
||||||
|
class="col-2"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.number="
|
||||||
|
promoCodesDialog.data.extra.promo_codes[index].discount_percent
|
||||||
|
"
|
||||||
|
type="number"
|
||||||
|
label="Discount (%)"
|
||||||
|
:hint="
|
||||||
|
promoCodesDialog.data.extra.promo_codes[index].used_count
|
||||||
|
? 'Used ' +
|
||||||
|
promoCodesDialog.data.extra.promo_codes[index].used_count +
|
||||||
|
(promoCodesDialog.data.extra.promo_codes[index].max_uses
|
||||||
|
? ' / ' +
|
||||||
|
promoCodesDialog.data.extra.promo_codes[index].max_uses
|
||||||
|
: '')
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
>
|
||||||
|
<template v-slot:after>
|
||||||
|
<q-btn
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
icon="delete"
|
||||||
|
@click="
|
||||||
|
promoCodesDialog.data.extra.promo_codes.splice(index, 1)
|
||||||
|
"
|
||||||
|
></q-btn>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
<q-input
|
||||||
|
class="col-2"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.number="
|
||||||
|
promoCodesDialog.data.extra.promo_codes[index].max_uses
|
||||||
|
"
|
||||||
|
type="number"
|
||||||
|
label="Max uses"
|
||||||
|
placeholder="∞"
|
||||||
|
min="1"
|
||||||
|
hint="Blank = unlimited"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 q-mt-md">
|
||||||
|
<q-btn @click="addPromoCodeToDialog">Add Promo Code</q-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn unelevated color="primary" type="submit"
|
||||||
|
>Save Promo Codes</q-btn
|
||||||
|
>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
color="grey"
|
||||||
|
class="q-ml-auto"
|
||||||
|
@click="resetPromoCodesDialog"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<q-dialog v-model="ticketWaveDialog.show" position="top">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
<q-form @submit="saveTicketWave" class="q-gutter-md">
|
||||||
|
<div
|
||||||
|
class="text-subtitle1"
|
||||||
|
v-text="
|
||||||
|
ticketWaveDialog.editingWaveId
|
||||||
|
? 'Edit Ticket Wave'
|
||||||
|
: 'Add Ticket Wave'
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
<div class="row q-col-gutter-sm">
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="ticketWaveDialog.data.title"
|
||||||
|
type="text"
|
||||||
|
label="Wave title"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="ticketWaveDialog.data.opening_date"
|
||||||
|
type="date"
|
||||||
|
label="Ticket opening date"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="ticketWaveDialog.data.closing_date"
|
||||||
|
type="date"
|
||||||
|
label="Ticket closing date"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row q-col-gutter-sm">
|
||||||
|
<div class="col">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="ticketWaveDialog.data.currency"
|
||||||
|
type="text"
|
||||||
|
label="Unit"
|
||||||
|
:options="currencies"
|
||||||
|
></q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.number="ticketWaveDialog.data.amount_tickets"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
label="Amount of tickets"
|
||||||
|
hint="Tickets on sale in this wave"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.number="ticketWaveDialog.data.price_per_ticket"
|
||||||
|
type="number"
|
||||||
|
:label="'Price (' + ticketWaveDialog.data.currency + ') *'"
|
||||||
|
:step="ticketWaveDialog.data.currency != 'sats' ? '0.01' : '1'"
|
||||||
|
:mask="ticketWaveDialog.data.currency != 'sats' ? '#.##' : '#'"
|
||||||
|
fill-mask="0"
|
||||||
|
reverse-fill-mask
|
||||||
|
:disable="ticketWaveDialog.data.currency == null"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-toggle
|
||||||
|
v-model="ticketWaveDialog.data.use_ticket_image"
|
||||||
|
label="Use ticket image"
|
||||||
|
left-label
|
||||||
|
></q-toggle>
|
||||||
|
<div
|
||||||
|
v-if="ticketWaveDialog.data.use_ticket_image"
|
||||||
|
class="row items-center q-col-gutter-sm q-mb-sm"
|
||||||
|
>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
type="a"
|
||||||
|
:href="templateDownloadUrl()"
|
||||||
|
download="ticket.jpg"
|
||||||
|
>
|
||||||
|
Download template
|
||||||
|
<q-tooltip>400/733 jpg</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="primary"
|
||||||
|
:loading="isUploadingTicketTemplate"
|
||||||
|
@click="triggerTicketImageUpload('dialog')"
|
||||||
|
>Replace</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="ticketWaveDialog.data.ticket_image_id"
|
||||||
|
class="col-12 text-caption"
|
||||||
|
>
|
||||||
|
Custom ticket template uploaded.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-toggle
|
||||||
|
v-model="ticketWaveDialog.data.allow_fiat"
|
||||||
|
label="Allow fiat checkout"
|
||||||
|
left-label
|
||||||
|
hint="Lets attendees pay through a configured fiat provider using this wave currency."
|
||||||
|
></q-toggle>
|
||||||
|
<q-select
|
||||||
|
v-if="
|
||||||
|
ticketWaveDialog.data.allow_fiat &&
|
||||||
|
['sat', 'sats'].includes(
|
||||||
|
(ticketWaveDialog.data.currency || '').toLowerCase()
|
||||||
|
)
|
||||||
|
"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="ticketWaveDialog.data.fiat_currency"
|
||||||
|
label="Fiat checkout currency"
|
||||||
|
:options="
|
||||||
|
currencies.filter(
|
||||||
|
c => !['sat', 'sats'].includes((c || '').toLowerCase())
|
||||||
|
)
|
||||||
|
"
|
||||||
|
></q-select>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn unelevated color="primary" type="submit">{{
|
||||||
|
ticketWaveDialog.editingWaveId
|
||||||
|
? 'Update Ticket Wave'
|
||||||
|
: 'Save Ticket Wave'
|
||||||
|
}}</q-btn>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
color="grey"
|
||||||
|
class="q-ml-auto"
|
||||||
|
@click="resetTicketWaveDialog"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
<input
|
||||||
|
ref="ticketImageUpload"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
style="display: none"
|
||||||
|
@change="handleTicketImageSelected"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,36 @@ window.PageEventsTicket = {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
ticketId: null,
|
ticketId: null,
|
||||||
ticket: null
|
ticket: null,
|
||||||
|
printMode: false,
|
||||||
|
qrSrc: ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
printWindow() {
|
async printWindow() {
|
||||||
window.print()
|
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() {
|
async created() {
|
||||||
this.ticketId = this.$route.params.id
|
this.ticketId = this.$route.params.id
|
||||||
|
this.qrSrc = `/api/v1/qrcode?data=${encodeURIComponent(
|
||||||
|
`ticket://${this.ticketId}`
|
||||||
|
)}`
|
||||||
try {
|
try {
|
||||||
const {data} = await LNbits.api.request(
|
const {data} = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
|
|
@ -22,5 +42,8 @@ window.PageEventsTicket = {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
LNbits.utils.notifyApiError(error)
|
LNbits.utils.notifyApiError(error)
|
||||||
}
|
}
|
||||||
|
window.addEventListener('afterprint', () => {
|
||||||
|
this.printMode = false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,4 +36,53 @@
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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>
|
||||||
|
|
|
||||||
112
tests/test_capacity_guard.py
Normal file
112
tests/test_capacity_guard.py
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
"""`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."
|
||||||
171
tests/test_nostr_publish_pending.py
Normal file
171
tests/test_nostr_publish_pending.py
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
"""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)]
|
||||||
218
tests/test_promo.py
Normal file
218
tests/test_promo.py
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
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
|
||||||
178
tests/test_promo_api.py
Normal file
178
tests/test_promo_api.py
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
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 == []
|
||||||
195
tests/test_publish_active_wave.py
Normal file
195
tests/test_publish_active_wave.py
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
"""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"
|
||||||
56
tests/test_publish_availability.py
Normal file
56
tests/test_publish_availability.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
"""`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"
|
||||||
105
tests/test_publish_confirmation.py
Normal file
105
tests/test_publish_confirmation.py
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
"""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
|
||||||
|
|
@ -5,6 +5,10 @@ from ..models import (
|
||||||
CreateEvent,
|
CreateEvent,
|
||||||
CreateTicket,
|
CreateTicket,
|
||||||
EventExtra,
|
EventExtra,
|
||||||
|
PromoCode,
|
||||||
|
PublicEvent,
|
||||||
|
PublicEventExtra,
|
||||||
|
TicketWave,
|
||||||
effective_payment_methods,
|
effective_payment_methods,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -108,3 +112,108 @@ def test_payment_methods_are_normalised_and_deduplicated():
|
||||||
def test_unknown_payment_method_is_rejected():
|
def test_unknown_payment_method_is_rejected():
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
EventExtra(payment_methods=["cash"])
|
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)) == []
|
||||||
|
|
|
||||||
118
tests/test_wave_capacity_required.py
Normal file
118
tests/test_wave_capacity_required.py
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
"""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)
|
||||||
124
tests/test_wave_preservation_on_edit.py
Normal file
124
tests/test_wave_preservation_on_edit.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
"""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"
|
||||||
159
tests/test_wave_transition_sweep.py
Normal file
159
tests/test_wave_transition_sweep.py
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
"""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"
|
||||||
406
views_api.py
406
views_api.py
|
|
@ -2,6 +2,7 @@ import asyncio
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
|
@ -16,17 +17,20 @@ from fastapi import (
|
||||||
)
|
)
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from lnbits.core.crud import get_user
|
from lnbits.core.crud import get_user
|
||||||
|
from lnbits.core.crud.assets import get_public_asset
|
||||||
from lnbits.core.crud.wallets import get_wallet
|
from lnbits.core.crud.wallets import get_wallet
|
||||||
from lnbits.core.models import Account, User, WalletTypeInfo
|
from lnbits.core.models import Account, User, WalletTypeInfo
|
||||||
from lnbits.core.models.payments import CreateInvoice
|
from lnbits.core.models.payments import CreateInvoice
|
||||||
from lnbits.core.services import create_payment_request
|
from lnbits.core.services import create_payment_request
|
||||||
|
from lnbits.db import Filters, Page
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
check_admin,
|
check_admin,
|
||||||
check_user_exists,
|
check_user_exists,
|
||||||
|
parse_filters,
|
||||||
require_admin_key,
|
require_admin_key,
|
||||||
require_invoice_key,
|
require_invoice_key,
|
||||||
)
|
)
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
from lnbits.helpers import generate_filter_params_openapi, urlsafe_short_hash
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.utils.exchange_rates import (
|
from lnbits.utils.exchange_rates import (
|
||||||
fiat_amount_as_satoshis,
|
fiat_amount_as_satoshis,
|
||||||
|
|
@ -34,6 +38,7 @@ from lnbits.utils.exchange_rates import (
|
||||||
satoshis_amount_as_fiat,
|
satoshis_amount_as_fiat,
|
||||||
)
|
)
|
||||||
from lnbits.utils.nostr import normalize_public_key
|
from lnbits.utils.nostr import normalize_public_key
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
from .crud import (
|
from .crud import (
|
||||||
create_event,
|
create_event,
|
||||||
|
|
@ -53,24 +58,38 @@ from .crud import (
|
||||||
get_tickets_by_event,
|
get_tickets_by_event,
|
||||||
get_tickets_by_payment_hash,
|
get_tickets_by_payment_hash,
|
||||||
get_tickets_by_user_id,
|
get_tickets_by_user_id,
|
||||||
|
get_tickets_paginated,
|
||||||
purge_unpaid_tickets,
|
purge_unpaid_tickets,
|
||||||
update_event,
|
update_event,
|
||||||
update_settings,
|
update_settings,
|
||||||
update_ticket,
|
update_ticket,
|
||||||
)
|
)
|
||||||
from .models import (
|
from .models import (
|
||||||
|
BasketTotals,
|
||||||
CreateEvent,
|
CreateEvent,
|
||||||
CreateTicket,
|
CreateTicket,
|
||||||
Event,
|
Event,
|
||||||
EventsSettings,
|
EventsSettings,
|
||||||
|
PromoValidateRequest,
|
||||||
PublicEvent,
|
PublicEvent,
|
||||||
PublicTicket,
|
PublicTicket,
|
||||||
Ticket,
|
Ticket,
|
||||||
|
TicketFilters,
|
||||||
TicketPaymentRequest,
|
TicketPaymentRequest,
|
||||||
TicketResendResult,
|
TicketResendResult,
|
||||||
|
TicketWave,
|
||||||
effective_payment_methods,
|
effective_payment_methods,
|
||||||
|
ensure_ticket_waves,
|
||||||
|
get_active_ticket_waves,
|
||||||
)
|
)
|
||||||
from .nostr_hooks import publish_or_delete_nostr_event
|
from .nostr_hooks import publish_or_delete_nostr_event
|
||||||
|
from .promo import (
|
||||||
|
basket_totals,
|
||||||
|
find_promo,
|
||||||
|
normalize_code,
|
||||||
|
remaining_uses,
|
||||||
|
round_amount,
|
||||||
|
)
|
||||||
from .qr import (
|
from .qr import (
|
||||||
image_png_bytes,
|
image_png_bytes,
|
||||||
load_qr_logo,
|
load_qr_logo,
|
||||||
|
|
@ -79,16 +98,23 @@ from .qr import (
|
||||||
ticket_card_filename,
|
ticket_card_filename,
|
||||||
)
|
)
|
||||||
from .services import (
|
from .services import (
|
||||||
|
event_promo_usage,
|
||||||
|
hydrate_promo_usage,
|
||||||
refund_tickets,
|
refund_tickets,
|
||||||
resend_ticket_email_notification,
|
resend_ticket_email_notification,
|
||||||
send_ticket_notification_in_background,
|
send_ticket_notification_in_background,
|
||||||
set_ticket_paid,
|
set_ticket_paid,
|
||||||
)
|
)
|
||||||
from .tasks import deregister_payment_listener, register_payment_listener
|
from .tasks import (
|
||||||
|
deregister_payment_listener,
|
||||||
|
register_payment_listener,
|
||||||
|
)
|
||||||
|
|
||||||
events_api_router = APIRouter(prefix="/api/v1/events")
|
events_api_router = APIRouter(prefix="/api/v1/events")
|
||||||
tickets_api_router = APIRouter(prefix="/api/v1/tickets")
|
tickets_api_router = APIRouter(prefix="/api/v1/tickets")
|
||||||
qr_api_router = APIRouter(prefix="/api/v1")
|
qr_api_router = APIRouter(prefix="/api/v1")
|
||||||
|
promo_api_router = APIRouter(prefix="/api/v1/promo")
|
||||||
|
tickets_filters = parse_filters(TicketFilters)
|
||||||
|
|
||||||
|
|
||||||
def _is_fiat_currency(currency: str | None) -> bool:
|
def _is_fiat_currency(currency: str | None) -> bool:
|
||||||
|
|
@ -97,8 +123,6 @@ def _is_fiat_currency(currency: str | None) -> bool:
|
||||||
|
|
||||||
# Literal-prefix routes (/public, /all, /pending, /settings) MUST be declared
|
# Literal-prefix routes (/public, /all, /pending, /settings) MUST be declared
|
||||||
# before any "/{event_id}" route or FastAPI matches them as a path parameter.
|
# before any "/{event_id}" route or FastAPI matches them as a path parameter.
|
||||||
|
|
||||||
|
|
||||||
@events_api_router.get("")
|
@events_api_router.get("")
|
||||||
async def api_events(
|
async def api_events(
|
||||||
all_wallets: bool = Query(False),
|
all_wallets: bool = Query(False),
|
||||||
|
|
@ -108,12 +132,17 @@ async def api_events(
|
||||||
if all_wallets:
|
if all_wallets:
|
||||||
user = await get_user(wallet.wallet.user)
|
user = await get_user(wallet.wallet.user)
|
||||||
wallet_ids = user.wallet_ids if user else []
|
wallet_ids = user.wallet_ids if user else []
|
||||||
return await get_events(wallet_ids)
|
events = await get_events(wallet_ids)
|
||||||
|
for event in events:
|
||||||
|
await hydrate_promo_usage(event)
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
@events_api_router.get("/public")
|
@events_api_router.get("/public", response_model=list[PublicEvent])
|
||||||
async def api_events_public() -> list[Event]:
|
async def api_events_public() -> list[Event]:
|
||||||
"""Approved, non-canceled events for an anonymous public listing."""
|
"""Approved, non-canceled events for an anonymous public listing.
|
||||||
|
|
||||||
|
Projected through `PublicEvent`: no wallet id, no promo codes."""
|
||||||
return await get_public_events()
|
return await get_public_events()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -131,6 +160,7 @@ async def api_events_all(
|
||||||
events = await get_all_events()
|
events = await get_all_events()
|
||||||
enriched: list[dict] = []
|
enriched: list[dict] = []
|
||||||
for event in events:
|
for event in events:
|
||||||
|
await hydrate_promo_usage(event)
|
||||||
wallet = await get_wallet(event.wallet)
|
wallet = await get_wallet(event.wallet)
|
||||||
row = event.dict()
|
row = event.dict()
|
||||||
row["wallet_user_id"] = wallet.user if wallet else None
|
row["wallet_user_id"] = wallet.user if wallet else None
|
||||||
|
|
@ -255,33 +285,80 @@ async def api_get_event(event_id: str) -> Event:
|
||||||
closing_date = event.closing_date or event.event_end_date or event.event_start_date
|
closing_date = event.closing_date or event.event_end_date or event.event_start_date
|
||||||
# Accept either YYYY-MM-DD or full ISO 8601 datetime (event_end_date
|
# Accept either YYYY-MM-DD or full ISO 8601 datetime (event_end_date
|
||||||
# may carry a time component since v1.3.0-aio.3 / our start-end-time
|
# may carry a time component since v1.3.0-aio.3 / our start-end-time
|
||||||
# feature).
|
# feature). Upstream v1.6.8 parses closing_date with a bare
|
||||||
|
# strptime("%Y-%m-%d") here; that raises ValueError on any event of ours
|
||||||
|
# whose closing date carries a time, which is most of them.
|
||||||
try:
|
try:
|
||||||
closing_dt = datetime.fromisoformat(closing_date)
|
closing_dt = datetime.fromisoformat(closing_date)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
closing_dt = datetime.strptime(closing_date[:10], "%Y-%m-%d")
|
closing_dt = datetime.strptime(closing_date[:10], "%Y-%m-%d")
|
||||||
if closing_dt.tzinfo is None:
|
if closing_dt.tzinfo is None:
|
||||||
closing_dt = closing_dt.replace(tzinfo=timezone.utc)
|
closing_dt = closing_dt.replace(tzinfo=timezone.utc)
|
||||||
is_window_open = datetime.now(timezone.utc) < closing_dt
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
today = now.date()
|
||||||
|
active_waves = get_active_ticket_waves(event, today)
|
||||||
|
# `is_sales_closed` is upstream's name for `not is_window_open`; the
|
||||||
|
# comparison stays ours (instant-precise) rather than upstream's
|
||||||
|
# whole-day `today > closing_date.date()`. Upstream's form keeps sales
|
||||||
|
# open for the whole of the closing day, which for our datetime-bearing
|
||||||
|
# closing dates would extend every existing event's sales window.
|
||||||
|
is_sales_closed = now >= closing_dt
|
||||||
is_min_tickets_met = (
|
is_min_tickets_met = (
|
||||||
event.sold >= event.extra.min_tickets if event.extra.conditional else True
|
event.sold >= event.extra.min_tickets if event.extra.conditional else True
|
||||||
)
|
)
|
||||||
if event.amount_tickets < 1:
|
if event.amount_tickets < 1:
|
||||||
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
|
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
|
||||||
if event.extra.conditional and not is_min_tickets_met and not is_window_open:
|
if event.extra.conditional and not is_min_tickets_met and is_sales_closed:
|
||||||
event.canceled = True
|
event.canceled = True
|
||||||
await update_event(event)
|
await update_event(event)
|
||||||
await refund_tickets(event_id)
|
await refund_tickets(event_id)
|
||||||
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.")
|
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.")
|
||||||
|
|
||||||
if not is_window_open:
|
if not active_waves:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.GONE, detail="Ticket closing date has passed."
|
status_code=HTTPStatus.GONE,
|
||||||
|
detail=(
|
||||||
|
"Ticket closing date has passed."
|
||||||
|
if is_sales_closed
|
||||||
|
else "No ticket wave is currently open."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_wave_capacity(data: CreateEvent, existing: Event | None = None) -> None:
|
||||||
|
"""Every ticket wave must state a real capacity.
|
||||||
|
|
||||||
|
"Capacity is always required, there is no unlimited" (#34, #62) was
|
||||||
|
settled when capacity was a single 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 be enforced where the organiser actually sets it — otherwise it
|
||||||
|
only survives as a `min="1"` on one HTML input, which no API client is
|
||||||
|
bound by.
|
||||||
|
|
||||||
|
A zero-capacity wave can never be active (`get_active_ticket_waves`
|
||||||
|
requires `amount_tickets > 0`), so it is the wave-level form of exactly
|
||||||
|
what #62 removed: an event that looks on sale but refuses every
|
||||||
|
purchase.
|
||||||
|
|
||||||
|
Selling out is the one legitimate route to zero, so an edit only checks
|
||||||
|
waves that are NEW to the event; waves already stored keep whatever
|
||||||
|
sales decremented them to. `ensure_ticket_waves` is used rather than the
|
||||||
|
raw list so an event submitted with no waves is checked through the
|
||||||
|
primary wave it will be given.
|
||||||
|
"""
|
||||||
|
known = {wave.id for wave in (existing.extra.ticket_waves if existing else [])}
|
||||||
|
for wave in ensure_ticket_waves(data):
|
||||||
|
if wave.id in known or wave.amount_tickets >= 1:
|
||||||
|
continue
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail=f"Ticket wave '{wave.title}' needs a capacity of at least 1.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@events_api_router.post("")
|
@events_api_router.post("")
|
||||||
async def api_event_create(
|
async def api_event_create(
|
||||||
data: CreateEvent,
|
data: CreateEvent,
|
||||||
|
|
@ -295,6 +372,8 @@ async def api_event_create(
|
||||||
if not data.wallet:
|
if not data.wallet:
|
||||||
data.wallet = wallet.wallet.id
|
data.wallet = wallet.wallet.id
|
||||||
|
|
||||||
|
_validate_wave_capacity(data)
|
||||||
|
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
ext_settings = await get_settings()
|
ext_settings = await get_settings()
|
||||||
|
|
@ -337,6 +416,22 @@ async def api_event_update(
|
||||||
if event.wallet != wallet.wallet.id:
|
if event.wallet != wallet.wallet.id:
|
||||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.")
|
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.")
|
||||||
|
|
||||||
|
# Carry the stored waves over unless the request names the key. `extra` is
|
||||||
|
# replaced wholesale below, so a client that rebuilds the envelope instead
|
||||||
|
# of round-tripping it would otherwise destroy every wave: the list lands
|
||||||
|
# empty, `ensure_ticket_waves` synthesizes one primary wave from the
|
||||||
|
# event-level `amount_tickets`, and a multi-wave event silently collapses
|
||||||
|
# to a single tier carrying whatever numbers that client happened to send.
|
||||||
|
# Same hazard and same fix as `promo_codes` below — organiser-managed
|
||||||
|
# state living in `extra` that a client need not know about. Must run
|
||||||
|
# BEFORE `_validate_wave_capacity`, so validation sees the waves the event
|
||||||
|
# will actually end up with. An explicit `[]` still resets, as it does for
|
||||||
|
# promo codes.
|
||||||
|
if "ticket_waves" not in data.extra.__fields_set__:
|
||||||
|
data.extra.ticket_waves = event.extra.ticket_waves
|
||||||
|
|
||||||
|
_validate_wave_capacity(data, event)
|
||||||
|
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
ext_settings = await get_settings()
|
ext_settings = await get_settings()
|
||||||
|
|
@ -353,6 +448,13 @@ async def api_event_update(
|
||||||
if not data.closing_date:
|
if not data.closing_date:
|
||||||
data.closing_date = data.event_end_date
|
data.closing_date = data.event_end_date
|
||||||
|
|
||||||
|
# Promo codes are organizer-only and absent from public responses, so a
|
||||||
|
# client that round-trips a public record (or simply doesn't manage
|
||||||
|
# codes) would otherwise wipe them on every edit. Carry the stored list
|
||||||
|
# over unless the request names the key; an explicit `[]` still clears.
|
||||||
|
if "promo_codes" not in data.extra.__fields_set__:
|
||||||
|
data.extra.promo_codes = event.extra.promo_codes
|
||||||
|
|
||||||
# Explicit field list — never copy `status` from the request body.
|
# Explicit field list — never copy `status` from the request body.
|
||||||
# Includes upstream v1.6.1 fields (allow_fiat, fiat_currency) so an
|
# Includes upstream v1.6.1 fields (allow_fiat, fiat_currency) so an
|
||||||
# owner editing a fiat-enabled event keeps the fiat config.
|
# owner editing a fiat-enabled event keeps the fiat config.
|
||||||
|
|
@ -511,6 +613,31 @@ async def api_tickets_by_user(
|
||||||
return await get_tickets_by_user_id(user_id)
|
return await get_tickets_by_user_id(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@tickets_api_router.get(
|
||||||
|
"/paginated",
|
||||||
|
summary="Get paginated list of tickets",
|
||||||
|
openapi_extra=generate_filter_params_openapi(TicketFilters),
|
||||||
|
response_model=Page[Ticket],
|
||||||
|
)
|
||||||
|
async def api_tickets_paginated(
|
||||||
|
all_wallets: bool = Query(False),
|
||||||
|
filters: Filters = Depends(tickets_filters),
|
||||||
|
key_info: WalletTypeInfo = Depends(require_admin_key),
|
||||||
|
) -> Page[Ticket]:
|
||||||
|
wallet_ids = [key_info.wallet.id]
|
||||||
|
|
||||||
|
if all_wallets:
|
||||||
|
user = await get_user(key_info.wallet.user)
|
||||||
|
wallet_ids = user.wallet_ids if user else []
|
||||||
|
|
||||||
|
if not filters.sortby:
|
||||||
|
filters.sortby = "time"
|
||||||
|
if not filters.direction:
|
||||||
|
filters.direction = "desc"
|
||||||
|
|
||||||
|
return await get_tickets_paginated(wallet_ids, filters)
|
||||||
|
|
||||||
|
|
||||||
@tickets_api_router.get("/{ticket_id}", response_model=PublicTicket)
|
@tickets_api_router.get("/{ticket_id}", response_model=PublicTicket)
|
||||||
async def api_get_ticket(ticket_id: str) -> Ticket:
|
async def api_get_ticket(ticket_id: str) -> Ticket:
|
||||||
ticket = await get_ticket(ticket_id)
|
ticket = await get_ticket(ticket_id)
|
||||||
|
|
@ -572,6 +699,36 @@ def _resolve_frontend_root(data: CreateTicket, request: Request) -> str:
|
||||||
return data.frontend_url.rstrip("/")
|
return data.frontend_url.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_ticket_wave(event: Event, ticket_wave_id: str | None) -> TicketWave:
|
||||||
|
"""The wave a purchase or a price preview is priced against.
|
||||||
|
|
||||||
|
Shared by the purchase and promo-validate endpoints so the two cannot
|
||||||
|
drift: whatever wave the preview quoted is the wave the invoice charges.
|
||||||
|
Selection is upstream v1.6.8's — an explicit id must be an OPEN wave, a
|
||||||
|
single open wave is implied, and an ambiguous choice is an error rather
|
||||||
|
than a silent pick.
|
||||||
|
"""
|
||||||
|
active_waves = get_active_ticket_waves(event)
|
||||||
|
if not active_waves:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.GONE, detail="No ticket wave is currently open."
|
||||||
|
)
|
||||||
|
if ticket_wave_id:
|
||||||
|
wave = next((wave for wave in active_waves if wave.id == ticket_wave_id), None)
|
||||||
|
if not wave:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail="Invalid ticket wave selected.",
|
||||||
|
)
|
||||||
|
return wave
|
||||||
|
if len(active_waves) == 1:
|
||||||
|
return active_waves[0]
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail="Please select a ticket wave.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _issue_free_tickets(
|
async def _issue_free_tickets(
|
||||||
*,
|
*,
|
||||||
event: Event,
|
event: Event,
|
||||||
|
|
@ -582,6 +739,7 @@ async def _issue_free_tickets(
|
||||||
promo_code: str | None,
|
promo_code: str | None,
|
||||||
nostr_identifier: str | None,
|
nostr_identifier: str | None,
|
||||||
frontend_root: str,
|
frontend_root: str,
|
||||||
|
selected_wave: TicketWave,
|
||||||
) -> TicketPaymentRequest:
|
) -> TicketPaymentRequest:
|
||||||
"""Issue `quantity` free tickets without minting an invoice.
|
"""Issue `quantity` free tickets without minting an invoice.
|
||||||
|
|
||||||
|
|
@ -610,6 +768,11 @@ async def _issue_free_tickets(
|
||||||
ticket_id=row_id,
|
ticket_id=row_id,
|
||||||
extra={
|
extra={
|
||||||
"applied_promo_code": promo_code,
|
"applied_promo_code": promo_code,
|
||||||
|
# Free tickets consume wave stock exactly like paid ones —
|
||||||
|
# `set_ticket_paid` runs on this path too — so they must name
|
||||||
|
# their wave or the decrement lands on the primary wave.
|
||||||
|
"ticket_wave_id": selected_wave.id,
|
||||||
|
"ticket_wave_title": selected_wave.title,
|
||||||
"nostr_identifier": nostr_identifier,
|
"nostr_identifier": nostr_identifier,
|
||||||
"ticket_base_url": frontend_root,
|
"ticket_base_url": frontend_root,
|
||||||
"sats_paid": 0,
|
"sats_paid": 0,
|
||||||
|
|
@ -645,22 +808,23 @@ async def api_ticket_create(
|
||||||
if event.canceled:
|
if event.canceled:
|
||||||
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.")
|
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.")
|
||||||
quantity = data.quantity
|
quantity = data.quantity
|
||||||
if event.amount_tickets > 0:
|
# `amount_tickets` IS the remaining count — `set_ticket_paid` decrements
|
||||||
if event.sold >= event.amount_tickets:
|
# it on every sale, and upstream reads it the same way everywhere. Do
|
||||||
raise HTTPException(
|
# not subtract `sold` from it: `sold` counts the same tickets the
|
||||||
status_code=HTTPStatus.GONE, detail="Event is sold out."
|
# decrement already removed, so doing both takes each sale off twice
|
||||||
)
|
# (aiolabs/events#34).
|
||||||
remaining = event.amount_tickets - event.sold
|
if event.amount_tickets < 1:
|
||||||
if quantity > remaining:
|
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
|
||||||
raise HTTPException(
|
if quantity > event.amount_tickets:
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
raise HTTPException(
|
||||||
detail=f"Only {remaining} ticket(s) remaining for this event.",
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
)
|
detail=f"Only {event.amount_tickets} ticket(s) remaining for this event.",
|
||||||
|
)
|
||||||
|
|
||||||
name = data.name
|
name = data.name
|
||||||
email = data.email
|
email = data.email
|
||||||
user_id = data.user_id
|
user_id = data.user_id
|
||||||
promo_code = data.promo_code.upper() if data.promo_code else None
|
promo_code = normalize_code(data.promo_code)
|
||||||
refund_address = data.refund_address
|
refund_address = data.refund_address
|
||||||
nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None
|
nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None
|
||||||
payment_method = (data.payment_method or "lightning").lower()
|
payment_method = (data.payment_method or "lightning").lower()
|
||||||
|
|
@ -669,6 +833,11 @@ async def api_ticket_create(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail="Unsupported payment method.",
|
detail="Unsupported payment method.",
|
||||||
)
|
)
|
||||||
|
# npub or NIP-05, both normalised to a hex pubkey. v1.6.8 replaced this
|
||||||
|
# with a hard "Only NIP-05 Nostr identifiers are supported." rejection —
|
||||||
|
# true for upstream, false here: `_send_nostr_ticket_notification` sends
|
||||||
|
# bare pubkeys via `send_nostr_dm`. That rejection merged in outside any
|
||||||
|
# conflict marker, so it would have silently dropped npub checkout.
|
||||||
if nostr_identifier and "@" not in nostr_identifier:
|
if nostr_identifier and "@" not in nostr_identifier:
|
||||||
try:
|
try:
|
||||||
nostr_identifier = normalize_public_key(nostr_identifier)
|
nostr_identifier = normalize_public_key(nostr_identifier)
|
||||||
|
|
@ -677,22 +846,50 @@ async def api_ticket_create(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail="Invalid Nostr identifier.",
|
detail="Invalid Nostr identifier.",
|
||||||
) from exc
|
) from exc
|
||||||
unit_price = event.price_per_ticket
|
|
||||||
|
selected_wave = _resolve_ticket_wave(event, data.ticket_wave_id)
|
||||||
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
|
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
|
||||||
frontend_root = _resolve_frontend_root(data, request)
|
frontend_root = _resolve_frontend_root(data, request)
|
||||||
|
|
||||||
|
# One invoice, N tickets; the promo (if any) prices the whole quantity
|
||||||
|
# through the same `basket_totals` the validate endpoint uses, so the
|
||||||
|
# preview a buyer saw is what gets charged. Unlike validate, a bad code
|
||||||
|
# is a hard error here — silently charging full price would be worse.
|
||||||
if promo_code:
|
if promo_code:
|
||||||
# check if promo_code exists in event.extra.promo_codes
|
promo = find_promo(event, promo_code)
|
||||||
if promo_code not in [pc.code for pc in event.extra.promo_codes]:
|
if not promo:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code."
|
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code."
|
||||||
)
|
)
|
||||||
# get the promocode
|
if not promo.active:
|
||||||
promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code)
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail="Promo code is not active.",
|
||||||
|
)
|
||||||
|
usage = await event_promo_usage(event.id)
|
||||||
|
uses_left = remaining_uses(promo, usage.get(promo.code, 0))
|
||||||
|
if uses_left is not None and uses_left < quantity:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail=(
|
||||||
|
"Promo code has been fully redeemed."
|
||||||
|
if uses_left == 0
|
||||||
|
else f"Only {uses_left} use(s) left on this promo code."
|
||||||
|
),
|
||||||
|
)
|
||||||
extra["promo_code"] = promo.code
|
extra["promo_code"] = promo.code
|
||||||
unit_price = event.price_per_ticket * (1 - promo.discount_percent / 100)
|
# Priced off `selected_wave`, not `event`: since v1.6.8 the price and
|
||||||
# Scale by quantity AFTER the promo applies. One invoice, N tickets.
|
# currency live on the wave, and the event-level fields are a roll-up
|
||||||
price = unit_price * quantity
|
# of the PRIMARY wave (`sync_event_ticket_waves`). Pricing off the
|
||||||
|
# event charges every buyer the first wave's price whichever wave they
|
||||||
|
# actually picked. Upstream prices one ticket; `basket_totals` keeps
|
||||||
|
# our quantity + promo arithmetic, so the "Apply" preview and the
|
||||||
|
# invoice still agree.
|
||||||
|
price = basket_totals(event, [promo.code], quantity, usage, selected_wave).total
|
||||||
|
else:
|
||||||
|
price = round_amount(
|
||||||
|
selected_wave.price_per_ticket * quantity, selected_wave.currency
|
||||||
|
)
|
||||||
|
|
||||||
# Free tickets (final charge 0 — a free event or a 100%-off promo).
|
# Free tickets (final charge 0 — a free event or a 100%-off promo).
|
||||||
# Short-circuit before any invoice / fiat-provider logic: no Lightning
|
# Short-circuit before any invoice / fiat-provider logic: no Lightning
|
||||||
|
|
@ -708,6 +905,16 @@ async def api_ticket_create(
|
||||||
promo_code=promo_code,
|
promo_code=promo_code,
|
||||||
nostr_identifier=nostr_identifier,
|
nostr_identifier=nostr_identifier,
|
||||||
frontend_root=frontend_root,
|
frontend_root=frontend_root,
|
||||||
|
selected_wave=selected_wave,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fiat is a per-wave opt-in since v1.6.8. `effective_payment_methods`
|
||||||
|
# below reads `event.allow_fiat`, which is only the PRIMARY wave's, so
|
||||||
|
# this check is what actually protects a non-fiat wave.
|
||||||
|
if payment_method == "fiat" and not selected_wave.allow_fiat:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail="Fiat payments are not enabled for this ticket wave.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Organizer-controlled rails (extra.payment_methods; legacy events fall
|
# Organizer-controlled rails (extra.payment_methods; legacy events fall
|
||||||
|
|
@ -719,23 +926,24 @@ async def api_ticket_create(
|
||||||
detail="Payment method not enabled for this event.",
|
detail="Payment method not enabled for this event.",
|
||||||
)
|
)
|
||||||
|
|
||||||
if _is_fiat_currency(event.currency):
|
if _is_fiat_currency(selected_wave.currency):
|
||||||
extra["fiat"] = True
|
extra["fiat"] = True
|
||||||
extra["currency"] = event.currency
|
extra["currency"] = selected_wave.currency
|
||||||
extra["fiatAmount"] = price
|
extra["fiatAmount"] = price
|
||||||
extra["rate"] = await get_fiat_rate_satoshis(event.currency)
|
extra["rate"] = await get_fiat_rate_satoshis(selected_wave.currency)
|
||||||
|
|
||||||
if payment_method != "fiat":
|
if payment_method != "fiat":
|
||||||
price = await fiat_amount_as_satoshis(price, event.currency)
|
price = await fiat_amount_as_satoshis(price, selected_wave.currency)
|
||||||
|
|
||||||
invoice_unit = event.currency
|
invoice_unit = selected_wave.currency
|
||||||
fiat_amount = price
|
fiat_amount = price
|
||||||
fiat_provider = None
|
fiat_provider = None
|
||||||
|
|
||||||
if payment_method == "fiat":
|
if payment_method == "fiat":
|
||||||
if _is_fiat_currency(event.currency):
|
if _is_fiat_currency(selected_wave.currency):
|
||||||
invoice_unit = event.currency
|
invoice_unit = selected_wave.currency
|
||||||
else:
|
else:
|
||||||
invoice_unit = event.fiat_currency
|
invoice_unit = selected_wave.fiat_currency
|
||||||
fiat_amount = await satoshis_amount_as_fiat(price, invoice_unit)
|
fiat_amount = await satoshis_amount_as_fiat(price, invoice_unit)
|
||||||
extra["fiat"] = True
|
extra["fiat"] = True
|
||||||
extra["currency"] = invoice_unit
|
extra["currency"] = invoice_unit
|
||||||
|
|
@ -784,6 +992,7 @@ async def api_ticket_create(
|
||||||
"event_id": event.id,
|
"event_id": event.id,
|
||||||
"quantity": str(quantity),
|
"quantity": str(quantity),
|
||||||
"ticket_ids": ",".join(ticket_ids),
|
"ticket_ids": ",".join(ticket_ids),
|
||||||
|
**({"promo_code": promo_code} if promo_code else {}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -810,6 +1019,12 @@ async def api_ticket_create(
|
||||||
ticket_id=row_id,
|
ticket_id=row_id,
|
||||||
extra={
|
extra={
|
||||||
"applied_promo_code": promo_code,
|
"applied_promo_code": promo_code,
|
||||||
|
# Which wave this ticket came from. `set_ticket_paid` debits
|
||||||
|
# the wave named here and falls back to waves[0] when it is
|
||||||
|
# absent, so omitting it would take every sale off the
|
||||||
|
# primary wave no matter what the buyer bought.
|
||||||
|
"ticket_wave_id": selected_wave.id,
|
||||||
|
"ticket_wave_title": selected_wave.title,
|
||||||
"refund_address": refund_address,
|
"refund_address": refund_address,
|
||||||
"nostr_identifier": nostr_identifier,
|
"nostr_identifier": nostr_identifier,
|
||||||
"ticket_base_url": frontend_root,
|
"ticket_base_url": frontend_root,
|
||||||
|
|
@ -913,7 +1128,9 @@ async def api_ticket_delete(
|
||||||
|
|
||||||
@tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult)
|
@tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult)
|
||||||
async def api_ticket_resend_email(
|
async def api_ticket_resend_email(
|
||||||
ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
ticket_id: str,
|
||||||
|
request: Request,
|
||||||
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> TicketResendResult:
|
) -> TicketResendResult:
|
||||||
ticket = await get_ticket(ticket_id)
|
ticket = await get_ticket(ticket_id)
|
||||||
if not ticket:
|
if not ticket:
|
||||||
|
|
@ -931,16 +1148,13 @@ async def api_ticket_resend_email(
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return await resend_ticket_email_notification(ticket)
|
return await resend_ticket_email_notification(
|
||||||
|
ticket, str(request.base_url).rstrip("/")
|
||||||
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
|
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
|
||||||
) from exc
|
) from exc
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Failed to resend ticket email.",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@tickets_api_router.put("/register/{ticket_id}")
|
@tickets_api_router.put("/register/{ticket_id}")
|
||||||
|
|
@ -1042,6 +1256,7 @@ async def api_event_ticket_stats(
|
||||||
"registered_at": (
|
"registered_at": (
|
||||||
t.reg_timestamp.isoformat() if t.reg_timestamp else None
|
t.reg_timestamp.isoformat() if t.reg_timestamp else None
|
||||||
),
|
),
|
||||||
|
"applied_promo_code": t.extra.applied_promo_code,
|
||||||
}
|
}
|
||||||
for t in paid_tickets
|
for t in paid_tickets
|
||||||
],
|
],
|
||||||
|
|
@ -1050,28 +1265,76 @@ async def api_event_ticket_stats(
|
||||||
|
|
||||||
@qr_api_router.get("/qr/{ticket_id}", response_class=StreamingResponse)
|
@qr_api_router.get("/qr/{ticket_id}", response_class=StreamingResponse)
|
||||||
async def api_ticket_qr(ticket_id: str):
|
async def api_ticket_qr(ticket_id: str):
|
||||||
"""PNG of the ticket's scan payload (`ticket://<id>`), branded with the
|
"""PNG of the ticket's scan payload (`ticket://<id>`).
|
||||||
instance QR logo. Anonymous by design — it is what the ticket email
|
|
||||||
embeds — and the id is the same bearer token the ticket page exposes.
|
Two shapes behind one route. Before the v1.6.8 merge this endpoint
|
||||||
Port of upstream v1.6.8 without ticket-image compositing."""
|
existed on both sides — ours was "upstream's, without ticket-image
|
||||||
|
compositing", theirs added the compositing but dropped the logo. They
|
||||||
|
are the same endpoint, so they are merged rather than registered twice
|
||||||
|
(FastAPI serves whichever route is declared first, so the second copy
|
||||||
|
would have been silently unreachable):
|
||||||
|
|
||||||
|
- wave opted into `use_ticket_image`: upstream's composite — the QR
|
||||||
|
pasted onto the organiser's uploaded template, or the bundled
|
||||||
|
default. QR size and paste coordinates are upstream's and are tied
|
||||||
|
to each other; no logo, because the template carries the branding.
|
||||||
|
- otherwise: our standalone QR at 300px with the instance logo.
|
||||||
|
|
||||||
|
Anonymous by design — it is what the ticket email links to — and the id
|
||||||
|
is the same bearer token the ticket page exposes.
|
||||||
|
"""
|
||||||
ticket = await get_ticket(ticket_id)
|
ticket = await get_ticket(ticket_id)
|
||||||
if not ticket:
|
if not ticket:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
|
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."
|
||||||
|
)
|
||||||
|
|
||||||
logo = await load_qr_logo()
|
headers = {
|
||||||
image = make_qr_png(f"ticket://{ticket_id}", size=300, logo=logo)
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||||
return StreamingResponse(
|
"Pragma": "no-cache",
|
||||||
BytesIO(image_png_bytes(image)),
|
"Expires": "0",
|
||||||
media_type="image/png",
|
}
|
||||||
headers={
|
|
||||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
waves = ensure_ticket_waves(event)
|
||||||
"Pragma": "no-cache",
|
wave = next(
|
||||||
"Expires": "0",
|
(wave for wave in waves if wave.id == ticket.extra.ticket_wave_id),
|
||||||
},
|
waves[0],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not wave.use_ticket_image:
|
||||||
|
logo = await load_qr_logo()
|
||||||
|
image = make_qr_png(f"ticket://{ticket_id}", size=300, logo=logo)
|
||||||
|
return StreamingResponse(
|
||||||
|
BytesIO(image_png_bytes(image)),
|
||||||
|
media_type="image/png",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
background_bytes = None
|
||||||
|
if wave.ticket_image_id:
|
||||||
|
asset = await get_public_asset(wave.ticket_image_id)
|
||||||
|
if asset:
|
||||||
|
background_bytes = asset.data
|
||||||
|
|
||||||
|
if background_bytes:
|
||||||
|
ticket_image = Image.open(BytesIO(background_bytes)).convert("RGBA")
|
||||||
|
else:
|
||||||
|
default_template = (
|
||||||
|
Path(__file__).resolve().parent / "static" / "image" / "ticket.jpg"
|
||||||
|
)
|
||||||
|
ticket_image = Image.open(default_template).convert("RGBA")
|
||||||
|
|
||||||
|
ticket_image.paste(make_qr_png(f"ticket://{ticket_id}", size=157), (122, 505))
|
||||||
|
output = BytesIO()
|
||||||
|
ticket_image.save(output, format="PNG")
|
||||||
|
output.seek(0)
|
||||||
|
return StreamingResponse(output, media_type="image/png", headers=headers)
|
||||||
|
|
||||||
|
|
||||||
@qr_api_router.get("/ticket-card/{ticket_id}", response_class=StreamingResponse)
|
@qr_api_router.get("/ticket-card/{ticket_id}", response_class=StreamingResponse)
|
||||||
async def api_ticket_card(ticket_id: str):
|
async def api_ticket_card(ticket_id: str):
|
||||||
|
|
@ -1100,3 +1363,24 @@ async def api_ticket_card(ticket_id: str):
|
||||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@promo_api_router.post("/validate/{event_id}")
|
||||||
|
async def api_validate_promo_codes(
|
||||||
|
event_id: str, data: PromoValidateRequest
|
||||||
|
) -> BasketTotals:
|
||||||
|
"""Price a purchase with the given codes without committing to it —
|
||||||
|
what the buyer sees before paying. Anonymous and advisory: a code that
|
||||||
|
is unknown / inactive / exhausted is simply absent from
|
||||||
|
`discounts_applied`; the purchase endpoint is where hard errors live.
|
||||||
|
Same URL as upstream v2 (`quantity` instead of v2's `items`)."""
|
||||||
|
event = await get_event(event_id)
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
||||||
|
)
|
||||||
|
usage = await event_promo_usage(event_id) if event.extra.promo_codes else {}
|
||||||
|
# Same resolver the purchase endpoint uses, so the quote and the charge
|
||||||
|
# are priced against the same wave.
|
||||||
|
wave = _resolve_ticket_wave(event, data.ticket_wave_id)
|
||||||
|
return basket_totals(event, data.codes, data.quantity, usage, wave)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue