Compare commits
No commits in common. "main" and "v1.6.1-aio.10" have entirely different histories.
main
...
v1.6.1-aio
32 changed files with 375 additions and 4429 deletions
|
|
@ -62,13 +62,6 @@ Events includes a shareable ticket scanner, which can be used to register attend
|
|||
- **Stripe session.** The buyer's email is passed as `customer_email`
|
||||
(prefilled and locked on the hosted page); the line item is named after the
|
||||
event; `event_id`, `quantity` and `ticket_ids` ride along as metadata.
|
||||
- **Promo codes.** `extra.promo_codes` (`code`, `discount_percent`, `active`,
|
||||
`max_uses`; `used_count` is derived from paid tickets) are organizer-only: they are
|
||||
never part of public responses. Buyers preview a code with
|
||||
`POST /events/api/v1/promo/validate/{event_id}` (`{codes, quantity}` → v2-shaped
|
||||
`BasketTotals` + `currency`); purchase enforces `active` and `max_uses` (each ticket
|
||||
of a multi-ticket purchase consumes one use) and rejects bad codes with a distinct
|
||||
`detail`. Updates that omit `extra.promo_codes` keep the stored list.
|
||||
- **Email.** Multipart text + HTML (links, no images) with the **ticket card**
|
||||
attached — a self-describing PNG (site, event, when, where, QR with the
|
||||
instance logo, name on ticket, ticket id) also served at
|
||||
|
|
|
|||
67
__init__.py
67
__init__.py
|
|
@ -6,19 +6,13 @@ from loguru import logger
|
|||
from .crud import db
|
||||
from .tasks import wait_for_paid_invoices
|
||||
from .views import events_generic_router
|
||||
from .views_api import (
|
||||
events_api_router,
|
||||
promo_api_router,
|
||||
qr_api_router,
|
||||
tickets_api_router,
|
||||
)
|
||||
from .views_api import events_api_router, qr_api_router, tickets_api_router
|
||||
|
||||
events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"])
|
||||
events_ext.include_router(events_generic_router)
|
||||
events_ext.include_router(events_api_router)
|
||||
events_ext.include_router(tickets_api_router)
|
||||
events_ext.include_router(qr_api_router)
|
||||
events_ext.include_router(promo_api_router)
|
||||
|
||||
events_static_files = [
|
||||
{
|
||||
|
|
@ -34,15 +28,6 @@ scheduled_tasks: list[asyncio.Task] = []
|
|||
# from nostr_hooks.publish_or_delete_nostr_event.
|
||||
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():
|
||||
for task in scheduled_tasks:
|
||||
|
|
@ -126,55 +111,5 @@ def events_start():
|
|||
task3 = create_permanent_unique_task("ext_events_nostr_sync", _sync_nostr_events)
|
||||
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"]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"id": "events",
|
||||
"version": "1.6.8-aio.4",
|
||||
"version": "1.6.1-aio.10",
|
||||
"name": "Events",
|
||||
"repo": "https://git.atitlan.io/aiolabs/events",
|
||||
"short_description": "Sell and register event tickets",
|
||||
|
|
|
|||
143
crud.py
143
crud.py
|
|
@ -1,20 +1,10 @@
|
|||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import cast
|
||||
|
||||
from lnbits.db import Database, Filters, Page
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from .models import (
|
||||
CreateEvent,
|
||||
Event,
|
||||
EventsSettings,
|
||||
Ticket,
|
||||
TicketExtra,
|
||||
TicketFilters,
|
||||
advertised_wave_key,
|
||||
sync_event_ticket_waves,
|
||||
)
|
||||
from .models import CreateEvent, Event, EventsSettings, Ticket, TicketExtra
|
||||
|
||||
db = Database("ext_events")
|
||||
|
||||
|
|
@ -165,31 +155,6 @@ async def get_tickets_by_user_id(user_id: str) -> list[Ticket]:
|
|||
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:
|
||||
await db.execute("DELETE FROM events.ticket WHERE id = :id", {"id": payment_hash})
|
||||
|
||||
|
|
@ -219,55 +184,44 @@ async def create_event(data: CreateEvent) -> Event:
|
|||
if not data.closing_date:
|
||||
data.closing_date = data.event_end_date
|
||||
event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict())
|
||||
event = cast(Event, sync_event_ticket_waves(event))
|
||||
await db.insert("events.events", event)
|
||||
return event
|
||||
|
||||
|
||||
async def update_event(event: Event) -> Event:
|
||||
event = cast(Event, sync_event_ticket_waves(event))
|
||||
await db.update("events.events", event)
|
||||
return event
|
||||
|
||||
|
||||
async def get_event(event_id: str) -> Event | None:
|
||||
event = await db.fetchone(
|
||||
return await db.fetchone(
|
||||
"SELECT * FROM events.events WHERE id = :id",
|
||||
{"id": event_id},
|
||||
Event,
|
||||
)
|
||||
return cast(Event, sync_event_ticket_waves(event)) if event else None
|
||||
|
||||
|
||||
async def get_events(wallet_ids: str | list[str]) -> list[Event]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids])
|
||||
events = await db.fetchall(
|
||||
return await db.fetchall(
|
||||
f"SELECT * FROM events.events WHERE wallet IN ({q})",
|
||||
model=Event,
|
||||
)
|
||||
return [cast(Event, sync_event_ticket_waves(event)) for event in events]
|
||||
|
||||
|
||||
async def get_all_events() -> list[Event]:
|
||||
"""All events, no wallet filter. Admin-only callers."""
|
||||
events = await db.fetchall(
|
||||
return await db.fetchall(
|
||||
"SELECT * FROM events.events ORDER BY time DESC",
|
||||
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]:
|
||||
"""Approved, non-canceled events for the public listing."""
|
||||
events = await db.fetchall(
|
||||
return await db.fetchall(
|
||||
"""
|
||||
SELECT * FROM events.events
|
||||
WHERE status = 'approved' AND canceled = FALSE
|
||||
|
|
@ -275,97 +229,14 @@ async def get_public_events() -> list[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]:
|
||||
"""Proposed events awaiting admin approval."""
|
||||
events = await db.fetchall(
|
||||
return await db.fetchall(
|
||||
"SELECT * FROM events.events WHERE status = 'proposed' ORDER BY time DESC",
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -1,173 +0,0 @@
|
|||
# Rebase playbook
|
||||
|
||||
How to merge an upstream release into this fork without shipping the
|
||||
failures a clean `git merge` cannot see.
|
||||
|
||||
Written during the v1.6.1 → v1.6.8 rebase (#33) after the first two
|
||||
instances showed up within minutes of each other. Both were textually
|
||||
clean merges that would have been semantically wrong in production.
|
||||
Modes C and D were added later in the same rebase, from `services.py`.
|
||||
|
||||
## The four failure modes
|
||||
|
||||
Git resolves _text_. Neither of these produces a conflict marker.
|
||||
|
||||
### A. Missed application
|
||||
|
||||
Upstream establishes an invariant and applies it at every call site **it
|
||||
knows about**. The fork has additional call sites upstream cannot see,
|
||||
so the invariant silently does not hold there.
|
||||
|
||||
> **Worked example.** v1.6.8 made `event.amount_tickets` a per-wave
|
||||
> roll-up recomputed by `sync_event_ticket_waves`, and added that call to
|
||||
> `get_event` and `get_events` in `crud.py`. Both merged cleanly. But the
|
||||
> fork has four getters upstream never had — `get_all_events`,
|
||||
> `get_public_events`, `get_pending_events`,
|
||||
> `get_events_pending_republish` — and two of them _publish_
|
||||
> (`/republish-all` and the #55 sweep). Without the same call they emit
|
||||
> the stale roll-up, so waves would have been wrong on exactly the paths
|
||||
> that push to relays, and nowhere else.
|
||||
|
||||
### B. Changed meaning
|
||||
|
||||
Upstream redefines what an existing field _means_. Fork code that reads
|
||||
it is untouched by the diff and keeps compiling, while now saying
|
||||
something false.
|
||||
|
||||
> **Worked example.** After `sync_event_ticket_waves`,
|
||||
> `event.price_per_ticket` is the **primary (first)** wave's price and
|
||||
> `event.amount_tickets` is the **sum across all waves**. Our
|
||||
> `nostr_publisher.build_nip52_event` reads both. Merged untouched, it
|
||||
> would advertise the early-bird price after early-bird closed, and count
|
||||
> stock in waves that have not opened. See #61.
|
||||
|
||||
### C. Misplaced conflict boundary
|
||||
|
||||
Git anchors a conflict on whatever lines happen to match. When both sides
|
||||
rewrote the same region, an _incidental_ shared line inside it can become
|
||||
the anchor — and everything past that line lands **outside** the markers,
|
||||
where it reads as cleanly merged.
|
||||
|
||||
Resolving only what sits between the markers then leaves **both**
|
||||
implementations in the file. Python does not complain: the later `def`
|
||||
silently wins. Since the merge appends upstream after ours, the survivor
|
||||
is upstream's — the fork's version is shadowed without a single warning.
|
||||
|
||||
> **Worked example.** In `services.py` the notification stack conflicted.
|
||||
> Ours (220 lines: multipart HTML mail, the QR-card attachment, the
|
||||
> Date/Message-ID headers that keep SpamAssassin quiet, npub DM support)
|
||||
> appeared between the markers; upstream's showed as 4 lines. But both
|
||||
> sides define the _same nine functions_, and git had anchored on a
|
||||
> shared `_send_nostr_ticket_notification` line — so upstream's entire
|
||||
> parallel stack sat below `>>>>>>>`, looking merged. Taking "ours" and
|
||||
> moving on would have left upstream's definitions last in the file and
|
||||
> therefore live, quietly reverting every one of those features.
|
||||
|
||||
**Do not trust the marker as the edit's boundary.** Before resolving a
|
||||
hunk, list the function names on each side and compare them to the names
|
||||
already in the file:
|
||||
|
||||
```sh
|
||||
grep -o '^\(async \)\?def \w*' <file>.py | sed 's/.*def //' | sort | uniq -d
|
||||
```
|
||||
|
||||
Run that per file **as you resolve**, not at the end. `ruff` does not
|
||||
flag redefinition (verified: F ruleset passes on a duplicated `def`).
|
||||
Only `mypy` does, via `no-redef` — and mypy refuses to run at all while
|
||||
any file in the package still has conflict markers, so the one tool that
|
||||
catches mode C is unavailable for the whole merge. The `uniq -d` line
|
||||
above is the substitute.
|
||||
|
||||
### D. Collided names
|
||||
|
||||
Ours and upstream independently grew a function with the **same name for
|
||||
a different feature**. Every resolution that reads as sane — take ours,
|
||||
take theirs, take "the newer one" — silently deletes a feature, and the
|
||||
diff looks like an ordinary reconciliation of one function.
|
||||
|
||||
> **Worked example.** Both sides had `_ticket_image_url`. Ours: the
|
||||
> rendered QR ticket-card PNG, always attached, served by this extension
|
||||
> off `lnbits_baseurl`. Upstream's: an organiser-uploaded template, opt-in
|
||||
> per wave via `use_ticket_image`, served off `ticket_base_url` and
|
||||
> returning `None` when the wave has not enabled it. Same name, different
|
||||
> arity, different return type, unrelated features. Resolved by renaming
|
||||
> ours to `_ticket_card_url` and keeping both — upstream's ticket-image
|
||||
> upload UI had already merged into `index.vue`, so dropping their backend
|
||||
> would have orphaned live UI.
|
||||
|
||||
Signals worth stopping on: the two versions differ in **arity**, in
|
||||
**return type** (`str` vs `str | None`), or in which setting they build a
|
||||
URL from. Any of those means it is probably not one function with two
|
||||
histories.
|
||||
|
||||
## The procedure
|
||||
|
||||
Run this _after_ the merge resolves and _before_ the release.
|
||||
|
||||
### 1. Enumerate what upstream introduced
|
||||
|
||||
```sh
|
||||
MB=$(git merge-base HEAD upstream/main)
|
||||
# new public names
|
||||
git diff $MB..<tag> -- '*.py' | grep -E "^\+(def |class |async def )"
|
||||
# fields whose meaning was redefined
|
||||
git show <tag>:models.py | sed -n '/^def sync_event_ticket_waves/,/return event/p'
|
||||
```
|
||||
|
||||
Split the result into **new symbols** (mode A candidates) and
|
||||
**redefined fields** (mode B candidates).
|
||||
|
||||
### 2. For each new symbol upstream _calls_, find the fork-only siblings
|
||||
|
||||
Ask what category of place the call belongs to — "every function that
|
||||
returns an `Event` from the DB", "every path that prices a ticket" — then
|
||||
enumerate that whole category in the merged tree and check coverage.
|
||||
|
||||
```sh
|
||||
grep -n "sync_event_ticket_waves" crud.py # where upstream put it
|
||||
grep -n "^async def get_.*-> \(list\[\)\?Event" crud.py # where it belongs
|
||||
```
|
||||
|
||||
The gap between those two lists is the work.
|
||||
|
||||
### 3. For each redefined field, grep the fork-only files
|
||||
|
||||
The 30-odd files upstream has never seen are where mode B hides, because
|
||||
nothing in the diff touches them:
|
||||
|
||||
```sh
|
||||
git diff --name-only $MB..HEAD > /tmp/fork.txt
|
||||
git diff --name-only $MB..<tag> > /tmp/up.txt
|
||||
comm -23 <(sort /tmp/fork.txt) <(sort /tmp/up.txt) # fork-only files
|
||||
grep -n "price_per_ticket\|amount_tickets" $(comm -23 ...)
|
||||
```
|
||||
|
||||
### 4. Prove each finding before fixing it
|
||||
|
||||
Both examples above were confirmed by reading the code path end to end,
|
||||
not inferred from the diff. A wrong theory costs more than the check:
|
||||
during this rebase an inference that `amount_tickets` "goes stale on
|
||||
sale" was wrong — `sync_event_ticket_waves` also runs on _reads_, which
|
||||
only the call-site list showed.
|
||||
|
||||
### 5. Write the reason at the site
|
||||
|
||||
Every fix from this procedure gets a comment saying **why upstream's diff
|
||||
missed it**. That is what stops the next rebase re-dropping it, and it is
|
||||
the only durable record that the omission was considered rather than
|
||||
overlooked.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `migrations.py` still byte-identical to upstream
|
||||
- [ ] New upstream symbols enumerated; each call-site category audited
|
||||
- [ ] Redefined fields enumerated; every fork-only reader checked
|
||||
- [ ] Fork-only files listed and grepped for both modes
|
||||
- [ ] Every resolved file checked for duplicate `def`s (mode C) — as it is
|
||||
resolved, since mypy cannot run until the whole package is clean
|
||||
- [ ] Same-named functions on both sides compared by arity and return type
|
||||
before being treated as one function (mode D)
|
||||
- [ ] Publishing paths specifically audited — they fail silently and
|
||||
externally, so they are the worst place for either mode to land
|
||||
- [ ] Every fix carries a comment explaining the omission
|
||||
- [ ] Deviations recorded in `docs/upstream-candidates.md`
|
||||
|
|
@ -4,18 +4,16 @@ Running log of fork features that are shaped so they could be offered to
|
|||
`lnbits/events` (or `lnbits/lnbits`). Add a row whenever a change lands here
|
||||
in an upstream-compatible form; strike it when the PR merges upstream.
|
||||
|
||||
| Feature | Where | Upstream target | Readiness |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR |
|
||||
| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above |
|
||||
| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch |
|
||||
| `extra.payment_methods` per event + `tickets_payment_methods` NIP-52 tag | `models.py`, `nostr_publisher.py` | lnbits/events (v2 PR #64 introduces the same field) | offer as review input on #64 |
|
||||
| `asyncio.to_thread` around the smtplib send | `services.py` `_send_ticket_email_notification` | lnbits/events | trivial, standalone |
|
||||
| QR logo overlay in `make_qr_png` (instance `lnbits_qr_logo`) | `views_api.py` | lnbits/events | standalone |
|
||||
| Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 |
|
||||
| Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone |
|
||||
| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 |
|
||||
| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 |
|
||||
| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `<img>` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) |
|
||||
| Promo `max_uses` + derived `used_count` (per ticket; v2 counts per basket), `POST /promo/validate/{event_id}` with `quantity` instead of `items`, `PublicEventExtra` projection (v2 still exposes `extra` fully) | `promo.py`, `models.py`, `views_api.py` | lnbits/events (v2 PR #64) | review input on #64 |
|
||||
| NIP-52 tags describe the **active** ticket wave (cheapest open wave; `tickets_available: 0` when none is open), plus `nostr_published_wave_id` so the reconciliation sweep republishes at wave boundaries | `nostr_publisher.py`, `crud.py` `flag_wave_transitions`, `migrations_fork.py` m004 | lnbits/events #46 (rides with the NIP-52 publishing PR) | **deviation, deliberate** — upstream has waves but publishes no calendar event, so there is nothing upstream to match. Several waves can be open at once and a publisher cannot ask which one the buyer wants (upstream's purchase path errors with "Please select a ticket wave"), so it advertises the cheapest — the price a buyer can actually obtain. Rationale and the rejected alternatives: aiolabs/events#61 |
|
||||
| Feature | Where | Upstream target | Readiness |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR |
|
||||
| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above |
|
||||
| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch |
|
||||
| `extra.payment_methods` per event + `tickets_payment_methods` NIP-52 tag | `models.py`, `nostr_publisher.py` | lnbits/events (v2 PR #64 introduces the same field) | offer as review input on #64 |
|
||||
| `asyncio.to_thread` around the smtplib send | `services.py` `_send_ticket_email_notification` | lnbits/events | trivial, standalone |
|
||||
| QR logo overlay in `make_qr_png` (instance `lnbits_qr_logo`) | `views_api.py` | lnbits/events | standalone |
|
||||
| Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 |
|
||||
| Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone |
|
||||
| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 |
|
||||
| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 |
|
||||
| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `<img>` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) |
|
||||
|
|
|
|||
|
|
@ -127,61 +127,3 @@ async def m002_ticket_payment_hash(db):
|
|||
"UPDATE events.ticket SET payment_hash = id "
|
||||
"WHERE payment_hash IS NULL OR payment_hash = ''"
|
||||
)
|
||||
|
||||
|
||||
async def m003_event_nostr_publish_pending(db):
|
||||
"""
|
||||
Add `events.nostr_publish_pending` — the marker that makes NIP-52
|
||||
publish drift queryable instead of invisible.
|
||||
|
||||
Inventory reaches clients only through the republished calendar
|
||||
event. When that publish doesn't land, the relay keeps serving the
|
||||
counts it last saw and nothing anywhere records the divergence; it
|
||||
has twice been caught only by a human reading a public page
|
||||
(aiolabs/events#35, #51).
|
||||
|
||||
The flag is set before each publish attempt and cleared only on a
|
||||
confirmed success, so it covers *both* observed failure shapes:
|
||||
an attempt that raised (a signer outage) and an attempt that was
|
||||
never made at all (no signer resolved, no NostrClient). A periodic
|
||||
sweep republishes whatever is still marked.
|
||||
|
||||
Existing rows default to FALSE rather than TRUE: on upgrade we have
|
||||
no evidence they're stale, and marking the whole table pending would
|
||||
stampede the signer with a full-table republish on first boot.
|
||||
`/republish-all` is the deliberate way to force that.
|
||||
"""
|
||||
await _alter_add_column_safe(
|
||||
db,
|
||||
"ALTER TABLE events.events "
|
||||
"ADD COLUMN nostr_publish_pending BOOLEAN NOT NULL DEFAULT FALSE",
|
||||
)
|
||||
|
||||
|
||||
async def m004_event_nostr_published_wave(db):
|
||||
"""
|
||||
Add `events.nostr_published_wave_id` — which ticket wave the last
|
||||
successful NIP-52 publish advertised.
|
||||
|
||||
Since upstream v1.6.8 price and inventory live on time-boxed waves, so
|
||||
what a calendar event should advertise changes at a *date* boundary.
|
||||
Every republish we have is sale-driven, and no sale happens at
|
||||
midnight when early bird ends — so without this the relay keeps
|
||||
serving the closed wave's price until the next ticket sells
|
||||
(aiolabs/events#61).
|
||||
|
||||
Recording the advertised wave makes that drift detectable with the
|
||||
machinery already in place: the reconciliation sweep compares this
|
||||
against the wave that would be advertised now and sets
|
||||
`nostr_publish_pending`, reusing the existing retry path rather than
|
||||
adding a scheduler.
|
||||
|
||||
NULL on existing rows means "never published, or published before this
|
||||
column existed". The sweep treats NULL as "no evidence of drift" and
|
||||
leaves it alone, so an upgrade does not stampede the signer with a
|
||||
full-table republish; the first ordinary publish fills it in.
|
||||
"""
|
||||
await _alter_add_column_safe(
|
||||
db,
|
||||
"ALTER TABLE events.events ADD COLUMN nostr_published_wave_id TEXT",
|
||||
)
|
||||
|
|
|
|||
269
models.py
269
models.py
|
|
@ -1,9 +1,7 @@
|
|||
import json
|
||||
from datetime import date, datetime
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlsplit
|
||||
from uuid import uuid4
|
||||
|
||||
from lnbits.db import FilterModel
|
||||
from pydantic import BaseModel, EmailStr, Field, root_validator, validator
|
||||
|
||||
PAYMENT_METHODS = ("lightning", "fiat")
|
||||
|
|
@ -13,56 +11,20 @@ class PromoCode(BaseModel):
|
|||
code: str
|
||||
discount_percent: float = 0.0
|
||||
active: bool = True
|
||||
# Redemption cap; None / 0 = unlimited. Field names follow upstream v2.
|
||||
max_uses: int | None = None
|
||||
# Derived on read from PAID tickets whose extra.applied_promo_code matches
|
||||
# (see promo.promo_usage / services.hydrate_promo_usage). Whatever a
|
||||
# client sends back here is ignored — it is never the source of truth.
|
||||
used_count: int = 0
|
||||
|
||||
# stored form: stripped + upper-case, never empty
|
||||
# make the promo code uppercase
|
||||
@validator("code")
|
||||
def uppercase_code(cls, v):
|
||||
v = (v or "").strip().upper()
|
||||
if not v:
|
||||
raise ValueError("Promo code cannot be empty.")
|
||||
return v
|
||||
return v.upper()
|
||||
|
||||
@validator("discount_percent")
|
||||
def validate_discount_percent(cls, v):
|
||||
assert 0 <= v <= 100, "Discount must be between 0 and 100."
|
||||
return v
|
||||
|
||||
@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
|
||||
min_tickets: int = 1
|
||||
email_notifications: bool = False
|
||||
|
|
@ -74,19 +36,6 @@ class EventExtraBase(BaseModel):
|
|||
# `effective_payment_methods`. Same field name/shape as upstream v2 so the
|
||||
# eventual rebase (#33) merges cleanly.
|
||||
payment_methods: list[str] = Field(default_factory=list)
|
||||
# Upstream v1.6.8 ticket waves — time-boxed pricing tiers. The
|
||||
# event-level `currency` / `allow_fiat` / `amount_tickets` /
|
||||
# `price_per_ticket` fields become derived values (see
|
||||
# `sync_event_ticket_waves`), which is why fork code that reads them
|
||||
# needs auditing — aiolabs/events#61.
|
||||
#
|
||||
# Public, not organizer-only: a buyer cannot choose a wave without its
|
||||
# id, and neither the public event response nor the NIP-52 tags carried
|
||||
# one before. A wave holds price, dates and remaining stock — the sales
|
||||
# information a buyer needs — so the only thing exposing it reveals is
|
||||
# the upcoming price schedule, which is what #61 regretted giving up
|
||||
# when it settled on flat Nostr tags.
|
||||
ticket_waves: list[TicketWave] = Field(default_factory=list)
|
||||
|
||||
@validator("payment_methods", pre=True)
|
||||
def normalize_payment_methods(cls, v):
|
||||
|
|
@ -104,13 +53,6 @@ class EventExtraBase(BaseModel):
|
|||
return seen
|
||||
|
||||
|
||||
class EventExtra(EventExtraBase):
|
||||
promo_codes: list[PromoCode] = Field(default_factory=list)
|
||||
|
||||
|
||||
PublicEventExtra = EventExtraBase
|
||||
|
||||
|
||||
class CreateEvent(BaseModel):
|
||||
wallet: str | None = None # filled from caller's wallet if absent
|
||||
name: str # title (required)
|
||||
|
|
@ -123,12 +65,7 @@ class CreateEvent(BaseModel):
|
|||
currency: str = "sat"
|
||||
allow_fiat: bool = False
|
||||
fiat_currency: str = "GBP"
|
||||
# Capacity is always required and there is no unlimited (#34): a zero
|
||||
# here means sold out / not sellable, which `api_get_event` and
|
||||
# `api_ticket_create` both enforce with a 410. Under v1.6.8 waves this
|
||||
# is only the seed for the primary wave — `sync_event_ticket_waves`
|
||||
# recomputes it as the sum of every wave's remaining stock.
|
||||
amount_tickets: int = 0
|
||||
amount_tickets: int = 0 # 0 = unlimited / not ticketed
|
||||
price_per_ticket: float = 0 # 0 = free
|
||||
banner: str | None = None
|
||||
location: str | None = None # venue/address (NIP-52 'location' tag)
|
||||
|
|
@ -160,16 +97,6 @@ class Event(BaseModel):
|
|||
status: str = "approved"
|
||||
nostr_event_id: str | 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)
|
||||
def parse_categories(cls, v):
|
||||
|
|
@ -193,9 +120,7 @@ class PublicEvent(BaseModel):
|
|||
banner: str | None
|
||||
location: str | None = None
|
||||
categories: list[str] = Field(default_factory=list)
|
||||
# PublicEventExtra: promo codes are organizer-only (a buyer who can read
|
||||
# every code can mint every discount).
|
||||
extra: PublicEventExtra = Field(default_factory=PublicEventExtra)
|
||||
extra: EventExtra = Field(default_factory=EventExtra)
|
||||
status: str = "approved" # surfaces "proposed"/"rejected" so SFC can render banner
|
||||
|
||||
@validator("categories", pre=True)
|
||||
|
|
@ -205,69 +130,22 @@ class PublicEvent(BaseModel):
|
|||
return v or []
|
||||
|
||||
|
||||
def effective_payment_methods(
|
||||
event: "Event | PublicEvent | CreateEvent",
|
||||
wave: "TicketWave | None" = None,
|
||||
) -> list[str]:
|
||||
def effective_payment_methods(event: "Event | PublicEvent | CreateEvent") -> list[str]:
|
||||
"""Rails a buyer may pick for `event`.
|
||||
|
||||
Explicit `extra.payment_methods` wins; an empty list falls back to the
|
||||
pre-#payment-methods rule so events created before the field existed
|
||||
keep behaving the same (Lightning always, fiat iff `allow_fiat`).
|
||||
|
||||
Pass `wave` when the answer is about one specific ticket wave. Fiat is a
|
||||
per-wave opt-in since v1.6.8 and `event.allow_fiat` is only the PRIMARY
|
||||
wave's, so without it a publisher can advertise a fiat rail for an
|
||||
advertised wave that does not accept fiat — which the purchase endpoint
|
||||
then rejects (aiolabs/events#61). Callers asking the event-level
|
||||
question ("which rails did the organiser enable at all") leave it unset.
|
||||
"""
|
||||
explicit = list(getattr(event.extra, "payment_methods", []) or [])
|
||||
if explicit:
|
||||
# The organiser's rail list is event-level, but fiat is a per-wave
|
||||
# opt-in. Asking about a specific wave means asking what a buyer can
|
||||
# actually use for it, so drop a rail that wave cannot honour —
|
||||
# otherwise the NIP-52 tag advertises fiat and the checkout offers a
|
||||
# card button that `api_ticket_create` then refuses with "Fiat
|
||||
# payments are not enabled for this ticket wave."
|
||||
if wave is not None and not wave.allow_fiat:
|
||||
return [method for method in explicit if method != "fiat"]
|
||||
return explicit
|
||||
methods = ["lightning"]
|
||||
if wave.allow_fiat if wave is not None else event.allow_fiat:
|
||||
if event.allow_fiat:
|
||||
methods.append("fiat")
|
||||
return methods
|
||||
|
||||
|
||||
class PromoValidateRequest(BaseModel):
|
||||
"""Upstream v2 shape. v2 sends `items` (ticket types); this fork prices a
|
||||
plain `quantity` against one ticket wave.
|
||||
|
||||
`ticket_wave_id` may be omitted when exactly one wave is open, matching
|
||||
how the purchase endpoint resolves it — the preview has to price the same
|
||||
wave the invoice will, and since v1.6.8 price and currency are per-wave.
|
||||
"""
|
||||
|
||||
codes: list[str] = Field(default_factory=list)
|
||||
quantity: int = Field(default=1, ge=1, le=10)
|
||||
ticket_wave_id: str | None = None
|
||||
|
||||
|
||||
class BasketDiscount(BaseModel):
|
||||
code: str
|
||||
discount_percent: float | None = None
|
||||
discount_fixed: int | None = None # always None here (percent-only); v2 shape
|
||||
amount_saved: float = 0
|
||||
|
||||
|
||||
class BasketTotals(BaseModel):
|
||||
subtotal: float = 0
|
||||
discount: float = 0
|
||||
total: float = 0
|
||||
discounts_applied: list[BasketDiscount] = Field(default_factory=list)
|
||||
currency: str = "sat" # fork addition so a client can format the numbers
|
||||
|
||||
|
||||
class EventsSettings(BaseModel):
|
||||
"""Extension-level settings for the events extension."""
|
||||
|
||||
|
|
@ -276,8 +154,6 @@ class EventsSettings(BaseModel):
|
|||
|
||||
class TicketExtra(BaseModel):
|
||||
applied_promo_code: str | None = None
|
||||
ticket_wave_id: str | None = None
|
||||
ticket_wave_title: str | None = None
|
||||
sats_paid: int | None = None
|
||||
refund_address: str | None = None
|
||||
nostr_identifier: str | None = None
|
||||
|
|
@ -285,18 +161,12 @@ class TicketExtra(BaseModel):
|
|||
email_notification_sent: bool = False
|
||||
nostr_notification_sent: bool = False
|
||||
refunded: bool = False
|
||||
# On-chain vocabulary, populated once native lnbits on-chain lands
|
||||
# (aiolabs/events#41). Upstream's field names, minus the SatsPay charge
|
||||
# id — SatsPay is the implementation we are not adopting.
|
||||
onchain: bool = False
|
||||
onchain_address: str | None = None
|
||||
|
||||
|
||||
class CreateTicket(BaseModel):
|
||||
name: str | None = None
|
||||
email: EmailStr | None = None
|
||||
user_id: str | None = None # LNbits user id (alternative to name+email)
|
||||
ticket_wave_id: str | None = None
|
||||
promo_code: str | None = None
|
||||
refund_address: str | None = None
|
||||
nostr_identifier: str | None = None
|
||||
|
|
@ -398,124 +268,3 @@ class TicketPaymentRequest(BaseModel):
|
|||
# the door). Buyers fetch these after payment to render N QRs in
|
||||
# My Tickets.
|
||||
ticket_ids: list[str] = Field(default_factory=list)
|
||||
onchain_amount_sat: int | None = None
|
||||
|
||||
|
||||
class TicketFilters(FilterModel):
|
||||
__search_fields__ = ["event", "name", "email", "id"] # noqa: RUF012
|
||||
__sort_fields__ = [ # noqa: RUF012
|
||||
"time",
|
||||
"event",
|
||||
"name",
|
||||
"email",
|
||||
"registered",
|
||||
"id",
|
||||
]
|
||||
|
||||
event: str | None = None
|
||||
name: str | None = None
|
||||
email: str | None = None
|
||||
registered: bool | None = None
|
||||
paid: bool | None = None
|
||||
id: str | None = None
|
||||
|
||||
|
||||
def _parse_date(value: str) -> date:
|
||||
"""Date component of `value`.
|
||||
|
||||
Upstream only ever produces bare `YYYY-MM-DD` here, so its version is a
|
||||
plain `strptime(value, "%Y-%m-%d")`. In this fork `event_end_date` may
|
||||
carry a time (start/end times, v1.3.0-aio.3) and `create_event` defaults
|
||||
`closing_date` to it, so a wave derived from an event inherits the full
|
||||
ISO datetime and upstream's parser raises
|
||||
`ValueError: unconverted data remains: T18:00:00` — on the purchase path,
|
||||
the public event gate, and the promo preview.
|
||||
"""
|
||||
return date.fromisoformat(value[:10])
|
||||
|
||||
|
||||
def ensure_ticket_waves(event: Event | PublicEvent | CreateEvent) -> list[TicketWave]:
|
||||
ticket_waves = list(getattr(event.extra, "ticket_waves", []) or [])
|
||||
if ticket_waves:
|
||||
return ticket_waves
|
||||
|
||||
# `TicketWave` requires both dates; `Event.closing_date` is Optional in
|
||||
# this fork (it defaults from event_end_date at create time), so fall
|
||||
# back the same way `create_event` does rather than handing None to a
|
||||
# required field.
|
||||
closing_date = event.closing_date or event.event_end_date or event.event_start_date
|
||||
|
||||
fallback_opening_date = None
|
||||
event_time = getattr(event, "time", None)
|
||||
if event_time:
|
||||
fallback_opening_date = event_time.date().isoformat()
|
||||
if not fallback_opening_date:
|
||||
fallback_opening_date = closing_date
|
||||
|
||||
return [
|
||||
TicketWave(
|
||||
id="primary",
|
||||
title="Primary wave",
|
||||
opening_date=fallback_opening_date,
|
||||
closing_date=closing_date,
|
||||
currency=event.currency,
|
||||
allow_fiat=event.allow_fiat,
|
||||
fiat_currency=event.fiat_currency,
|
||||
amount_tickets=getattr(event, "amount_tickets", 0),
|
||||
price_per_ticket=event.price_per_ticket,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def advertised_ticket_wave(event: "Event | PublicEvent") -> "TicketWave | None":
|
||||
"""The wave a public listing should describe, or None when nothing is
|
||||
on sale (sold out, between waves, or not yet open).
|
||||
|
||||
Several waves can be open at once. The purchase endpoint refuses to
|
||||
guess; a publisher has no one to ask, so it advertises the CHEAPEST
|
||||
open wave — the price a buyer is actually able to obtain.
|
||||
|
||||
Shared by the NIP-52 publisher and the wave-transition detector so the
|
||||
two cannot disagree about which wave is currently being advertised.
|
||||
"""
|
||||
active = get_active_ticket_waves(event)
|
||||
if not active:
|
||||
return None
|
||||
return min(active, key=lambda wave: wave.price_per_ticket)
|
||||
|
||||
|
||||
def advertised_wave_key(event: "Event | PublicEvent") -> str:
|
||||
"""Stable key for what a publish advertised. Empty string means "nothing
|
||||
on sale", which is a real published state and distinct from NULL in
|
||||
`nostr_published_wave_id` (never published)."""
|
||||
wave = advertised_ticket_wave(event)
|
||||
return wave.id if wave else ""
|
||||
|
||||
|
||||
def sync_event_ticket_waves(event: Event | CreateEvent) -> Event | CreateEvent:
|
||||
ticket_waves = ensure_ticket_waves(event)
|
||||
event.extra.ticket_waves = ticket_waves
|
||||
|
||||
primary_wave = ticket_waves[0]
|
||||
event.closing_date = max(wave.closing_date for wave in ticket_waves)
|
||||
event.currency = primary_wave.currency
|
||||
event.allow_fiat = primary_wave.allow_fiat
|
||||
event.fiat_currency = primary_wave.fiat_currency
|
||||
event.amount_tickets = sum(wave.amount_tickets for wave in ticket_waves)
|
||||
event.price_per_ticket = primary_wave.price_per_ticket
|
||||
|
||||
return event
|
||||
|
||||
|
||||
def get_active_ticket_waves(
|
||||
event: Event | PublicEvent, today: date | None = None
|
||||
) -> list[TicketWave]:
|
||||
current_day = today or datetime.utcnow().date()
|
||||
return [
|
||||
wave
|
||||
for wave in ensure_ticket_waves(event)
|
||||
if _parse_date(wave.opening_date)
|
||||
<= current_day
|
||||
<= _parse_date(wave.closing_date)
|
||||
and wave.amount_tickets > 0
|
||||
]
|
||||
|
|
|
|||
|
|
@ -19,14 +19,6 @@ from websocket import WebSocketApp
|
|||
from .event import NostrEvent
|
||||
|
||||
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:
|
||||
|
|
@ -37,10 +29,6 @@ class NostrClient:
|
|||
self.subscription_id = "events-" + urlsafe_short_hash()[:32]
|
||||
self.running = False
|
||||
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
|
||||
def is_websocket_connected(self):
|
||||
|
|
@ -90,37 +78,17 @@ class NostrClient:
|
|||
|
||||
async def run_forever(self):
|
||||
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:
|
||||
try:
|
||||
if not self.is_websocket_connected:
|
||||
self.ws = await self.connect()
|
||||
await asyncio.sleep(5)
|
||||
|
||||
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
|
||||
req = await self.send_req_queue.get()
|
||||
assert self.ws
|
||||
self.ws.send(json.dumps(req))
|
||||
held_req, held_attempts = None, 0
|
||||
except Exception as 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)
|
||||
|
||||
def is_duplicate_event(self, event_id: str) -> bool:
|
||||
|
|
@ -133,82 +101,14 @@ class NostrClient:
|
|||
return False
|
||||
|
||||
async def get_event(self):
|
||||
"""Get the next relay message, consuming `OK` frames on the way.
|
||||
"""Get next event from the receive queue."""
|
||||
value = await self.receive_event_queue.get()
|
||||
if isinstance(value, ValueError):
|
||||
raise value
|
||||
return value
|
||||
|
||||
This is the only place relay messages cross from the websocket
|
||||
thread into the event loop, which makes it the natural place to
|
||||
settle publish confirmations — no cross-thread future juggling.
|
||||
`OK` frames are swallowed rather than forwarded; the sync loop
|
||||
never handled them.
|
||||
"""
|
||||
while True:
|
||||
value = await self.receive_event_queue.get()
|
||||
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 publish_nostr_event(self, e: NostrEvent):
|
||||
await self.send_req_queue.put(["EVENT", e.dict()])
|
||||
|
||||
async def subscribe(self, filters: list[dict]):
|
||||
"""Subscribe to events matching the given filters."""
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ import cycle (views_api -> nostr_hooks -> nostr_publisher -> models).
|
|||
from loguru import logger
|
||||
|
||||
from .crud import update_event
|
||||
from .models import Event, advertised_wave_key
|
||||
from .models import Event
|
||||
from .nostr_publisher import publish_event_to_nostr
|
||||
|
||||
|
||||
async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -> bool:
|
||||
async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -> None:
|
||||
"""Publish or delete the NIP-52 calendar event for `event`.
|
||||
|
||||
Resolves a `NostrSigner` for the wallet owner — backend-agnostic
|
||||
|
|
@ -22,22 +22,7 @@ async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -
|
|||
`await signer.sign_event(...)` for signing. Failures are logged
|
||||
and swallowed so a Nostr outage doesn't break the HTTP flow that
|
||||
triggered the publish.
|
||||
|
||||
Returns True when the event was signed and handed to the client,
|
||||
False on any skip or failure. Callers are free to ignore it — the
|
||||
`nostr_publish_pending` flag is the durable record, and the sweep
|
||||
retries from that rather than from a return value.
|
||||
"""
|
||||
# Mark before attempting, clear only on confirmed success. Doing it
|
||||
# in this order is what makes "the attempt was never made" — no
|
||||
# signer, no NostrClient, process died mid-flight — as visible as
|
||||
# "the attempt raised". Cheap guard so a re-publish of an already
|
||||
# pending row doesn't write twice; `set_ticket_paid` sets the flag
|
||||
# inside its own update so the sale path adds no extra write.
|
||||
if not event.nostr_publish_pending:
|
||||
event.nostr_publish_pending = True
|
||||
await update_event(event)
|
||||
|
||||
try:
|
||||
from lnbits.core.signers import resolve_for_wallet
|
||||
|
||||
|
|
@ -47,40 +32,17 @@ async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -
|
|||
if signer is None:
|
||||
# Wallet missing, account missing, unclassified row, or
|
||||
# ClientSideOnlySigner account (server can't sign for them).
|
||||
# Soft-fail: the HTTP / payment flow that triggered this must
|
||||
# not break. The user can still publish kind-31922/31923
|
||||
# events client-side once we have that path.
|
||||
#
|
||||
# Logged at WARNING, not debug: skipping the publish means the
|
||||
# relay keeps serving whatever inventory it last saw, so the
|
||||
# public ticket count silently stops tracking the DB. That has
|
||||
# twice been discovered only by a human noticing a wrong number
|
||||
# on a public page (aiolabs/events#35, #51).
|
||||
logger.warning(
|
||||
f"[EVENTS] No signer for wallet {event.wallet}, skipping "
|
||||
f"NIP-52 {'delete' if delete else 'publish'} for event {event.id}"
|
||||
)
|
||||
return False
|
||||
# Soft-fail: skip the publish silently. The user can still
|
||||
# publish kind-31922/31923 events client-side once we have
|
||||
# that path.
|
||||
return
|
||||
|
||||
nostr_event = await publish_event_to_nostr(
|
||||
nostr_client, event, signer, delete=delete
|
||||
)
|
||||
if nostr_event is None:
|
||||
return False
|
||||
|
||||
event.nostr_publish_pending = False
|
||||
if not delete:
|
||||
if nostr_event and not delete:
|
||||
event.nostr_event_id = nostr_event.id
|
||||
event.nostr_event_created_at = nostr_event.created_at
|
||||
# Record which wave this publish advertised so the sweep can
|
||||
# notice a wave boundary later (aiolabs/events#61). Written in
|
||||
# the same update as the cleared flag, so the two can never
|
||||
# disagree about what is on the relay.
|
||||
event.nostr_published_wave_id = advertised_wave_key(event)
|
||||
await update_event(event)
|
||||
return True
|
||||
await update_event(event)
|
||||
except Exception as exc:
|
||||
# ERROR, not warning: the row stays flagged and its published
|
||||
# counts stay behind until the sweep or a later edit succeeds.
|
||||
logger.error(f"[EVENTS] Nostr publish failed for event {event.id}: {exc}")
|
||||
return False
|
||||
logger.warning(f"[EVENTS] Nostr publish failed: {exc}")
|
||||
|
|
|
|||
|
|
@ -17,12 +17,7 @@ from datetime import datetime, timezone
|
|||
from lnbits.core.signers import NostrSigner
|
||||
from loguru import logger
|
||||
|
||||
from .models import (
|
||||
Event,
|
||||
advertised_ticket_wave,
|
||||
effective_payment_methods,
|
||||
ensure_ticket_waves,
|
||||
)
|
||||
from .models import Event, effective_payment_methods
|
||||
from .nostr.event import NostrEvent
|
||||
from .nostr_timestamp import monotonic_created_at
|
||||
|
||||
|
|
@ -51,13 +46,11 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
|
|||
start - unix timestamp (31923) or YYYY-MM-DD (31922)
|
||||
end - same encoding (optional)
|
||||
image, location, t (categories) - optional
|
||||
tickets_available - remaining capacity of the advertised wave
|
||||
(always emitted; 0 = nothing on sale now)
|
||||
tickets_sold - running paid-count across all waves (always
|
||||
emitted)
|
||||
tickets_price - the advertised wave's price (always emitted;
|
||||
0 means free)
|
||||
tickets_currency - the advertised wave's currency
|
||||
tickets_available - current remaining capacity (omitted when unlimited)
|
||||
tickets_sold - running paid-count (always emitted; clients can
|
||||
derive original_capacity = available + sold)
|
||||
tickets_price - price_per_ticket (always emitted; 0 means free)
|
||||
tickets_currency - the currency string
|
||||
tickets_allow_fiat - "true" when fiat checkout is enabled (omitted otherwise)
|
||||
tickets_fiat_currency - the fiat settle currency (only when allow_fiat)
|
||||
Content: event.info
|
||||
|
|
@ -103,59 +96,25 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
|
|||
for cat in event.categories or []:
|
||||
tags.append(["t", cat])
|
||||
|
||||
# Always emitted, including zero. Omitting it used to mean "unlimited",
|
||||
# which contradicted every other reader: `api_get_event` and
|
||||
# `api_ticket_create` both treat `amount_tickets < 1` as sold out, so a
|
||||
# zero-capacity event advertised "Unlimited tickets" on the card while
|
||||
# the detail page and the purchase both returned 410 (aiolabs/events#34).
|
||||
# Clients that must still handle an absent tag — a NIP-52 event from
|
||||
# some other publisher — are unaffected; we simply never omit it.
|
||||
#
|
||||
# Since v1.6.8 price, currency and inventory belong to a ticket WAVE.
|
||||
# The event-level fields `sync_event_ticket_waves` derives are the
|
||||
# PRIMARY wave's price/currency and the SUM of every wave's stock, so
|
||||
# publishing them would keep advertising the early-bird price after
|
||||
# early bird closed, and count stock in waves that have not opened yet
|
||||
# (aiolabs/events#61). Advertise the wave a buyer can actually buy from.
|
||||
#
|
||||
# Several waves can be open at once. The purchase endpoint refuses to
|
||||
# guess ("Please select a ticket wave"); a publisher has no one to ask,
|
||||
# so it advertises the CHEAPEST open wave — the price a buyer is able to
|
||||
# obtain right now. Deviation recorded in docs/upstream-candidates.md.
|
||||
open_wave = advertised_ticket_wave(event)
|
||||
# Nothing open: sold out, between waves, or not yet on sale. Fall back
|
||||
# to the primary wave so price/currency still describe the event,
|
||||
# paired with the zero availability below.
|
||||
advertised = open_wave or ensure_ticket_waves(event)[0]
|
||||
|
||||
# Always emitted, including zero — see #34 above. With no open wave
|
||||
# there is nothing to sell regardless of what the waves hold, so this
|
||||
# is 0 rather than the wave's stock.
|
||||
available = advertised.amount_tickets if open_wave else 0
|
||||
tags.append(["tickets_available", str(available)])
|
||||
# Event-level: total paid across every wave, which is what "sold" means
|
||||
# to a reader of the card.
|
||||
# `amount_tickets == 0` means unlimited capacity in this extension's
|
||||
# schema. Omitting the tag is how clients distinguish unlimited from
|
||||
# "0 left" (sold out).
|
||||
if event.amount_tickets > 0:
|
||||
tags.append(["tickets_available", str(event.amount_tickets)])
|
||||
tags.append(["tickets_sold", str(event.sold)])
|
||||
tags.append(["tickets_price", str(advertised.price_per_ticket)])
|
||||
tags.append(["tickets_currency", advertised.currency])
|
||||
tags.append(["tickets_price", str(event.price_per_ticket)])
|
||||
tags.append(["tickets_currency", event.currency])
|
||||
# Fiat-checkout config — only emitted when allow_fiat is on so
|
||||
# clients can branch the buy UI without re-reading the schema.
|
||||
if advertised.allow_fiat:
|
||||
if event.allow_fiat:
|
||||
tags.append(["tickets_allow_fiat", "true"])
|
||||
if advertised.fiat_currency:
|
||||
tags.append(["tickets_fiat_currency", advertised.fiat_currency])
|
||||
if event.fiat_currency:
|
||||
tags.append(["tickets_fiat_currency", event.fiat_currency])
|
||||
# Rails the organizer accepts, resolved through the same helper the
|
||||
# ticket endpoint enforces with, so a client can render exactly the
|
||||
# buttons that will be accepted (e.g. a card-only event) without a REST
|
||||
# round-trip. Comma-separated, lowercase.
|
||||
tags.append(
|
||||
[
|
||||
"tickets_payment_methods",
|
||||
# Scoped to the advertised wave so this cannot contradict
|
||||
# `tickets_allow_fiat` above — they are the same fact.
|
||||
",".join(effective_payment_methods(event, advertised)),
|
||||
]
|
||||
)
|
||||
tags.append(["tickets_payment_methods", ",".join(effective_payment_methods(event))])
|
||||
|
||||
# NIP-52 calendar events are replaceable: this d-tag is republished
|
||||
# whenever inventory changes (a ticket sells). Use a strictly-monotonic
|
||||
|
|
@ -213,14 +172,7 @@ async def publish_event_to_nostr(
|
|||
Returns the published NostrEvent for metadata storage, or None on failure.
|
||||
"""
|
||||
if not nostr_client:
|
||||
# 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}"
|
||||
)
|
||||
logger.debug("[EVENTS] No NostrClient available, skipping publish")
|
||||
return None
|
||||
|
||||
try:
|
||||
|
|
@ -244,16 +196,7 @@ async def publish_event_to_nostr(
|
|||
nostr_event.pubkey = signed["pubkey"]
|
||||
nostr_event.sig = signed["sig"]
|
||||
|
||||
accepted = await nostr_client.publish_nostr_event(nostr_event)
|
||||
if not accepted:
|
||||
# Returning None keeps `nostr_publish_pending` set, so the
|
||||
# sweep retries instead of recording a delivery that never
|
||||
# happened (aiolabs/events#56).
|
||||
logger.warning(
|
||||
f"[EVENTS] Relay did not confirm NIP-52 "
|
||||
f"{'delete' if delete else 'calendar'} event for {event.id}"
|
||||
)
|
||||
return None
|
||||
await nostr_client.publish_nostr_event(nostr_event)
|
||||
|
||||
logger.info(
|
||||
f"[EVENTS] Published NIP-52 {'delete' if delete else 'calendar'} "
|
||||
|
|
@ -262,8 +205,5 @@ async def publish_event_to_nostr(
|
|||
return nostr_event
|
||||
|
||||
except Exception as 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}")
|
||||
logger.warning(f"[EVENTS] Failed to publish to Nostr: {e}")
|
||||
return None
|
||||
|
|
|
|||
117
promo.py
117
promo.py
|
|
@ -1,117 +0,0 @@
|
|||
"""Promo-code arithmetic shared by the validate endpoint and the purchase path.
|
||||
|
||||
Pure functions (no DB, no settings) so the number a buyer sees in the
|
||||
"Apply" preview is exactly the number the invoice / Stripe session charges.
|
||||
Field names and the `BasketTotals` shape follow upstream lnbits/events v2
|
||||
(PR #64) so the eventual rebase (#33) merges cleanly; deviations are noted
|
||||
inline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from .models import BasketDiscount, BasketTotals, Event, PromoCode, Ticket, TicketWave
|
||||
|
||||
SAT_UNITS = ("sat", "sats")
|
||||
|
||||
|
||||
def normalize_code(raw: str | None) -> str | None:
|
||||
"""Buyer input → stored form (stripped, upper-cased); empty → None."""
|
||||
if raw is None:
|
||||
return None
|
||||
code = raw.strip().upper()
|
||||
return code or None
|
||||
|
||||
|
||||
def find_promo(event: Event, code: str) -> PromoCode | None:
|
||||
return next((pc for pc in event.extra.promo_codes if pc.code == code), None)
|
||||
|
||||
|
||||
def promo_usage(tickets: list[Ticket]) -> dict[str, int]:
|
||||
"""Redemptions per code = PAID tickets carrying it in
|
||||
`extra.applied_promo_code`. Every row counts, so a multi-ticket
|
||||
purchase consumes `quantity` uses (upstream v2 counts one per basket).
|
||||
|
||||
Paid only: pending rows live up to 24 h (`purge_unpaid_tickets`), so
|
||||
counting them would let an abandoned Stripe session lock out the last
|
||||
uses of a limited code for a day. The cost is a bounded overshoot when
|
||||
several buyers pass the check before any of them pays — accepted.
|
||||
"""
|
||||
counter: Counter[str] = Counter()
|
||||
for ticket in tickets:
|
||||
code = ticket.extra.applied_promo_code
|
||||
if ticket.paid and code:
|
||||
counter[code] += 1
|
||||
return dict(counter)
|
||||
|
||||
|
||||
def remaining_uses(promo: PromoCode, used: int) -> int | None:
|
||||
"""None = unlimited (`max_uses` unset / 0)."""
|
||||
if not promo.max_uses:
|
||||
return None
|
||||
return max(promo.max_uses - used, 0)
|
||||
|
||||
|
||||
def round_amount(amount: float, currency: str | None) -> float:
|
||||
"""Sats are integers; fiat is 2 dp. Applied once, at the end, so
|
||||
subtotal - total == discount holds for what is actually charged."""
|
||||
if (currency or "sat").lower() in SAT_UNITS:
|
||||
return float(int(amount))
|
||||
return round(amount, 2)
|
||||
|
||||
|
||||
def basket_totals(
|
||||
event: Event,
|
||||
codes: list[str],
|
||||
quantity: int,
|
||||
usage: dict[str, int],
|
||||
wave: TicketWave,
|
||||
) -> BasketTotals:
|
||||
"""Price `quantity` tickets from `wave` with the first applicable code.
|
||||
|
||||
A code is applicable when it exists, is active, has enough uses left
|
||||
for the whole quantity, and actually saves something. Anything else is
|
||||
simply absent from `discounts_applied` (upstream v2 semantics — the
|
||||
purchase endpoint is where hard errors are raised). Only one code is
|
||||
applied; v2's `combinable` stacking is out of scope here.
|
||||
|
||||
`wave` is the pricing authority, not `event`. Since v1.6.8 a ticket's
|
||||
price and currency belong to the wave it is bought from, and the
|
||||
event-level fields are a derived roll-up of the PRIMARY wave
|
||||
(`sync_event_ticket_waves`) — pricing off `event` would quote and charge
|
||||
the first wave's price to a buyer who picked a later one. It is a
|
||||
required argument rather than an optional override precisely because
|
||||
that failure is silent: both call sites have to name the wave.
|
||||
"""
|
||||
currency = wave.currency or "sat"
|
||||
subtotal = round_amount(wave.price_per_ticket * quantity, currency)
|
||||
totals = BasketTotals(
|
||||
subtotal=subtotal, discount=0, total=subtotal, currency=currency
|
||||
)
|
||||
for raw in codes:
|
||||
code = normalize_code(raw)
|
||||
if not code:
|
||||
continue
|
||||
promo = find_promo(event, code)
|
||||
if not promo or not promo.active:
|
||||
continue
|
||||
remaining = remaining_uses(promo, usage.get(promo.code, 0))
|
||||
if remaining is not None and remaining < quantity:
|
||||
continue
|
||||
total = round_amount(subtotal * (1 - promo.discount_percent / 100), currency)
|
||||
saved = round_amount(subtotal - total, currency)
|
||||
if saved <= 0:
|
||||
continue
|
||||
totals.total = total
|
||||
totals.discount = saved
|
||||
totals.discounts_applied = [
|
||||
BasketDiscount(
|
||||
code=promo.code,
|
||||
discount_percent=promo.discount_percent,
|
||||
discount_fixed=None,
|
||||
amount_saved=saved,
|
||||
)
|
||||
]
|
||||
break
|
||||
return totals
|
||||
148
services.py
148
services.py
|
|
@ -26,15 +26,8 @@ from .crud import (
|
|||
update_event,
|
||||
update_ticket,
|
||||
)
|
||||
from .models import (
|
||||
Event,
|
||||
NotificationDeliveryResult,
|
||||
Ticket,
|
||||
TicketResendResult,
|
||||
ensure_ticket_waves,
|
||||
)
|
||||
from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult
|
||||
from .nostr_hooks import publish_or_delete_nostr_event
|
||||
from .promo import promo_usage
|
||||
from .qr import (
|
||||
format_event_when,
|
||||
image_png_bytes,
|
||||
|
|
@ -76,29 +69,7 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket:
|
|||
event = await get_event(ticket.event)
|
||||
assert event, "Couldn't get event from ticket being paid"
|
||||
event.sold += 1
|
||||
# Debit the wave the buyer actually bought from. v1.6.8 moved
|
||||
# inventory onto waves; the event-level counter is only the
|
||||
# fallback for events that predate them, and is itself a derived
|
||||
# roll-up (`sync_event_ticket_waves`). Upstream's `> 0` guards are
|
||||
# kept — ours decremented unconditionally and could go negative.
|
||||
ticket_waves = event.extra.ticket_waves or []
|
||||
if ticket_waves:
|
||||
selected_wave = next(
|
||||
(
|
||||
wave
|
||||
for wave in ticket_waves
|
||||
if wave.id == ticket.extra.ticket_wave_id
|
||||
),
|
||||
ticket_waves[0],
|
||||
)
|
||||
if selected_wave.amount_tickets > 0:
|
||||
selected_wave.amount_tickets -= 1
|
||||
elif event.amount_tickets > 0:
|
||||
event.amount_tickets -= 1
|
||||
# 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
|
||||
event.amount_tickets -= 1
|
||||
await update_event(event)
|
||||
|
||||
# Republish the NIP-52 calendar event so connected clients see
|
||||
|
|
@ -111,22 +82,6 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket:
|
|||
return ticket
|
||||
|
||||
|
||||
async def event_promo_usage(event_id: str) -> dict[str, int]:
|
||||
"""Paid redemptions per promo code for one event (see promo.promo_usage)."""
|
||||
return promo_usage(await get_event_tickets(event_id))
|
||||
|
||||
|
||||
async def hydrate_promo_usage(event: Event) -> Event:
|
||||
"""Fill `used_count` on each of the event's promo codes. No query when
|
||||
the event has no codes, so listing stays cheap."""
|
||||
if not event.extra.promo_codes:
|
||||
return event
|
||||
usage = await event_promo_usage(event.id)
|
||||
for promo in event.extra.promo_codes:
|
||||
promo.used_count = usage.get(promo.code, 0)
|
||||
return event
|
||||
|
||||
|
||||
def send_ticket_notification_in_background(ticket: Ticket) -> None:
|
||||
create_task(_send_ticket_notification(ticket))
|
||||
|
||||
|
|
@ -140,17 +95,10 @@ async def _send_ticket_notification(ticket: Ticket) -> None:
|
|||
await _deliver_ticket_notifications(ticket, event)
|
||||
|
||||
|
||||
async def resend_ticket_email_notification(
|
||||
ticket: Ticket, base_url: str | None = None
|
||||
) -> TicketResendResult:
|
||||
async def resend_ticket_email_notification(ticket: Ticket) -> TicketResendResult:
|
||||
"""Organizer-triggered re-delivery of the ticket email. Bypasses the
|
||||
per-event `email_notifications` opt-in (the organizer asked explicitly)
|
||||
but still needs the instance mailer and an address on the ticket.
|
||||
|
||||
`base_url` is upstream v1.6.8's: it re-points the ticket link at the
|
||||
caller's frontend, which matters for us specifically because our
|
||||
`ticket_base_url` can differ from `lnbits_baseurl` (separate web app).
|
||||
"""
|
||||
but still needs the instance mailer and an address on the ticket."""
|
||||
event = await get_event(ticket.event)
|
||||
if not event:
|
||||
raise ValueError("Event does not exist.")
|
||||
|
|
@ -158,12 +106,7 @@ async def resend_ticket_email_notification(
|
|||
raise ValueError("Email notifications are not enabled.")
|
||||
if not ticket.email:
|
||||
raise ValueError("Ticket does not have an email address.")
|
||||
if base_url:
|
||||
ticket.extra.ticket_base_url = base_url.rstrip("/")
|
||||
|
||||
# email-only: this is the *email* resend endpoint. Upstream calls
|
||||
# `_deliver_ticket_notifications(ticket, event)` unqualified, which in
|
||||
# our fork would also fire a Nostr DM the organiser did not ask for.
|
||||
return await _deliver_ticket_notifications(ticket, event, email=True, nostr=False)
|
||||
|
||||
|
||||
|
|
@ -199,35 +142,17 @@ def _ticket_details(ticket: Ticket, event: Event) -> str:
|
|||
|
||||
|
||||
def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) -> str:
|
||||
"""Text part of the ticket mail.
|
||||
|
||||
Carries up to two images, which are NOT the same thing (see the note on
|
||||
`_ticket_card_url` vs `_ticket_image_url`): our rendered QR card, always
|
||||
present, and the organiser's uploaded template, only when the buyer's
|
||||
wave opted into it. They had the same label before the v1.6.8 merge
|
||||
because only one of them existed; now that both can appear the labels
|
||||
have to distinguish them.
|
||||
"""
|
||||
message = (
|
||||
return (
|
||||
f"{base_message}\n\n{_ticket_details(ticket, event)}"
|
||||
f"\n\nTicket card: {_ticket_card_url(ticket)}"
|
||||
f"\n\nTicket image: {_ticket_image_url(ticket)}"
|
||||
)
|
||||
ticket_image_url = _ticket_image_url(ticket, event)
|
||||
if ticket_image_url:
|
||||
message = f"{message}\n\nTicket image: {ticket_image_url}"
|
||||
return message
|
||||
|
||||
|
||||
def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) -> str:
|
||||
"""HTML twin of the text part.
|
||||
|
||||
Deliberately no <img> for our own ticket card: it travels as an
|
||||
attachment (renders inline in most clients, works offline, and keeps
|
||||
SpamAssassin's HTML_IMAGE_ONLY rules quiet), and its URL becomes a link.
|
||||
The organiser's uploaded ticket image is a remote URL with no attachment
|
||||
to fall back on, so that one does get upstream's <img> — the surrounding
|
||||
ticket details keep the mail from being image-only either way.
|
||||
"""
|
||||
"""HTML twin of the text part. Deliberately no <img>: the card travels
|
||||
as an attachment (renders inline in most clients, works offline, and
|
||||
keeps SpamAssassin's HTML_IMAGE_ONLY rules quiet), and URLs become
|
||||
links."""
|
||||
text_message = _ticket_delivery_message(ticket, event, base_message)
|
||||
html = escape(text_message)
|
||||
html = re.sub(
|
||||
|
|
@ -235,17 +160,7 @@ def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str)
|
|||
lambda m: f'<a href="{m.group(1)}">{m.group(1)}</a>',
|
||||
html,
|
||||
)
|
||||
html_message = f"<p>{html.replace(chr(10), '<br />')}</p>"
|
||||
|
||||
ticket_image_url = _ticket_image_url(ticket, event)
|
||||
if not ticket_image_url:
|
||||
return html_message
|
||||
|
||||
return (
|
||||
f"{html_message}"
|
||||
f'<p><img src="{escape(ticket_image_url, quote=True)}" alt="Ticket image" '
|
||||
'style="max-width: 200px; height: auto;" /></p>'
|
||||
)
|
||||
return f"<p>{html.replace(chr(10), '<br />')}</p>"
|
||||
|
||||
|
||||
def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]:
|
||||
|
|
@ -264,16 +179,7 @@ async def _deliver_ticket_notifications(
|
|||
) -> TicketResendResult:
|
||||
"""Send the ticket by every configured channel and report per-channel
|
||||
outcome (upstream v1.6.8 shape). `email` / `nostr` override the event's
|
||||
opt-ins when not None; the instance-level prerequisites always apply.
|
||||
|
||||
Upstream v1.6.8 guards the nostr branch with a `_supports_nostr_delivery`
|
||||
check that errors with "Only NIP-05 Nostr identifiers are supported."
|
||||
That guard is NOT taken here: it encodes upstream's limitation, not ours.
|
||||
`_send_nostr_ticket_notification` below dispatches NIP-05 identifiers to
|
||||
core's notifier and bare npubs to `send_nostr_dm`, so adopting the guard
|
||||
would silently refuse identifiers we deliver to today — and say so with
|
||||
a message that would be false for this fork.
|
||||
"""
|
||||
opt-ins when not None; the instance-level prerequisites always apply."""
|
||||
subject, text_message, html_message = _ticket_notification_payload(ticket, event)
|
||||
updated = False
|
||||
|
||||
|
|
@ -449,36 +355,16 @@ def _ticket_url(ticket: Ticket) -> str:
|
|||
return f"{base_url}/events/ticket/{ticket.id}"
|
||||
|
||||
|
||||
def _ticket_card_url(ticket: Ticket) -> str:
|
||||
"""Our rendered ticket-card PNG — always available, and served by THIS
|
||||
extension on the LNbits host, so it is built from `lnbits_baseurl` even
|
||||
when `ticket_base_url` points at a separate web app (deviation from
|
||||
upstream, which assumes both are the same host)."""
|
||||
def _ticket_image_url(ticket: Ticket) -> str:
|
||||
"""The ticket card PNG is served by THIS extension on the LNbits host, so
|
||||
it is built from `lnbits_baseurl` even when `ticket_base_url` points at
|
||||
a separate web app (deviation from upstream, which assumes both are the
|
||||
same host)."""
|
||||
return (
|
||||
f"{settings.lnbits_baseurl.rstrip('/')}/events/api/v1/ticket-card/{ticket.id}"
|
||||
)
|
||||
|
||||
|
||||
def _ticket_image_url(ticket: Ticket, event: Event) -> str | None:
|
||||
"""Upstream's organiser-uploaded ticket template, opted into per wave.
|
||||
|
||||
Distinct from `_ticket_card_url`: that is our own rendered card and is
|
||||
always attached, this is a template the organiser uploads and enables
|
||||
on a specific wave. They shared a name before the v1.6.8 merge, which
|
||||
made them look like one feature.
|
||||
"""
|
||||
waves = ensure_ticket_waves(event)
|
||||
wave = next(
|
||||
(wave for wave in waves if wave.id == ticket.extra.ticket_wave_id),
|
||||
waves[0],
|
||||
)
|
||||
if not wave.use_ticket_image:
|
||||
return None
|
||||
|
||||
base_url = (ticket.extra.ticket_base_url or settings.lnbits_baseurl).rstrip("/")
|
||||
return f"{base_url}/events/api/v1/qr/{ticket.id}"
|
||||
|
||||
|
||||
async def refund_tickets(event_id: str):
|
||||
"""
|
||||
Refund tickets for an event that has not met the minimum ticket requirement.
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 104 KiB |
|
|
@ -13,7 +13,6 @@ window.PageEventsDisplay = {
|
|||
email: '',
|
||||
refund: '',
|
||||
nostr_identifier: '',
|
||||
ticket_wave_id: null,
|
||||
payment_method: 'lightning'
|
||||
}
|
||||
},
|
||||
|
|
@ -47,62 +46,26 @@ window.PageEventsDisplay = {
|
|||
paymentMethods() {
|
||||
// Mirrors `effective_payment_methods` on the backend: an explicit
|
||||
// extra.payment_methods list wins, else Lightning + fiat-if-allow_fiat.
|
||||
// Since v1.6.8 `allow_fiat` is a per-wave flag and `event.allow_fiat`
|
||||
// is only the PRIMARY wave's, so prefer the active wave when there is
|
||||
// one — otherwise a later fiat-enabled wave would not offer fiat.
|
||||
const explicit = this.event?.extra?.payment_methods || []
|
||||
if (explicit.length) return explicit
|
||||
const allowFiat = this.selectedTicketWave
|
||||
? this.selectedTicketWave.allow_fiat
|
||||
: this.event?.allow_fiat
|
||||
return ['lightning', ...(allowFiat ? ['fiat'] : [])]
|
||||
},
|
||||
activeTicketWaves() {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
return (this.event?.extra?.ticket_waves || []).filter(
|
||||
wave =>
|
||||
wave.amount_tickets > 0 &&
|
||||
wave.opening_date <= today &&
|
||||
wave.closing_date >= today
|
||||
)
|
||||
},
|
||||
selectedTicketWave() {
|
||||
return (
|
||||
this.activeTicketWaves.find(
|
||||
wave => wave.id === this.formDialog.data.ticket_wave_id
|
||||
) ||
|
||||
this.activeTicketWaves[0] ||
|
||||
null
|
||||
)
|
||||
},
|
||||
showTicketWaveSelector() {
|
||||
return this.activeTicketWaves.length > 1
|
||||
return ['lightning', ...(this.event?.allow_fiat ? ['fiat'] : [])]
|
||||
},
|
||||
allowFiatCheckout() {
|
||||
return this.paymentMethods.includes('fiat')
|
||||
},
|
||||
paymentMethodOptions() {
|
||||
// Options come from `paymentMethods` — the same list the backend
|
||||
// validates against in `effective_payment_methods` — rather than
|
||||
// being re-derived from per-method booleans, so a rail the server
|
||||
// would reject can never be offered. The `|| method` fallback covers
|
||||
// a method with no label yet (on-chain, once #41 lands).
|
||||
const labels = {
|
||||
lightning: 'Lightning',
|
||||
fiat: this.fiatCheckoutLabel
|
||||
}
|
||||
return this.paymentMethods.map(method => ({
|
||||
value: method,
|
||||
label: labels[method] || method
|
||||
label: method === 'fiat' ? this.fiatCheckoutLabel : 'Lightning'
|
||||
}))
|
||||
},
|
||||
fiatCheckoutLabel() {
|
||||
if (!this.allowFiatCheckout) return 'Fiat'
|
||||
const unit = ['sat', 'sats'].includes(
|
||||
(this.selectedTicketWave?.currency || '').toLowerCase()
|
||||
(this.event?.currency || '').toLowerCase()
|
||||
)
|
||||
? this.selectedTicketWave?.fiat_currency
|
||||
: this.selectedTicketWave?.currency
|
||||
? this.event?.fiat_currency
|
||||
: this.event?.currency
|
||||
return `Fiat (${(unit || 'GBP').toUpperCase()})`
|
||||
},
|
||||
allowEmailNotifications() {
|
||||
|
|
@ -119,16 +82,6 @@ window.PageEventsDisplay = {
|
|||
'GET',
|
||||
`/events/api/v1/events/${this.eventId}`
|
||||
)
|
||||
const activeWaves = (data.extra?.ticket_waves || []).filter(wave => {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
return (
|
||||
wave.amount_tickets > 0 &&
|
||||
wave.opening_date <= today &&
|
||||
wave.closing_date >= today
|
||||
)
|
||||
})
|
||||
this.formDialog.data.ticket_wave_id =
|
||||
activeWaves.length === 1 ? activeWaves[0].id : null
|
||||
return data
|
||||
} catch (error) {
|
||||
this.eventErrorLabel = 'Event unavailable.'
|
||||
|
|
@ -141,11 +94,6 @@ window.PageEventsDisplay = {
|
|||
this.formDialog.data.email = ''
|
||||
this.formDialog.data.refund = ''
|
||||
this.formDialog.data.nostr_identifier = ''
|
||||
this.formDialog.data.ticket_wave_id =
|
||||
this.activeTicketWaves.length === 1
|
||||
? this.activeTicketWaves[0].id
|
||||
: null
|
||||
this.formDialog.data.promo_code = ''
|
||||
this.formDialog.data.payment_method =
|
||||
this.paymentMethods[0] || 'lightning'
|
||||
},
|
||||
|
|
@ -159,13 +107,6 @@ window.PageEventsDisplay = {
|
|||
this.paymentWebsocket.close()
|
||||
this.paymentWebsocket = null
|
||||
}
|
||||
this.paymentReq = null
|
||||
this.receive = {
|
||||
show: false,
|
||||
status: 'pending',
|
||||
paymentReq: null,
|
||||
isFiat: false
|
||||
}
|
||||
},
|
||||
nameValidation(val) {
|
||||
const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g
|
||||
|
|
@ -188,10 +129,6 @@ window.PageEventsDisplay = {
|
|||
this.formDialog.data.email = ''
|
||||
this.formDialog.data.refund = ''
|
||||
this.formDialog.data.nostr_identifier = ''
|
||||
this.formDialog.data.ticket_wave_id =
|
||||
this.activeTicketWaves.length === 1
|
||||
? this.activeTicketWaves[0].id
|
||||
: null
|
||||
this.formDialog.data.payment_method =
|
||||
this.paymentMethods[0] || 'lightning'
|
||||
Quasar.Notify.create({
|
||||
|
|
@ -222,19 +159,10 @@ window.PageEventsDisplay = {
|
|||
{
|
||||
name: this.formDialog.data.name,
|
||||
email: this.formDialog.data.email,
|
||||
ticket_wave_id: this.formDialog.data.ticket_wave_id || null,
|
||||
promo_code: this.formDialog.data.promo_code || null,
|
||||
refund_address: this.formDialog.data.refund || null,
|
||||
nostr_identifier: this.formDialog.data.nostr_identifier || null,
|
||||
// Gate matches the template's (`paymentMethods.length > 1`).
|
||||
// v1.6.8 gated on `showPaymentMethodSelector`
|
||||
// (`allowFiatCheckout || allowOnchain`), a different condition:
|
||||
// where the two disagreed the buyer's visible choice was
|
||||
// silently replaced with 'lightning'.
|
||||
payment_method:
|
||||
this.paymentMethods.length > 1
|
||||
? this.formDialog.data.payment_method
|
||||
: this.paymentMethods[0] || 'lightning'
|
||||
payment_method: this.formDialog.data.payment_method
|
||||
}
|
||||
)
|
||||
const isFiat = Boolean(data.is_fiat)
|
||||
|
|
@ -268,7 +196,7 @@ window.PageEventsDisplay = {
|
|||
|
||||
const url = new URL(window.location)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
url.pathname = `/events/api/v1/tickets/ws/${paymentHash}`
|
||||
url.pathname = `/api/v1/ws/${paymentHash}`
|
||||
url.search = ''
|
||||
url.hash = ''
|
||||
|
||||
|
|
@ -277,7 +205,7 @@ window.PageEventsDisplay = {
|
|||
|
||||
ws.onmessage = event => {
|
||||
const data = JSON.parse(event.data)
|
||||
if (data.paid === true) {
|
||||
if (data.pending === false) {
|
||||
this.paymentSuccess(paymentHash)
|
||||
ws.close()
|
||||
}
|
||||
|
|
@ -286,12 +214,8 @@ window.PageEventsDisplay = {
|
|||
console.error('WebSocket error:', error)
|
||||
}
|
||||
ws.onclose = () => {
|
||||
if (this.paymentWebsocket !== ws) return
|
||||
this.paymentWebsocket = null
|
||||
if (this.receive.show) {
|
||||
setTimeout(() => {
|
||||
if (this.receive.show) this.paymentWatcher(paymentHash)
|
||||
}, 3000)
|
||||
if (this.paymentWebsocket === ws) {
|
||||
this.paymentWebsocket = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@
|
|||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.nostr_identifier"
|
||||
label="(optional) Nostr NIP-05"
|
||||
label="(optional) Nostr NIP-05 or npub"
|
||||
hint="If provided, we'll DM your ticket link after payment."
|
||||
></q-input>
|
||||
</div>
|
||||
|
|
@ -89,21 +89,6 @@
|
|||
lazy-rules
|
||||
:hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`"
|
||||
></q-input>
|
||||
<q-select
|
||||
v-if="showTicketWaveSelector"
|
||||
filled
|
||||
dense
|
||||
v-model="formDialog.data.ticket_wave_id"
|
||||
emit-value
|
||||
map-options
|
||||
label="Ticket wave"
|
||||
:options="
|
||||
activeTicketWaves.map(wave => ({
|
||||
label: `${wave.title} - ${wave.price_per_ticket} ${wave.currency}`,
|
||||
value: wave.id
|
||||
}))
|
||||
"
|
||||
></q-select>
|
||||
<div class="row q-col-gutter-md q-pt-lg items-center">
|
||||
<div v-if="paymentMethods.length > 1" class="col-auto">
|
||||
<q-option-group
|
||||
|
|
@ -132,8 +117,7 @@
|
|||
:disable="
|
||||
formDialog.data.name == '' ||
|
||||
formDialog.data.email == '' ||
|
||||
(showTicketWaveSelector && !formDialog.data.ticket_wave_id) ||
|
||||
(receive.show && Boolean(paymentReq))
|
||||
Boolean(paymentReq)
|
||||
"
|
||||
type="submit"
|
||||
>Submit</q-btn
|
||||
|
|
|
|||
|
|
@ -4,10 +4,7 @@ window.PageEvents = {
|
|||
return {
|
||||
events: [],
|
||||
tickets: [],
|
||||
allPaidTickets: [],
|
||||
resendingTicketEmails: [],
|
||||
isUploadingTicketTemplate: false,
|
||||
ticketImageUploadTarget: null,
|
||||
currencies: [],
|
||||
pendingEvents: [],
|
||||
allUserEvents: [],
|
||||
|
|
@ -28,12 +25,7 @@ window.PageEvents = {
|
|||
label: 'Owner',
|
||||
field: 'wallet_user_id'
|
||||
},
|
||||
{
|
||||
name: 'id',
|
||||
align: 'left',
|
||||
label: 'ID',
|
||||
field: row => this.shortenId(row.id)
|
||||
},
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||
{
|
||||
name: 'event_start_date',
|
||||
|
|
@ -47,6 +39,12 @@ window.PageEvents = {
|
|||
label: 'End date',
|
||||
field: 'event_end_date'
|
||||
},
|
||||
{
|
||||
name: 'closing_date',
|
||||
align: 'left',
|
||||
label: 'Ticket close',
|
||||
field: 'closing_date'
|
||||
},
|
||||
{
|
||||
name: 'canceled',
|
||||
align: 'left',
|
||||
|
|
@ -69,15 +67,13 @@ window.PageEvents = {
|
|||
name: 'event_start_date',
|
||||
align: 'left',
|
||||
label: 'Start date',
|
||||
field: 'event_start_date',
|
||||
format: val => this.formatEventDate(val)
|
||||
field: 'event_start_date'
|
||||
},
|
||||
{
|
||||
name: 'event_end_date',
|
||||
align: 'left',
|
||||
label: 'End date',
|
||||
field: 'event_end_date',
|
||||
format: val => this.formatEventDate(val)
|
||||
field: 'event_end_date'
|
||||
},
|
||||
{
|
||||
name: 'closing_date',
|
||||
|
|
@ -131,41 +127,16 @@ window.PageEvents = {
|
|||
}
|
||||
},
|
||||
ticketsTable: {
|
||||
loading: false,
|
||||
columns: [
|
||||
{
|
||||
name: 'event',
|
||||
align: 'left',
|
||||
label: 'Event',
|
||||
field: row => this.shortenId(row.event)
|
||||
},
|
||||
{name: 'event', align: 'left', label: 'Event', field: 'event'},
|
||||
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||
{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',
|
||||
align: 'left',
|
||||
label: 'Registered',
|
||||
field: 'registered'
|
||||
},
|
||||
{
|
||||
name: 'nostr',
|
||||
align: 'left',
|
||||
label: 'Nostr',
|
||||
field: row => row.extra?.nostr_identifier || ''
|
||||
},
|
||||
{
|
||||
name: 'promo_code',
|
||||
align: 'left',
|
||||
|
|
@ -175,302 +146,39 @@ window.PageEvents = {
|
|||
{name: 'id', align: 'left', label: 'ID', field: 'id'}
|
||||
],
|
||||
pagination: {
|
||||
sortBy: 'time',
|
||||
descending: true,
|
||||
page: 1,
|
||||
rowsPerPage: 10,
|
||||
rowsNumber: 10
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
// Rails an organizer can enable per event. Mirrors the webapp's
|
||||
// CreateEventDialog; `fiat` is rendered disabled when the LNbits user
|
||||
// has no fiat provider (see `hasFiatProvider`).
|
||||
paymentMethodOptions: [
|
||||
{
|
||||
value: 'lightning',
|
||||
label: 'Lightning',
|
||||
hint: 'Pay with any Lightning wallet'
|
||||
},
|
||||
{
|
||||
value: 'fiat',
|
||||
label: 'Card',
|
||||
hint: 'Card or bank through your configured fiat provider'
|
||||
}
|
||||
],
|
||||
// Same list the webapp offers (src/modules/events/types/category.ts);
|
||||
// published as NIP-52 `t` tags so both clients filter on one vocabulary.
|
||||
categoryOptions: [
|
||||
'concert',
|
||||
'workshop',
|
||||
'market',
|
||||
'festival',
|
||||
'exhibition',
|
||||
'sport',
|
||||
'theater',
|
||||
'cinema',
|
||||
'party',
|
||||
'talk',
|
||||
'conference',
|
||||
'meetup',
|
||||
'food',
|
||||
'outdoor',
|
||||
'kids',
|
||||
'wellness',
|
||||
'technology',
|
||||
'art',
|
||||
'music',
|
||||
'dance',
|
||||
'literature',
|
||||
'comedy',
|
||||
'charity',
|
||||
'tradition',
|
||||
'other'
|
||||
].map(c => ({label: c.charAt(0).toUpperCase() + c.slice(1), value: c})),
|
||||
formDialog: {
|
||||
show: false,
|
||||
data: {
|
||||
currency: 'sats',
|
||||
allow_fiat: false,
|
||||
fiat_currency: 'GBP',
|
||||
location: '',
|
||||
categories: [],
|
||||
extra: {
|
||||
payment_methods: ['lightning'],
|
||||
ticket_waves: [],
|
||||
promo_codes: [],
|
||||
notification_subject: '',
|
||||
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: []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
hasFiatProvider() {
|
||||
return (this.g.user?.fiat_providers || []).length > 0
|
||||
},
|
||||
fiatProviderNames() {
|
||||
return (this.g.user?.fiat_providers || [])
|
||||
.map(p => p.charAt(0).toUpperCase() + p.slice(1))
|
||||
.join(', ')
|
||||
},
|
||||
acceptsFiat() {
|
||||
return (this.formDialog.data.extra?.payment_methods || []).includes(
|
||||
'fiat'
|
||||
)
|
||||
},
|
||||
isSatPrice() {
|
||||
return !this.isFiatCurrency(this.formDialog.data.currency)
|
||||
},
|
||||
fiatCurrencyOptions() {
|
||||
return this.currencies.filter(c => this.isFiatCurrency(c))
|
||||
}
|
||||
},
|
||||
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) {
|
||||
return !['sat', 'sats'].includes((currency || '').toLowerCase())
|
||||
},
|
||||
normalizePromoCodes(promoCodes = []) {
|
||||
return promoCodes
|
||||
.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(
|
||||
getTickets() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/events/api/v1/tickets?all_wallets=true',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
this.allPaidTickets = data.filter(ticket => ticket.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
|
||||
}
|
||||
.then(response => {
|
||||
this.tickets = response.data.filter(e => e.paid)
|
||||
})
|
||||
},
|
||||
deleteTicket(ticketId) {
|
||||
const tickets = _.findWhere(this.tickets, {id: ticketId})
|
||||
|
|
@ -485,9 +193,10 @@ window.PageEvents = {
|
|||
'/events/api/v1/tickets/' + ticketId,
|
||||
wallet.adminkey
|
||||
)
|
||||
.then(async () => {
|
||||
await this.getTickets()
|
||||
await this.getAllTickets()
|
||||
.then(response => {
|
||||
this.tickets = _.reject(this.tickets, function (obj) {
|
||||
return obj.id == ticketId
|
||||
})
|
||||
})
|
||||
.catch(LNbits.utils.notifyApiError)
|
||||
})
|
||||
|
|
@ -505,30 +214,14 @@ window.PageEvents = {
|
|||
wallet.adminkey
|
||||
)
|
||||
.then(response => {
|
||||
const result = response.data
|
||||
this.tickets = this.tickets.map(obj =>
|
||||
obj.id === ticket.id ? result.ticket : obj
|
||||
obj.id === ticket.id ? response.data : obj
|
||||
)
|
||||
|
||||
if (result.email?.attempted) {
|
||||
Quasar.Notify.create({
|
||||
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
|
||||
})
|
||||
}
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Ticket email resent.',
|
||||
icon: null
|
||||
})
|
||||
})
|
||||
.catch(LNbits.utils.notifyApiError)
|
||||
.finally(() => {
|
||||
|
|
@ -538,7 +231,7 @@ window.PageEvents = {
|
|||
})
|
||||
},
|
||||
exportticketsCSV() {
|
||||
LNbits.utils.exportCSV(this.ticketsTable.columns, this.allPaidTickets)
|
||||
LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets)
|
||||
},
|
||||
getEvents() {
|
||||
LNbits.api
|
||||
|
|
@ -689,70 +382,37 @@ window.PageEvents = {
|
|||
},
|
||||
splitDateTime(value) {
|
||||
// Inverse of foldDateTime: split a stored string back into the
|
||||
// day/time pieces the form inputs bind to. Slicing to HH:MM also
|
||||
// drops the seconds + offset suffix withLocalTzOffset stamps on
|
||||
// submit, so the organizer sees the wall-clock they entered.
|
||||
// day/time pieces the form inputs bind to.
|
||||
if (!value) return {day: '', time: ''}
|
||||
const [day, time = ''] = value.split('T')
|
||||
// Time inputs only accept HH:MM, drop any seconds we stored.
|
||||
return {day, time: time.slice(0, 5)}
|
||||
},
|
||||
withLocalTzOffset(value) {
|
||||
// Stamp the browser's UTC offset on a "YYYY-MM-DDTHH:MM" value.
|
||||
// The publisher's `_to_unix` treats a naive datetime as UTC, so an
|
||||
// event entered as 18:00 in CEST would otherwise go out on Nostr
|
||||
// as 18:00 UTC. Same transform the webapp applies; date-only
|
||||
// values pass through unchanged (they map to NIP-52 kind 31922).
|
||||
if (!value || !value.includes('T')) return value
|
||||
const offMin = -new Date(value).getTimezoneOffset()
|
||||
const sign = offMin >= 0 ? '+' : '-'
|
||||
const abs = Math.abs(offMin)
|
||||
const hh = String(Math.floor(abs / 60)).padStart(2, '0')
|
||||
const mm = String(abs % 60).padStart(2, '0')
|
||||
return `${value}:00${sign}${hh}:${mm}`
|
||||
},
|
||||
formatEventDate(value) {
|
||||
// Table display: "YYYY-MM-DD" or "YYYY-MM-DD HH:MM".
|
||||
if (!value) return ''
|
||||
const {day, time} = this.splitDateTime(value)
|
||||
return time ? `${day} ${time}` : day
|
||||
},
|
||||
validateEndDate() {
|
||||
// Cross-field rule for the end-day input: end >= start, compared
|
||||
// on the folded date+time so an equal-day earlier time is caught.
|
||||
const d = this.formDialog.data
|
||||
const start = this.foldDateTime(d.event_start_day, d.event_start_time)
|
||||
const end = this.foldDateTime(d.event_end_day, d.event_end_time)
|
||||
if (!start || !end) return true
|
||||
return end >= start || 'End must be on or after start'
|
||||
},
|
||||
sendEventData() {
|
||||
const wallet = _.findWhere(this.g.user.wallets, {
|
||||
id: this.formDialog.data.wallet
|
||||
})
|
||||
const data = {...this.formDialog.data}
|
||||
data.event_start_date = this.withLocalTzOffset(
|
||||
this.foldDateTime(data.event_start_day, data.event_start_time)
|
||||
data.event_start_date = this.foldDateTime(
|
||||
data.event_start_day,
|
||||
data.event_start_time
|
||||
)
|
||||
data.event_end_date = this.withLocalTzOffset(
|
||||
this.foldDateTime(data.event_end_day, data.event_end_time)
|
||||
data.event_end_date = this.foldDateTime(
|
||||
data.event_end_day,
|
||||
data.event_end_time
|
||||
)
|
||||
delete data.event_start_day
|
||||
delete data.event_start_time
|
||||
delete data.event_end_day
|
||||
delete data.event_end_time
|
||||
// Optional NIP-52 fields: blank location is "unset", not "".
|
||||
data.location = (data.location || '').trim() || null
|
||||
data.categories = data.categories || []
|
||||
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) {
|
||||
data.extra.promo_codes = this.normalizePromoCodes(
|
||||
data.extra.promo_codes
|
||||
)
|
||||
data.extra.promo_codes = data.extra.promo_codes
|
||||
.filter(code => code.code?.trim() !== '')
|
||||
.map(code => ({
|
||||
...code,
|
||||
code: code.code.trim().toUpperCase()
|
||||
}))
|
||||
}
|
||||
const methods = data.extra?.payment_methods || []
|
||||
if (methods.length === 0) {
|
||||
|
|
@ -765,14 +425,11 @@ window.PageEvents = {
|
|||
// allow_fiat stays the fiat-currency carrier the backend and the
|
||||
// NIP-52 tags read; keep it in lockstep with the checkbox list.
|
||||
data.allow_fiat = methods.includes('fiat')
|
||||
if (this.isFiatCurrency(data.currency)) {
|
||||
// A fiat-priced event settles in its price currency; mirror it so
|
||||
// the payload (and the tickets_fiat_currency tag) stay coherent.
|
||||
data.fiat_currency = data.currency
|
||||
} else if (!data.allow_fiat) {
|
||||
data.fiat_currency = 'GBP'
|
||||
if (!this.isFiatCurrency(data.currency)) {
|
||||
if (!data.allow_fiat) {
|
||||
data.fiat_currency = 'GBP'
|
||||
}
|
||||
}
|
||||
this.syncPrimaryWaveFromForm(data)
|
||||
|
||||
if (data.id) {
|
||||
this.updateEvent(wallet, data)
|
||||
|
|
@ -785,14 +442,10 @@ window.PageEvents = {
|
|||
if (data && data.id) {
|
||||
const start = this.splitDateTime(data.event_start_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 = {
|
||||
...hydrated,
|
||||
...data,
|
||||
extra: {
|
||||
...(hydrated.extra || {}),
|
||||
...(data.extra || {}),
|
||||
// Events created before extra.payment_methods existed carry an
|
||||
// empty list; show the rails the backend actually accepts for
|
||||
// them (Lightning always, fiat when allow_fiat).
|
||||
|
|
@ -800,8 +453,6 @@ window.PageEvents = {
|
|||
? data.extra.payment_methods
|
||||
: ['lightning', ...(data.allow_fiat ? ['fiat'] : [])]
|
||||
},
|
||||
location: data.location || '',
|
||||
categories: [...(data.categories || [])],
|
||||
event_start_day: start.day,
|
||||
event_start_time: start.time,
|
||||
event_end_day: end.day,
|
||||
|
|
@ -812,8 +463,6 @@ window.PageEvents = {
|
|||
currency: 'sats',
|
||||
allow_fiat: false,
|
||||
fiat_currency: 'GBP',
|
||||
location: '',
|
||||
categories: [],
|
||||
event_start_day: '',
|
||||
event_start_time: '',
|
||||
event_end_day: '',
|
||||
|
|
@ -824,21 +473,6 @@ window.PageEvents = {
|
|||
min_tickets: 1,
|
||||
email_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: [],
|
||||
notification_subject: '',
|
||||
notification_body: ''
|
||||
|
|
@ -853,29 +487,10 @@ window.PageEvents = {
|
|||
currency: 'sats',
|
||||
allow_fiat: false,
|
||||
fiat_currency: 'GBP',
|
||||
location: '',
|
||||
categories: [],
|
||||
extra: {
|
||||
payment_methods: ['lightning'],
|
||||
conditional: false,
|
||||
min_tickets: 1,
|
||||
email_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: [],
|
||||
notification_subject: '',
|
||||
notification_body: ''
|
||||
|
|
@ -896,183 +511,6 @@ window.PageEvents = {
|
|||
const link = _.findWhere(this.events, {id: formId})
|
||||
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) {
|
||||
LNbits.api
|
||||
.request(
|
||||
|
|
@ -1139,7 +577,6 @@ window.PageEvents = {
|
|||
async created() {
|
||||
if (this.g.user.wallets.length) {
|
||||
this.getTickets()
|
||||
this.getAllTickets()
|
||||
this.getEvents()
|
||||
this.getSettings()
|
||||
this.getPendingEvents()
|
||||
|
|
|
|||
|
|
@ -243,86 +243,45 @@
|
|||
<q-tr v-show="props.expand" :props="props">
|
||||
<q-td colspan="100%">
|
||||
<div class="q-pa-md">
|
||||
<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="text-subtitle1 q-mb-md">Promo codes</div>
|
||||
<div class="column">
|
||||
<div
|
||||
v-if="
|
||||
props.row.extra.promo_codes.filter(
|
||||
code => code.active
|
||||
).length == 0
|
||||
"
|
||||
v-if="props.row.extra.promo_codes.length == 0"
|
||||
class="text-caption"
|
||||
>
|
||||
No active promo codes for this event.
|
||||
No promo codes for this event.
|
||||
</div>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div
|
||||
v-for="(
|
||||
code, index
|
||||
) in props.row.extra.promo_codes.filter(
|
||||
code => code.active
|
||||
)"
|
||||
:key="index"
|
||||
class="col-auto q-mb-sm"
|
||||
>
|
||||
<div
|
||||
v-for="(code, index) in props.row.extra.promo_codes"
|
||||
:key="index"
|
||||
class="row items-center q-col-gutter-sm q-mb-sm"
|
||||
>
|
||||
<div class="col-auto">
|
||||
<q-chip
|
||||
square
|
||||
size="md"
|
||||
clickable
|
||||
@click="utils.copyText(code.code.toUpperCase())"
|
||||
>
|
||||
<span
|
||||
v-text="
|
||||
`${code.code.toUpperCase()} - ${code.discount_percent}%`
|
||||
"
|
||||
></span>
|
||||
<q-avatar
|
||||
icon="bookmark"
|
||||
:color="code.active ? 'green' : 'grey'"
|
||||
text-color="white"
|
||||
></q-avatar>
|
||||
<span v-text="code.code.toUpperCase()"></span>
|
||||
</q-chip>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
Discount:
|
||||
<span v-text="code.discount_percent"></span>%
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
Status:
|
||||
<span
|
||||
:class="code.active ? 'text-green' : 'text-grey'"
|
||||
v-text="code.active ? 'Active' : 'Inactive'"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -349,11 +308,9 @@
|
|||
dense
|
||||
flat
|
||||
:rows="tickets"
|
||||
:loading="ticketsTable.loading"
|
||||
row-key="id"
|
||||
:columns="ticketsTable.columns"
|
||||
v-model:pagination="ticketsTable.pagination"
|
||||
@request="getTickets"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
|
|
@ -522,10 +479,8 @@
|
|||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.name"
|
||||
type="text"
|
||||
label="Title of event *"
|
||||
lazy-rules
|
||||
:rules="[val => !!val || 'Title is required']"
|
||||
type="name"
|
||||
label="Title of event "
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col q-pl-sm">
|
||||
|
|
@ -549,30 +504,6 @@
|
|||
label="Info about the event"
|
||||
hint="Markdown supported"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.location"
|
||||
type="text"
|
||||
label="Location"
|
||||
hint="Venue or address, e.g. Salle des fêtes, Foix. Published as the NIP-52 location tag."
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="place"></q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
multiple
|
||||
use-chips
|
||||
emit-value
|
||||
map-options
|
||||
v-model="formDialog.data.categories"
|
||||
:options="categoryOptions"
|
||||
label="Categories"
|
||||
hint="Published as NIP-52 hashtags so clients can filter the feed."
|
||||
></q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
|
|
@ -581,16 +512,25 @@
|
|||
label="Image URL"
|
||||
hint="Optional banner image to display on the event page"
|
||||
></q-input>
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col-4">Ticket closing date</div>
|
||||
<div class="col-8">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.closing_date"
|
||||
type="date"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col-4">Event begins *</div>
|
||||
<div class="col-4">Event begins</div>
|
||||
<div class="col-5">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.event_start_day"
|
||||
type="date"
|
||||
lazy-rules
|
||||
:rules="[val => !!val || 'Start date is required']"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
|
|
@ -612,10 +552,6 @@
|
|||
dense
|
||||
v-model.trim="formDialog.data.event_end_day"
|
||||
type="date"
|
||||
hint="Defaults to the start date"
|
||||
lazy-rules
|
||||
reactive-rules
|
||||
:rules="[validateEndDate]"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
|
|
@ -628,51 +564,6 @@
|
|||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col-4">Ticket sales close</div>
|
||||
<div class="col-8">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.closing_date"
|
||||
type="date"
|
||||
hint="Optional. Defaults to the event end date."
|
||||
></q-input>
|
||||
</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="col">
|
||||
<q-select
|
||||
|
|
@ -680,7 +571,7 @@
|
|||
dense
|
||||
v-model="formDialog.data.currency"
|
||||
type="text"
|
||||
label="Price currency"
|
||||
label="Unit"
|
||||
:options="currencies"
|
||||
></q-select>
|
||||
</div>
|
||||
|
|
@ -690,9 +581,7 @@
|
|||
dense
|
||||
v-model.number="formDialog.data.amount_tickets"
|
||||
type="number"
|
||||
min="1"
|
||||
label="Amount of tickets"
|
||||
hint="Total tickets on sale"
|
||||
label="Amount of tickets "
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col">
|
||||
|
|
@ -711,100 +600,34 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="q-mt-sm">
|
||||
<div class="text-subtitle2">Payment methods *</div>
|
||||
<div class="text-caption text-grey-7">Payment methods *</div>
|
||||
<q-option-group
|
||||
v-model="formDialog.data.extra.payment_methods"
|
||||
type="checkbox"
|
||||
inline
|
||||
:options="paymentMethodOptions"
|
||||
></q-option-group>
|
||||
<div class="text-caption text-grey-7">
|
||||
Pick the rails buyers can pay with. Untick Lightning for a
|
||||
card-only sale.
|
||||
</div>
|
||||
<div class="row q-col-gutter-md q-mt-xs">
|
||||
<div
|
||||
v-for="opt in paymentMethodOptions"
|
||||
:key="opt.value"
|
||||
class="col-12 col-sm-6"
|
||||
>
|
||||
<!-- The span carries the tooltip: a disabled checkbox
|
||||
swallows pointer events, the wrapper still hovers. -->
|
||||
<span class="inline-block full-width">
|
||||
<q-checkbox
|
||||
v-model="formDialog.data.extra.payment_methods"
|
||||
:val="opt.value"
|
||||
:label="opt.label"
|
||||
:disable="opt.value === 'fiat' && !hasFiatProvider"
|
||||
></q-checkbox>
|
||||
<div class="text-caption text-grey-7 q-pl-lg">
|
||||
<span v-text="opt.hint"></span>
|
||||
<span
|
||||
v-if="opt.value === 'fiat' && hasFiatProvider"
|
||||
v-text="' (' + fiatProviderNames + ')'"
|
||||
></span>
|
||||
</div>
|
||||
<q-tooltip
|
||||
v-if="opt.value === 'fiat' && !hasFiatProvider"
|
||||
max-width="280px"
|
||||
>
|
||||
Your LNbits user has no fiat provider configured. Ask the
|
||||
instance admin to enable Stripe, PayPal or Square (Admin →
|
||||
Fiat providers) to accept card payments.
|
||||
</q-tooltip>
|
||||
</span>
|
||||
</div>
|
||||
Card / fiat checkout goes through the fiat provider configured on
|
||||
this LNbits instance. Untick Lightning for a card-only event.
|
||||
</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
|
||||
v-if="acceptsFiat && isSatPrice"
|
||||
v-if="
|
||||
acceptsFiat &&
|
||||
['sat', 'sats'].includes(
|
||||
(formDialog.data.currency || '').toLowerCase()
|
||||
)
|
||||
"
|
||||
filled
|
||||
dense
|
||||
v-model="formDialog.data.fiat_currency"
|
||||
label="Fiat currency"
|
||||
hint="Currency card buyers are charged in."
|
||||
:options="fiatCurrencyOptions"
|
||||
lazy-rules
|
||||
:rules="[
|
||||
val => !!val || 'Pick a fiat currency for buyers paying by card'
|
||||
]"
|
||||
label="Fiat checkout currency"
|
||||
:options="
|
||||
currencies.filter(
|
||||
c => !['sat', 'sats'].includes((c || '').toLowerCase())
|
||||
)
|
||||
"
|
||||
></q-select>
|
||||
<q-expansion-item
|
||||
group="advanced"
|
||||
|
|
@ -837,50 +660,112 @@
|
|||
</div>
|
||||
</div>
|
||||
<q-separator class="q-my-md"></q-separator>
|
||||
<div class="text-subtitle1 q-mb-md">Promo Codes</div>
|
||||
<div class="text-caption">
|
||||
Allow users to enter a promo code for discounts.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(code, index) in formDialog.data.extra.promo_codes"
|
||||
:key="index"
|
||||
class="row q-col-gutter-sm q-mt-md"
|
||||
>
|
||||
<q-input
|
||||
class="col-8"
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.extra.promo_codes[index].code"
|
||||
type="text"
|
||||
label="Promo Code"
|
||||
>
|
||||
<template v-slot:before>
|
||||
<q-checkbox
|
||||
left-label
|
||||
v-model="formDialog.data.extra.promo_codes[index].active"
|
||||
checked-icon="radio_button_checked"
|
||||
unchecked-icon="radio_button_unchecked"
|
||||
></q-checkbox>
|
||||
<q-tooltip>
|
||||
<span
|
||||
v-text="
|
||||
formDialog.data.extra.promo_codes[index].active
|
||||
? 'Active'
|
||||
: 'Inactive'
|
||||
"
|
||||
></span>
|
||||
</q-tooltip>
|
||||
</template>
|
||||
</q-input>
|
||||
<q-input
|
||||
class="col-4"
|
||||
filled
|
||||
dense
|
||||
v-model.number="
|
||||
formDialog.data.extra.promo_codes[index].discount_percent
|
||||
"
|
||||
type="number"
|
||||
label="Discount (%)"
|
||||
min="0"
|
||||
max="100"
|
||||
>
|
||||
<template v-slot:after>
|
||||
<q-btn
|
||||
round
|
||||
dense
|
||||
flat
|
||||
icon="delete"
|
||||
@click="formDialog.data.extra.promo_codes.splice(index, 1)"
|
||||
></q-btn>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-12 q-mt-md">
|
||||
<q-btn
|
||||
@click="
|
||||
formDialog.data.extra.promo_codes.push({
|
||||
code: '',
|
||||
discount_percent: 0,
|
||||
active: true
|
||||
})
|
||||
"
|
||||
>Add Promo Code</q-btn
|
||||
>
|
||||
</div>
|
||||
<q-separator class="q-my-md"></q-separator>
|
||||
<div class="text-subtitle1 q-mb-md">Ticket Delivery</div>
|
||||
<div class="text-caption">
|
||||
Send the paid ticket link automatically by email or Nostr DM.
|
||||
</div>
|
||||
<div class="row items-center q-col-gutter-md">
|
||||
<div class="col-auto">
|
||||
<q-toggle
|
||||
v-model="formDialog.data.extra.email_notifications"
|
||||
label="Email notifications"
|
||||
left-label
|
||||
></q-toggle>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<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-toggle
|
||||
v-model="formDialog.data.extra.email_notifications"
|
||||
label="Email notifications"
|
||||
left-label
|
||||
></q-toggle>
|
||||
<q-toggle
|
||||
v-model="formDialog.data.extra.nostr_notifications"
|
||||
label="Nostr notifications"
|
||||
left-label
|
||||
></q-toggle>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-separator class="q-my-md"></q-separator>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.extra.notification_subject"
|
||||
type="text"
|
||||
label="Ticket notification subject"
|
||||
hint="Used as the email subject when sending paid ticket links."
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.extra.notification_body"
|
||||
type="textarea"
|
||||
label="Ticket notification body"
|
||||
hint="Shown before the ticket link in the paid ticket notification."
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="formDialog.data.id"
|
||||
|
|
@ -894,11 +779,14 @@
|
|||
unelevated
|
||||
color="primary"
|
||||
:disable="
|
||||
!formDialog.data.wallet ||
|
||||
!formDialog.data.name ||
|
||||
!formDialog.data.event_start_day ||
|
||||
primaryTicketWave().title == null ||
|
||||
primaryTicketWave().opening_date == null
|
||||
formDialog.data.wallet == null ||
|
||||
formDialog.data.name == null ||
|
||||
formDialog.data.info == null ||
|
||||
formDialog.data.closing_date == null ||
|
||||
formDialog.data.event_start_day == null ||
|
||||
formDialog.data.event_end_day == null ||
|
||||
formDialog.data.amount_tickets == null ||
|
||||
formDialog.data.price_per_ticket == null
|
||||
"
|
||||
type="submit"
|
||||
>Create Event</q-btn
|
||||
|
|
@ -910,277 +798,5 @@
|
|||
</q-form>
|
||||
</q-card>
|
||||
</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>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -3,36 +3,16 @@ window.PageEventsTicket = {
|
|||
data() {
|
||||
return {
|
||||
ticketId: null,
|
||||
ticket: null,
|
||||
printMode: false,
|
||||
qrSrc: ''
|
||||
ticket: null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async printWindow() {
|
||||
this.printMode = true
|
||||
await this.$nextTick()
|
||||
await this.waitForPrintAssets()
|
||||
setTimeout(() => window.print(), 50)
|
||||
},
|
||||
async waitForPrintAssets() {
|
||||
await this.$nextTick()
|
||||
const img = document.querySelector('.ticket-print-qr')
|
||||
if (!img) return
|
||||
if (img.complete && img.naturalWidth > 0) return
|
||||
await new Promise(resolve => {
|
||||
const done = () => resolve()
|
||||
img.addEventListener('load', done, {once: true})
|
||||
img.addEventListener('error', done, {once: true})
|
||||
setTimeout(done, 500)
|
||||
})
|
||||
printWindow() {
|
||||
window.print()
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
this.ticketId = this.$route.params.id
|
||||
this.qrSrc = `/api/v1/qrcode?data=${encodeURIComponent(
|
||||
`ticket://${this.ticketId}`
|
||||
)}`
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
|
|
@ -42,8 +22,5 @@ window.PageEventsTicket = {
|
|||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
window.addEventListener('afterprint', () => {
|
||||
this.printMode = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,53 +36,4 @@
|
|||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div class="ticket-print-sheet" v-if="printMode">
|
||||
<img class="ticket-print-qr" :src="qrSrc" alt="Ticket QR" v-if="qrSrc" />
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@media print {
|
||||
@page {
|
||||
size: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
* {
|
||||
color: black !important;
|
||||
background: white !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
body > * {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ticket-print-sheet {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100vw;
|
||||
min-height: 100vh;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
background: white !important;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
.ticket-print-qr {
|
||||
display: block;
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,112 +0,0 @@
|
|||
"""`amount_tickets` is the remaining count (aiolabs/events#34).
|
||||
|
||||
`set_ticket_paid` decrements it on every sale and `sold` increments, so
|
||||
subtracting `sold` from it removes each sale twice. That made the buyer
|
||||
cap under-report and, once an event passed half its capacity, turned
|
||||
`sold >= amount_tickets` true and declared it sold out with stock left.
|
||||
Measured on aio-demo before the fix: 16 of 24 live events affected,
|
||||
3 already refusing sales while tickets remained.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .. import views_api
|
||||
from ..models import CreateTicket, Event
|
||||
|
||||
SENTINEL = object()
|
||||
|
||||
|
||||
def _event(amount_tickets: int, sold: int) -> Event:
|
||||
return Event(
|
||||
id="evt",
|
||||
wallet="w",
|
||||
name="Capacity",
|
||||
info="",
|
||||
closing_date="2030-01-01",
|
||||
event_start_date="2030-01-01",
|
||||
event_end_date="2030-01-02",
|
||||
currency="sat",
|
||||
price_per_ticket=0, # free path, so the guard is all that gates us
|
||||
amount_tickets=amount_tickets,
|
||||
sold=sold,
|
||||
time=datetime.now(timezone.utc),
|
||||
status="approved",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def issued(monkeypatch):
|
||||
"""Past the capacity guard the free path short-circuits to a sentinel."""
|
||||
monkeypatch.setattr(
|
||||
views_api, "_issue_free_tickets", AsyncMock(return_value=SENTINEL)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
views_api, "_resolve_frontend_root", lambda data, req: "http://x"
|
||||
)
|
||||
return SENTINEL
|
||||
|
||||
|
||||
async def _buy(amount_tickets: int, sold: int, quantity: int, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
views_api, "get_event", AsyncMock(return_value=_event(amount_tickets, sold))
|
||||
)
|
||||
return await views_api.api_ticket_create(
|
||||
"evt", CreateTicket(user_id="u1", quantity=quantity), SimpleNamespace()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_last_ticket_still_sells(monkeypatch, issued):
|
||||
"""One left, one wanted. Previously refused once sold >= remaining."""
|
||||
assert await _buy(1, 99, 1, monkeypatch) is issued
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sold_out_only_when_actually_empty(monkeypatch, issued):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _buy(0, 100, 1, monkeypatch)
|
||||
assert exc.value.detail == "Event is sold out."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("sold", [0, 49, 50, 51, 500])
|
||||
async def test_remaining_alone_decides_availability(monkeypatch, issued, sold):
|
||||
"""The regression proper: with 50 left the event sells, whatever
|
||||
`sold` says. Because remaining + sold is the original capacity,
|
||||
`sold >= amount_tickets` first flips true at the halfway point —
|
||||
sold=50 here — so an event locked itself once half its seats went.
|
||||
The boundary cases (49/50/51) are the ones that matter."""
|
||||
assert await _buy(50, sold, 1, monkeypatch) is issued
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_order_may_take_everything_left(monkeypatch, issued):
|
||||
assert await _buy(10, 40, 10, monkeypatch) is issued
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_order_over_capacity_reports_the_true_remainder(monkeypatch, issued):
|
||||
"""3 left, 5 wanted. The message must name the real remainder — it
|
||||
used to say `3 - 40 = -37`. (`CreateTicket.quantity` is capped at 10
|
||||
by the model, so the overshoot is tested within that bound.)"""
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _buy(3, 40, 5, monkeypatch)
|
||||
assert exc.value.detail == "Only 3 ticket(s) remaining for this event."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_walk_an_event_to_capacity(monkeypatch, issued):
|
||||
"""50-seat event, one sale at a time, mirroring set_ticket_paid."""
|
||||
remaining, sold = 50, 0
|
||||
for _ in range(50):
|
||||
assert await _buy(remaining, sold, 1, monkeypatch) is issued
|
||||
remaining, sold = remaining - 1, sold + 1
|
||||
assert (remaining, sold) == (0, 50)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _buy(remaining, sold, 1, monkeypatch)
|
||||
assert exc.value.detail == "Event is sold out."
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
"""The `nostr_publish_pending` marker and its lifecycle.
|
||||
|
||||
Inventory reaches clients only through the republished NIP-52 calendar
|
||||
event. These tests pin the invariant that makes drift recoverable: the
|
||||
flag goes up before every attempt and comes down only on a confirmed
|
||||
success, so every shape of failure — raised, skipped, never attempted —
|
||||
leaves the row queryable by the sweep.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from .. import nostr_hooks, services
|
||||
from ..models import Event, Ticket
|
||||
|
||||
|
||||
def _event(**kwargs) -> Event:
|
||||
defaults = {
|
||||
"id": "evt",
|
||||
"wallet": "w",
|
||||
"name": "Test",
|
||||
"info": "",
|
||||
"closing_date": "2030-01-01",
|
||||
"event_start_date": "2030-01-01",
|
||||
"event_end_date": "2030-01-02",
|
||||
"currency": "sat",
|
||||
"price_per_ticket": 1000,
|
||||
"amount_tickets": 10,
|
||||
"time": datetime.now(timezone.utc),
|
||||
"status": "approved",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return Event(**defaults)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def saved(monkeypatch):
|
||||
"""Capture every update_event write so ordering can be asserted."""
|
||||
writes: list[bool] = []
|
||||
|
||||
async def _update(event):
|
||||
writes.append(event.nostr_publish_pending)
|
||||
return event
|
||||
|
||||
monkeypatch.setattr(nostr_hooks, "update_event", _update)
|
||||
return writes
|
||||
|
||||
|
||||
def _signer(monkeypatch, signer):
|
||||
monkeypatch.setattr(
|
||||
"lnbits.core.signers.resolve_for_wallet", AsyncMock(return_value=signer)
|
||||
)
|
||||
|
||||
|
||||
def _publisher(monkeypatch, result):
|
||||
monkeypatch.setattr(
|
||||
nostr_hooks, "publish_event_to_nostr", AsyncMock(return_value=result)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_raises_then_clears_the_flag(monkeypatch, saved):
|
||||
event = _event()
|
||||
_signer(monkeypatch, SimpleNamespace(pubkey="pk"))
|
||||
_publisher(monkeypatch, SimpleNamespace(id="nid", created_at=123))
|
||||
|
||||
assert await nostr_hooks.publish_or_delete_nostr_event(event) is True
|
||||
# Flagged before the attempt, cleared after it — in that order.
|
||||
assert saved == [True, False]
|
||||
assert event.nostr_publish_pending is False
|
||||
assert event.nostr_event_id == "nid"
|
||||
assert event.nostr_event_created_at == 123
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_signer_leaves_the_flag_up(monkeypatch, saved):
|
||||
event = _event()
|
||||
_signer(monkeypatch, None)
|
||||
|
||||
assert await nostr_hooks.publish_or_delete_nostr_event(event) is False
|
||||
assert saved == [True]
|
||||
assert event.nostr_publish_pending is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publisher_returning_none_leaves_the_flag_up(monkeypatch, saved):
|
||||
"""The no-NostrClient shape: nothing raised, nothing published."""
|
||||
event = _event()
|
||||
_signer(monkeypatch, SimpleNamespace(pubkey="pk"))
|
||||
_publisher(monkeypatch, None)
|
||||
|
||||
assert await nostr_hooks.publish_or_delete_nostr_event(event) is False
|
||||
assert saved == [True]
|
||||
assert event.nostr_publish_pending is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raised_publish_leaves_the_flag_up(monkeypatch, saved):
|
||||
event = _event()
|
||||
_signer(monkeypatch, SimpleNamespace(pubkey="pk"))
|
||||
monkeypatch.setattr(
|
||||
nostr_hooks,
|
||||
"publish_event_to_nostr",
|
||||
AsyncMock(side_effect=RuntimeError("signer timeout")),
|
||||
)
|
||||
|
||||
assert await nostr_hooks.publish_or_delete_nostr_event(event) is False
|
||||
assert saved == [True]
|
||||
assert event.nostr_publish_pending is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_already_pending_row_is_not_re_flagged(monkeypatch, saved):
|
||||
"""The sweep re-publishing a flagged row writes once, not twice."""
|
||||
event = _event(nostr_publish_pending=True)
|
||||
_signer(monkeypatch, SimpleNamespace(pubkey="pk"))
|
||||
_publisher(monkeypatch, SimpleNamespace(id="nid", created_at=123))
|
||||
|
||||
assert await nostr_hooks.publish_or_delete_nostr_event(event) is True
|
||||
assert saved == [False]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_clears_the_flag_without_touching_the_coordinate(
|
||||
monkeypatch, saved
|
||||
):
|
||||
"""A take-down must not overwrite the id/created_at of the event it
|
||||
just deleted — the kind-5 has its own."""
|
||||
event = _event(nostr_event_id="old", nostr_event_created_at=100)
|
||||
_signer(monkeypatch, SimpleNamespace(pubkey="pk"))
|
||||
_publisher(monkeypatch, SimpleNamespace(id="del", created_at=999))
|
||||
|
||||
assert await nostr_hooks.publish_or_delete_nostr_event(event, delete=True) is True
|
||||
assert event.nostr_publish_pending is False
|
||||
assert event.nostr_event_id == "old"
|
||||
assert event.nostr_event_created_at == 100
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sale_flags_the_event_in_the_same_write(monkeypatch):
|
||||
"""`set_ticket_paid` must flag inside its own update, so the counters
|
||||
and "the relay doesn't know yet" land atomically."""
|
||||
event = _event(sold=4, amount_tickets=6)
|
||||
seen: list[tuple[int, int, bool]] = []
|
||||
|
||||
async def _update_event(ev):
|
||||
seen.append((ev.sold, ev.amount_tickets, ev.nostr_publish_pending))
|
||||
return ev
|
||||
|
||||
monkeypatch.setattr(services, "update_ticket", AsyncMock())
|
||||
monkeypatch.setattr(services, "get_event", AsyncMock(return_value=event))
|
||||
monkeypatch.setattr(services, "update_event", _update_event)
|
||||
monkeypatch.setattr(services, "publish_or_delete_nostr_event", AsyncMock())
|
||||
|
||||
ticket = Ticket(
|
||||
id="t1",
|
||||
wallet="w",
|
||||
event="evt",
|
||||
name="A",
|
||||
email="a@example.com",
|
||||
registered=False,
|
||||
paid=False,
|
||||
time=datetime.now(timezone.utc),
|
||||
reg_timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
await services.set_ticket_paid(ticket)
|
||||
|
||||
assert seen == [(5, 5, True)]
|
||||
|
|
@ -1,218 +0,0 @@
|
|||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from ..models import (
|
||||
Event,
|
||||
EventExtra,
|
||||
PromoCode,
|
||||
PublicEvent,
|
||||
Ticket,
|
||||
TicketWave,
|
||||
ensure_ticket_waves,
|
||||
)
|
||||
from ..promo import basket_totals, normalize_code, promo_usage, remaining_uses
|
||||
|
||||
|
||||
def _event(currency="sat", price=1000.0, codes=None) -> Event:
|
||||
return Event(
|
||||
id="evt",
|
||||
wallet="w",
|
||||
name="Test",
|
||||
info="",
|
||||
closing_date="2030-01-01",
|
||||
event_start_date="2030-01-01",
|
||||
event_end_date="2030-01-02",
|
||||
currency=currency,
|
||||
price_per_ticket=price,
|
||||
amount_tickets=10,
|
||||
time=datetime.now(timezone.utc),
|
||||
extra=EventExtra(promo_codes=codes or []),
|
||||
)
|
||||
|
||||
|
||||
def _wave(event: Event) -> TicketWave:
|
||||
"""Primary wave synthesized from the event's own price/currency.
|
||||
|
||||
These tests exercise promo arithmetic rather than wave selection, so
|
||||
pricing against the primary wave keeps their original meaning.
|
||||
"""
|
||||
return ensure_ticket_waves(event)[0]
|
||||
|
||||
|
||||
def _ticket(code, paid=True) -> Ticket:
|
||||
now = datetime.now(timezone.utc)
|
||||
return Ticket(
|
||||
id=f"t-{code}-{paid}",
|
||||
wallet="w",
|
||||
event="evt",
|
||||
registered=False,
|
||||
paid=paid,
|
||||
time=now,
|
||||
reg_timestamp=now,
|
||||
extra={"applied_promo_code": code},
|
||||
)
|
||||
|
||||
|
||||
# --- model -----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_code_is_stripped_and_uppercased():
|
||||
assert PromoCode(code=" half ", discount_percent=50).code == "HALF"
|
||||
|
||||
|
||||
def test_empty_code_is_rejected():
|
||||
with pytest.raises(ValidationError):
|
||||
PromoCode(code=" ", discount_percent=10)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw,expected", [(None, None), ("", None), (0, None), ("0", None), (3, 3), ("7", 7)]
|
||||
)
|
||||
def test_max_uses_normalisation(raw, expected):
|
||||
assert PromoCode(code="X", max_uses=raw).max_uses == expected
|
||||
|
||||
|
||||
def test_max_uses_below_one_rejected():
|
||||
with pytest.raises(ValidationError):
|
||||
PromoCode(code="X", max_uses=-1)
|
||||
|
||||
|
||||
def test_discount_bounds():
|
||||
with pytest.raises(ValidationError):
|
||||
PromoCode(code="X", discount_percent=101)
|
||||
|
||||
|
||||
def test_public_event_projection_drops_promo_codes():
|
||||
event = _event(codes=[PromoCode(code="SECRET", discount_percent=100)])
|
||||
public = PublicEvent.parse_obj(event.dict()).dict()
|
||||
assert "promo_codes" not in public["extra"]
|
||||
assert public["extra"]["payment_methods"] == []
|
||||
# the full model keeps them
|
||||
assert Event.parse_obj(event.dict()).extra.promo_codes[0].code == "SECRET"
|
||||
|
||||
|
||||
# --- helpers ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_normalize_code():
|
||||
assert normalize_code(" save20 ") == "SAVE20"
|
||||
assert normalize_code("") is None
|
||||
assert normalize_code(None) is None
|
||||
|
||||
|
||||
def test_promo_usage_counts_paid_rows_only_per_ticket():
|
||||
usage = promo_usage(
|
||||
[_ticket("HALF"), _ticket("HALF"), _ticket("HALF", paid=False), _ticket(None)]
|
||||
)
|
||||
assert usage == {"HALF": 2}
|
||||
|
||||
|
||||
def test_remaining_uses():
|
||||
assert remaining_uses(PromoCode(code="X"), 5) is None
|
||||
assert remaining_uses(PromoCode(code="X", max_uses=3), 1) == 2
|
||||
assert remaining_uses(PromoCode(code="X", max_uses=3), 9) == 0
|
||||
|
||||
|
||||
# --- basket_totals -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_sat_totals_round_to_whole_sats():
|
||||
event = _event(price=333, codes=[PromoCode(code="OFF15", discount_percent=15)])
|
||||
totals = basket_totals(event, ["off15"], 1, {}, _wave(event))
|
||||
assert (totals.subtotal, totals.total, totals.discount) == (333, 283, 50)
|
||||
assert totals.currency == "sat"
|
||||
assert totals.discounts_applied[0].dict() == {
|
||||
"code": "OFF15",
|
||||
"discount_percent": 15,
|
||||
"discount_fixed": None,
|
||||
"amount_saved": 50,
|
||||
}
|
||||
|
||||
|
||||
def test_fiat_totals_round_to_cents_and_scale_by_quantity():
|
||||
event = _event(
|
||||
currency="EUR",
|
||||
price=19.99,
|
||||
codes=[PromoCode(code="THIRD", discount_percent=33)],
|
||||
)
|
||||
totals = basket_totals(event, ["THIRD"], 3, {}, _wave(event))
|
||||
assert totals.subtotal == 59.97
|
||||
assert totals.total == 40.18
|
||||
assert totals.discount == 19.79
|
||||
assert totals.discount + totals.total == totals.subtotal
|
||||
|
||||
|
||||
def test_first_applicable_code_wins():
|
||||
event = _event(
|
||||
codes=[
|
||||
PromoCode(code="A", discount_percent=10),
|
||||
PromoCode(code="B", discount_percent=50),
|
||||
]
|
||||
)
|
||||
assert (
|
||||
basket_totals(event, ["NOPE", "B", "A"], 1, {}, _wave(event))
|
||||
.discounts_applied[0]
|
||||
.code
|
||||
== "B"
|
||||
)
|
||||
|
||||
|
||||
def test_inactive_unknown_zero_and_exhausted_codes_are_absent():
|
||||
event = _event(
|
||||
codes=[
|
||||
PromoCode(code="OLD", discount_percent=20, active=False),
|
||||
PromoCode(code="ZERO", discount_percent=0),
|
||||
PromoCode(code="TWO", discount_percent=50, max_uses=2),
|
||||
]
|
||||
)
|
||||
for codes, usage, qty in (
|
||||
(["OLD"], {}, 1),
|
||||
(["ZERO"], {}, 1),
|
||||
(["NOPE"], {}, 1),
|
||||
(["TWO"], {"TWO": 2}, 1),
|
||||
(["TWO"], {"TWO": 1}, 2), # not enough left for the whole quantity
|
||||
):
|
||||
totals = basket_totals(event, codes, qty, usage, _wave(event))
|
||||
assert totals.discounts_applied == []
|
||||
assert totals.total == totals.subtotal and totals.discount == 0
|
||||
|
||||
|
||||
def test_unlimited_and_partially_used_codes_apply():
|
||||
event = _event(
|
||||
codes=[
|
||||
PromoCode(code="TWO", discount_percent=50, max_uses=2),
|
||||
PromoCode(code="INF", discount_percent=10),
|
||||
]
|
||||
)
|
||||
assert basket_totals(event, ["TWO"], 1, {"TWO": 1}, _wave(event)).total == 500
|
||||
assert basket_totals(event, ["INF"], 10, {"INF": 999}, _wave(event)).total == 9000
|
||||
|
||||
|
||||
def test_full_discount_prices_to_zero():
|
||||
event = _event(codes=[PromoCode(code="FREE", discount_percent=100)])
|
||||
assert basket_totals(event, ["FREE"], 2, {}, _wave(event)).total == 0
|
||||
|
||||
|
||||
def test_price_comes_from_the_selected_wave_not_the_event():
|
||||
"""Regression guard for the v1.6.8 merge.
|
||||
|
||||
`sync_event_ticket_waves` makes `event.price_per_ticket` a roll-up of the
|
||||
PRIMARY wave, so pricing off the event charged every buyer the first
|
||||
wave's price no matter which wave they picked.
|
||||
"""
|
||||
event = _event(price=1000.0, codes=[PromoCode(code="HALF", discount_percent=50)])
|
||||
late = TicketWave(
|
||||
id="late",
|
||||
title="Late",
|
||||
opening_date="2030-01-01",
|
||||
closing_date="2030-02-01",
|
||||
currency="sat",
|
||||
price_per_ticket=2500.0,
|
||||
amount_tickets=10,
|
||||
)
|
||||
|
||||
assert basket_totals(event, [], 2, {}, _wave(event)).total == 2000
|
||||
assert basket_totals(event, [], 2, {}, late).total == 5000
|
||||
assert basket_totals(event, ["HALF"], 2, {}, late).total == 2500
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .. import views_api
|
||||
from ..models import CreateTicket, Event, EventExtra, PromoCode, PromoValidateRequest
|
||||
|
||||
|
||||
def _event(codes) -> Event:
|
||||
return Event(
|
||||
id="evt",
|
||||
wallet="w",
|
||||
name="Test",
|
||||
info="",
|
||||
closing_date="2030-01-01",
|
||||
event_start_date="2030-01-01",
|
||||
event_end_date="2030-01-02",
|
||||
currency="sat",
|
||||
price_per_ticket=1000,
|
||||
amount_tickets=10,
|
||||
time=datetime.now(timezone.utc),
|
||||
extra=EventExtra(promo_codes=codes),
|
||||
status="approved",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def event(monkeypatch):
|
||||
ev = _event(
|
||||
[
|
||||
PromoCode(code="HALF", discount_percent=50, max_uses=2),
|
||||
PromoCode(code="OLD", discount_percent=20, active=False),
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=ev))
|
||||
monkeypatch.setattr(
|
||||
views_api, "event_promo_usage", AsyncMock(return_value={"HALF": 1})
|
||||
)
|
||||
return ev
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_returns_v2_shaped_totals(event):
|
||||
totals = await views_api.api_validate_promo_codes(
|
||||
"evt", PromoValidateRequest(codes=["half"], quantity=1)
|
||||
)
|
||||
assert totals.dict() == {
|
||||
"subtotal": 1000,
|
||||
"discount": 500,
|
||||
"total": 500,
|
||||
"currency": "sat",
|
||||
"discounts_applied": [
|
||||
{
|
||||
"code": "HALF",
|
||||
"discount_percent": 50,
|
||||
"discount_fixed": None,
|
||||
"amount_saved": 500,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_is_advisory_for_bad_codes(event):
|
||||
for codes, qty in ((["OLD"], 1), (["NOPE"], 1), (["HALF"], 2)):
|
||||
totals = await views_api.api_validate_promo_codes(
|
||||
"evt", PromoValidateRequest(codes=codes, quantity=qty)
|
||||
)
|
||||
assert totals.discounts_applied == [] and totals.total == totals.subtotal
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_unknown_event_is_404(monkeypatch):
|
||||
monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=None))
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await views_api.api_validate_promo_codes(
|
||||
"nope", PromoValidateRequest(codes=["X"])
|
||||
)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"code,quantity,detail",
|
||||
[
|
||||
("NOPE", 1, "Invalid promo code."),
|
||||
("old", 1, "Promo code is not active."),
|
||||
("HALF", 2, "Only 1 use(s) left on this promo code."),
|
||||
],
|
||||
)
|
||||
async def test_purchase_rejects_bad_codes_before_any_invoice(
|
||||
event, monkeypatch, code, quantity, detail
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
views_api,
|
||||
"create_payment_request",
|
||||
AsyncMock(side_effect=AssertionError("must not be called")),
|
||||
)
|
||||
data = CreateTicket(user_id="u1", promo_code=code, quantity=quantity)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await views_api.api_ticket_create(
|
||||
"evt", data, SimpleNamespace(base_url="http://lnbits.local/")
|
||||
)
|
||||
assert exc.value.status_code == 400
|
||||
assert exc.value.detail == detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_reports_fully_redeemed(event, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
views_api, "event_promo_usage", AsyncMock(return_value={"HALF": 2})
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
views_api,
|
||||
"create_payment_request",
|
||||
AsyncMock(side_effect=AssertionError("must not be called")),
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await views_api.api_ticket_create(
|
||||
"evt",
|
||||
CreateTicket(user_id="u1", promo_code="HALF"),
|
||||
SimpleNamespace(base_url="http://lnbits.local/"),
|
||||
)
|
||||
assert exc.value.detail == "Promo code has been fully redeemed."
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def update_env(monkeypatch):
|
||||
stored = _event([PromoCode(code="KEEP", discount_percent=10)])
|
||||
monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=stored))
|
||||
monkeypatch.setattr(
|
||||
views_api,
|
||||
"get_settings",
|
||||
AsyncMock(return_value=SimpleNamespace(auto_approve=True)),
|
||||
)
|
||||
monkeypatch.setattr(views_api, "update_event", AsyncMock(side_effect=lambda e: e))
|
||||
monkeypatch.setattr(views_api, "publish_or_delete_nostr_event", AsyncMock())
|
||||
return stored
|
||||
|
||||
|
||||
def _update_payload(extra: dict) -> dict:
|
||||
return {
|
||||
"wallet": "w",
|
||||
"name": "Test",
|
||||
"info": "",
|
||||
"closing_date": "2030-01-01",
|
||||
"event_start_date": "2030-01-01",
|
||||
"event_end_date": "2030-01-02",
|
||||
"amount_tickets": 10,
|
||||
"price_per_ticket": 1000,
|
||||
"extra": extra,
|
||||
}
|
||||
|
||||
|
||||
def _wallet():
|
||||
return SimpleNamespace(wallet=SimpleNamespace(id="w", user="u1"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_without_promo_key_keeps_stored_codes(update_env):
|
||||
from ..models import CreateEvent
|
||||
|
||||
data = CreateEvent.parse_obj(_update_payload({"email_notifications": True}))
|
||||
event = await views_api.api_event_update("evt", data, _wallet())
|
||||
assert [pc.code for pc in event.extra.promo_codes] == ["KEEP"]
|
||||
assert event.extra.email_notifications is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_with_empty_promo_list_clears(update_env):
|
||||
from ..models import CreateEvent
|
||||
|
||||
data = CreateEvent.parse_obj(_update_payload({"promo_codes": []}))
|
||||
event = await views_api.api_event_update("evt", data, _wallet())
|
||||
assert event.extra.promo_codes == []
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
"""The NIP-52 tags describe the ACTIVE ticket wave, not the roll-up (#61).
|
||||
|
||||
Upstream v1.6.8 moved price, currency and inventory onto time-boxed waves
|
||||
and made the event-level fields derived: `price_per_ticket` and `currency`
|
||||
become the PRIMARY (first) wave's, `amount_tickets` the SUM across every
|
||||
wave. Publishing those would advertise the early-bird price after early
|
||||
bird closed and count stock in waves that have not opened yet.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from ..models import (
|
||||
Event,
|
||||
EventExtra,
|
||||
TicketWave,
|
||||
advertised_wave_key,
|
||||
sync_event_ticket_waves,
|
||||
)
|
||||
from ..nostr_publisher import build_nip52_event
|
||||
|
||||
PUBKEY = "a" * 64
|
||||
TODAY = datetime.now(timezone.utc).date()
|
||||
|
||||
|
||||
def _day(offset: int) -> str:
|
||||
return (TODAY + timedelta(days=offset)).isoformat()
|
||||
|
||||
|
||||
def _wave(
|
||||
wave_id: str,
|
||||
price: float,
|
||||
stock: int,
|
||||
opens: int,
|
||||
closes: int,
|
||||
*,
|
||||
currency: str = "EUR",
|
||||
allow_fiat: bool = False,
|
||||
) -> TicketWave:
|
||||
return TicketWave(
|
||||
id=wave_id,
|
||||
title=wave_id,
|
||||
opening_date=_day(opens),
|
||||
closing_date=_day(closes),
|
||||
currency=currency,
|
||||
price_per_ticket=price,
|
||||
amount_tickets=stock,
|
||||
allow_fiat=allow_fiat,
|
||||
fiat_currency="GBP",
|
||||
)
|
||||
|
||||
|
||||
def _event(waves: list[TicketWave], sold: int = 0) -> Event:
|
||||
event = Event(
|
||||
id="evt",
|
||||
wallet="w",
|
||||
name="Waves",
|
||||
info="",
|
||||
closing_date=_day(30),
|
||||
event_start_date=_day(30),
|
||||
event_end_date=_day(30),
|
||||
currency="sat",
|
||||
price_per_ticket=0,
|
||||
amount_tickets=0,
|
||||
sold=sold,
|
||||
time=datetime.now(timezone.utc),
|
||||
status="approved",
|
||||
extra=EventExtra(ticket_waves=waves),
|
||||
)
|
||||
# Mirrors the CRUD layer, which syncs on every read and write.
|
||||
return cast(Event, sync_event_ticket_waves(event))
|
||||
|
||||
|
||||
def _tags(event: Event) -> dict[str, str]:
|
||||
return {t[0]: t[1] for t in build_nip52_event(event, PUBKEY).tags if len(t) > 1}
|
||||
|
||||
|
||||
CLOSED_EARLY_BIRD = _wave("early", 10.0, 5, -10, -1)
|
||||
OPEN_REGULAR = _wave("regular", 25.0, 40, 0, 20)
|
||||
UNOPENED_VIP = _wave("vip", 50.0, 10, 5, 25)
|
||||
|
||||
|
||||
def test_closed_wave_price_is_not_advertised():
|
||||
"""The defect this fixes: early bird closed yesterday."""
|
||||
event = _event([CLOSED_EARLY_BIRD, OPEN_REGULAR, UNOPENED_VIP])
|
||||
|
||||
# What the event-level roll-up would have published.
|
||||
assert event.price_per_ticket == 10.0
|
||||
assert event.amount_tickets == 55
|
||||
|
||||
tags = _tags(event)
|
||||
assert tags["tickets_price"] == "25.0"
|
||||
assert tags["tickets_available"] == "40"
|
||||
|
||||
|
||||
def test_unopened_wave_stock_is_not_counted():
|
||||
event = _event([OPEN_REGULAR, UNOPENED_VIP])
|
||||
assert _tags(event)["tickets_available"] == "40"
|
||||
|
||||
|
||||
def test_cheapest_open_wave_wins_when_several_are_open():
|
||||
"""A publisher cannot ask which wave the buyer wants, so it advertises
|
||||
the price a buyer is actually able to obtain."""
|
||||
cheaper = _wave("cheap", 15.0, 3, 0, 10)
|
||||
tags = _tags(_event([OPEN_REGULAR, cheaper]))
|
||||
assert tags["tickets_price"] == "15.0"
|
||||
assert tags["tickets_available"] == "3"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"waves",
|
||||
[
|
||||
pytest.param([CLOSED_EARLY_BIRD], id="all-closed"),
|
||||
pytest.param([UNOPENED_VIP], id="not-yet-open"),
|
||||
pytest.param([_wave("only", 25.0, 0, 0, 20)], id="sold-out"),
|
||||
],
|
||||
)
|
||||
def test_no_open_wave_publishes_zero_availability(waves):
|
||||
"""Never omit the tag: omission meant "unlimited" (#34, #62)."""
|
||||
tags = _tags(_event(waves))
|
||||
assert tags["tickets_available"] == "0"
|
||||
|
||||
|
||||
def test_currency_and_fiat_follow_the_advertised_wave():
|
||||
fiat_primary = _wave("a", 10.0, 0, -10, -1, currency="GBP", allow_fiat=True)
|
||||
sats_open = _wave("b", 2500.0, 9, 0, 20, currency="sat", allow_fiat=False)
|
||||
tags = _tags(_event([fiat_primary, sats_open]))
|
||||
|
||||
assert tags["tickets_currency"] == "sat"
|
||||
assert "tickets_allow_fiat" not in tags
|
||||
# Must agree with tickets_allow_fiat — they are the same fact, and a
|
||||
# client rendering a fiat button here would hit a purchase-time refusal.
|
||||
assert tags["tickets_payment_methods"] == "lightning"
|
||||
|
||||
|
||||
def test_payment_methods_offer_fiat_when_the_advertised_wave_accepts_it():
|
||||
tags = _tags(
|
||||
_event(
|
||||
[
|
||||
_wave("a", 10.0, 0, -10, -1, allow_fiat=False),
|
||||
_wave("b", 25.0, 9, 0, 20, allow_fiat=True),
|
||||
]
|
||||
)
|
||||
)
|
||||
assert tags["tickets_allow_fiat"] == "true"
|
||||
assert tags["tickets_payment_methods"] == "lightning,fiat"
|
||||
|
||||
|
||||
def test_sold_stays_event_level():
|
||||
"""`tickets_sold` is the total paid across all waves."""
|
||||
assert _tags(_event([OPEN_REGULAR], sold=7))["tickets_sold"] == "7"
|
||||
|
||||
|
||||
def test_advertised_wave_key_tracks_the_published_wave():
|
||||
assert advertised_wave_key(_event([CLOSED_EARLY_BIRD, OPEN_REGULAR])) == "regular"
|
||||
# Published while nothing is on sale — a real state, distinct from the
|
||||
# NULL that means "never published".
|
||||
assert advertised_wave_key(_event([CLOSED_EARLY_BIRD])) == ""
|
||||
|
||||
|
||||
def test_published_rails_drop_fiat_when_the_advertised_wave_cannot_take_it():
|
||||
"""The webapp always sets `extra.payment_methods`, so the explicit-list
|
||||
path is the normal one — and it used to ignore the wave entirely.
|
||||
|
||||
That published `tickets_payment_methods: lightning,fiat` beside an
|
||||
absent `tickets_allow_fiat`, and a card button the purchase endpoint
|
||||
then refused ("Fiat payments are not enabled for this ticket wave").
|
||||
"""
|
||||
event = _event(
|
||||
[
|
||||
_wave("a", 10.0, 0, -10, -1, allow_fiat=True),
|
||||
_wave("b", 25.0, 9, 0, 20, allow_fiat=False),
|
||||
]
|
||||
)
|
||||
event.extra.payment_methods = ["lightning", "fiat"]
|
||||
tags = _tags(event)
|
||||
|
||||
assert "tickets_allow_fiat" not in tags
|
||||
assert tags["tickets_payment_methods"] == "lightning"
|
||||
|
||||
|
||||
def test_published_rails_keep_fiat_when_the_advertised_wave_takes_it():
|
||||
event = _event(
|
||||
[
|
||||
_wave("a", 10.0, 0, -10, -1, allow_fiat=False),
|
||||
_wave("b", 25.0, 9, 0, 20, allow_fiat=True),
|
||||
]
|
||||
)
|
||||
event.extra.payment_methods = ["lightning", "fiat"]
|
||||
tags = _tags(event)
|
||||
|
||||
assert tags["tickets_allow_fiat"] == "true"
|
||||
assert tags["tickets_payment_methods"] == "lightning,fiat"
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
"""`tickets_available` is always published, including zero (#34).
|
||||
|
||||
Omitting the tag used to mean "unlimited", which contradicted every other
|
||||
reader: `api_get_event` and `api_ticket_create` both treat
|
||||
`amount_tickets < 1` as sold out. On aio-demo three zero-capacity events
|
||||
advertised "Unlimited tickets" on the card while the detail endpoint and
|
||||
the purchase both returned 410.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from ..models import Event
|
||||
from ..nostr_publisher import build_nip52_event
|
||||
|
||||
PUBKEY = "a" * 64
|
||||
|
||||
|
||||
def _event(amount_tickets: int, sold: int = 0) -> Event:
|
||||
return Event(
|
||||
id="evt",
|
||||
wallet="w",
|
||||
name="Availability",
|
||||
info="",
|
||||
closing_date="2030-01-01",
|
||||
event_start_date="2030-01-01T18:00",
|
||||
event_end_date="2030-01-01T22:00",
|
||||
currency="sat",
|
||||
price_per_ticket=0,
|
||||
amount_tickets=amount_tickets,
|
||||
sold=sold,
|
||||
time=datetime.now(timezone.utc),
|
||||
status="approved",
|
||||
)
|
||||
|
||||
|
||||
def _tags(event: Event) -> dict[str, str]:
|
||||
return {t[0]: t[1] for t in build_nip52_event(event, PUBKEY).tags if len(t) > 1}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("amount", [0, 1, 50])
|
||||
def test_tickets_available_is_always_present(amount):
|
||||
assert _tags(_event(amount))["tickets_available"] == str(amount)
|
||||
|
||||
|
||||
def test_zero_capacity_reads_as_sold_out_not_unlimited():
|
||||
"""The regression: an absent tag is what clients render as unlimited."""
|
||||
tags = _tags(_event(0, sold=0))
|
||||
assert "tickets_available" in tags
|
||||
assert tags["tickets_available"] == "0"
|
||||
|
||||
|
||||
def test_sold_out_after_selling_through_still_publishes_zero():
|
||||
assert _tags(_event(0, sold=25))["tickets_available"] == "0"
|
||||
assert _tags(_event(0, sold=25))["tickets_sold"] == "25"
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
"""Publish confirmation against the relay's `OK` (aiolabs/events#56).
|
||||
|
||||
Queueing is not delivery. nostrclient drops an EVENT outright when no
|
||||
relay is connected and answers `OK false`; before this, that reply was
|
||||
discarded and the publish reported success, which once cleared the
|
||||
`nostr_publish_pending` flag on a republish that never left the
|
||||
building.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from ..nostr import nostr_client as nc
|
||||
from ..nostr.event import NostrEvent
|
||||
|
||||
|
||||
def _event(event_id: str = "a" * 64) -> NostrEvent:
|
||||
e = NostrEvent(pubkey="b" * 64, created_at=0, kind=31923)
|
||||
e.id = event_id
|
||||
return e
|
||||
|
||||
|
||||
async def _publish_and_reply(client, event, reply, delay=0.01):
|
||||
"""Start a publish, then feed `reply` through the receive path."""
|
||||
task = asyncio.create_task(client.publish_nostr_event(event))
|
||||
await asyncio.sleep(delay) # let the future register
|
||||
if reply is not None:
|
||||
client.receive_event_queue.put_nowait(reply)
|
||||
consumer = asyncio.create_task(client.get_event())
|
||||
await asyncio.sleep(delay)
|
||||
consumer.cancel()
|
||||
return await task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accepted_publish_returns_true():
|
||||
client = nc.NostrClient()
|
||||
event = _event()
|
||||
ok = json.dumps(["OK", event.id, True, ""])
|
||||
assert await _publish_and_reply(client, event, ok) is True
|
||||
assert client._pending_oks == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejected_publish_returns_false():
|
||||
"""The shape that bit us: no relay connected, so nostrclient's
|
||||
router answers `OK false` without the event ever being sent."""
|
||||
client = nc.NostrClient()
|
||||
event = _event()
|
||||
ok = json.dumps(["OK", event.id, False, "error: no relays connected"])
|
||||
assert await _publish_and_reply(client, event, ok) is False
|
||||
assert client._pending_oks == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_ok_times_out_as_unconfirmed(monkeypatch):
|
||||
monkeypatch.setattr(nc, "PUBLISH_OK_TIMEOUT_SECONDS", 0.05)
|
||||
client = nc.NostrClient()
|
||||
assert await _publish_and_reply(client, _event(), None) is False
|
||||
assert client._pending_oks == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ok_for_a_different_event_does_not_settle_ours(monkeypatch):
|
||||
monkeypatch.setattr(nc, "PUBLISH_OK_TIMEOUT_SECONDS", 0.05)
|
||||
client = nc.NostrClient()
|
||||
other = json.dumps(["OK", "c" * 64, True, ""])
|
||||
assert await _publish_and_reply(client, _event(), other) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_event_swallows_ok_and_forwards_everything_else():
|
||||
client = nc.NostrClient()
|
||||
client.receive_event_queue.put_nowait(json.dumps(["OK", "d" * 64, True, ""]))
|
||||
forwarded = json.dumps(["EVENT", "sub", {"id": "e" * 64}])
|
||||
client.receive_event_queue.put_nowait(forwarded)
|
||||
assert await client.get_event() == forwarded
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_settles_inflight_publishes_immediately(monkeypatch):
|
||||
"""A dropped socket must not leave the caller waiting the full
|
||||
timeout for an OK that can no longer arrive."""
|
||||
monkeypatch.setattr(nc, "PUBLISH_OK_TIMEOUT_SECONDS", 30)
|
||||
client = nc.NostrClient()
|
||||
event = _event()
|
||||
task = asyncio.create_task(client.publish_nostr_event(event))
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
client.receive_event_queue.put_nowait(ValueError("WebSocket closed"))
|
||||
consumer = asyncio.create_task(client.get_event())
|
||||
await asyncio.sleep(0.01)
|
||||
consumer.cancel()
|
||||
|
||||
assert await asyncio.wait_for(task, 1) is False
|
||||
|
||||
|
||||
def test_settle_ok_ignores_non_ok_frames():
|
||||
client = nc.NostrClient()
|
||||
assert client._settle_ok(json.dumps(["EVENT", "sub", {}])) is False
|
||||
assert client._settle_ok(json.dumps(["EOSE", "sub"])) is False
|
||||
assert client._settle_ok("not json") is False
|
||||
assert client._settle_ok(json.dumps(["OK", "f" * 64, True, ""])) is True
|
||||
|
|
@ -5,10 +5,6 @@ from ..models import (
|
|||
CreateEvent,
|
||||
CreateTicket,
|
||||
EventExtra,
|
||||
PromoCode,
|
||||
PublicEvent,
|
||||
PublicEventExtra,
|
||||
TicketWave,
|
||||
effective_payment_methods,
|
||||
)
|
||||
|
||||
|
|
@ -112,108 +108,3 @@ def test_payment_methods_are_normalised_and_deduplicated():
|
|||
def test_unknown_payment_method_is_rejected():
|
||||
with pytest.raises(ValidationError):
|
||||
EventExtra(payment_methods=["cash"])
|
||||
|
||||
|
||||
# --- public projection ------------------------------------------------------
|
||||
|
||||
|
||||
def test_public_extra_exposes_waves_but_never_promo_codes():
|
||||
"""A buyer needs a wave id to choose a tier, so waves are public; promo
|
||||
codes stay organizer-only (aiolabs/events#61, v1.6.1-aio.12)."""
|
||||
organizer = EventExtra(
|
||||
promo_codes=[PromoCode(code="SECRET", discount_percent=50)],
|
||||
ticket_waves=[
|
||||
TicketWave(
|
||||
id="early",
|
||||
title="Early",
|
||||
opening_date="2030-01-01",
|
||||
closing_date="2030-02-01",
|
||||
currency="sat",
|
||||
price_per_ticket=10,
|
||||
amount_tickets=5,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
public = PublicEventExtra(**organizer.dict())
|
||||
|
||||
assert [wave.id for wave in public.ticket_waves] == ["early"]
|
||||
assert public.ticket_waves[0].price_per_ticket == 10
|
||||
assert not hasattr(public, "promo_codes")
|
||||
assert "promo_codes" not in public.dict()
|
||||
|
||||
|
||||
def test_public_event_response_carries_waves():
|
||||
"""End of the chain: what `GET /events/{id}` actually serialises."""
|
||||
wave = TicketWave(
|
||||
id="regular",
|
||||
title="Regular",
|
||||
opening_date="2030-01-01",
|
||||
closing_date="2030-02-01",
|
||||
currency="sat",
|
||||
price_per_ticket=25,
|
||||
amount_tickets=40,
|
||||
)
|
||||
organizer_extra = EventExtra(
|
||||
ticket_waves=[wave], promo_codes=[PromoCode(code="SECRET")]
|
||||
)
|
||||
public = PublicEvent(
|
||||
id="evt",
|
||||
name="Test",
|
||||
info="",
|
||||
canceled=False,
|
||||
event_start_date="2030-01-01",
|
||||
currency="sat",
|
||||
price_per_ticket=25,
|
||||
banner=None,
|
||||
extra=PublicEventExtra(**organizer_extra.dict()),
|
||||
)
|
||||
body = public.dict()
|
||||
|
||||
assert [w["id"] for w in body["extra"]["ticket_waves"]] == ["regular"]
|
||||
assert "promo_codes" not in body["extra"]
|
||||
|
||||
|
||||
# --- rails vs per-wave fiat --------------------------------------------------
|
||||
|
||||
|
||||
def _fiat_wave(allow_fiat: bool):
|
||||
from ..models import TicketWave
|
||||
|
||||
return TicketWave(
|
||||
id="w",
|
||||
title="w",
|
||||
opening_date="2030-01-01",
|
||||
closing_date="2030-02-01",
|
||||
currency="EUR",
|
||||
price_per_ticket=10,
|
||||
amount_tickets=5,
|
||||
allow_fiat=allow_fiat,
|
||||
)
|
||||
|
||||
|
||||
def test_explicit_rails_drop_fiat_for_a_wave_that_cannot_take_it():
|
||||
"""The organiser's rail list is event-level; fiat is per-wave.
|
||||
|
||||
Without this the NIP-52 tag advertises fiat and the checkout renders a
|
||||
card button that `api_ticket_create` refuses with "Fiat payments are
|
||||
not enabled for this ticket wave" (reported on aio-demo).
|
||||
"""
|
||||
event = _event()
|
||||
event.extra.payment_methods = ["lightning", "fiat"]
|
||||
|
||||
assert effective_payment_methods(event, _fiat_wave(True)) == ["lightning", "fiat"]
|
||||
assert effective_payment_methods(event, _fiat_wave(False)) == ["lightning"]
|
||||
|
||||
|
||||
def test_event_level_question_still_reports_every_rail():
|
||||
"""No wave means "what did the organiser enable at all" — unfiltered."""
|
||||
event = _event()
|
||||
event.extra.payment_methods = ["lightning", "fiat"]
|
||||
assert effective_payment_methods(event) == ["lightning", "fiat"]
|
||||
|
||||
|
||||
def test_fiat_only_rails_on_a_non_fiat_wave_leave_nothing_purchasable():
|
||||
event = _event()
|
||||
event.extra.payment_methods = ["fiat"]
|
||||
assert effective_payment_methods(event, _fiat_wave(False)) == []
|
||||
|
|
|
|||
|
|
@ -1,118 +0,0 @@
|
|||
"""Capacity is required per ticket wave (#34, #62).
|
||||
|
||||
"Capacity is always required, there is no unlimited" was settled when
|
||||
capacity was one number on the event. Since v1.6.8 it is per-wave and
|
||||
`event.amount_tickets` is a derived roll-up, so the rule has to bite where
|
||||
the organiser sets it. A zero-capacity wave can never be active
|
||||
(`get_active_ticket_waves` requires `> 0`), so it is the wave-level form of
|
||||
the trap #62 removed: an event that looks on sale but refuses every
|
||||
purchase.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from ..models import CreateEvent, Event, EventExtra, TicketWave
|
||||
from ..views_api import _validate_wave_capacity
|
||||
|
||||
TODAY = datetime.now(timezone.utc).date()
|
||||
|
||||
|
||||
def _day(offset: int) -> str:
|
||||
return (TODAY + timedelta(days=offset)).isoformat()
|
||||
|
||||
|
||||
def _wave(wave_id: str, stock: int) -> TicketWave:
|
||||
return TicketWave(
|
||||
id=wave_id,
|
||||
title=wave_id,
|
||||
opening_date=_day(0),
|
||||
closing_date=_day(20),
|
||||
currency="sat",
|
||||
price_per_ticket=10,
|
||||
amount_tickets=stock,
|
||||
)
|
||||
|
||||
|
||||
def _create(waves=None, amount_tickets=10) -> CreateEvent:
|
||||
return CreateEvent(
|
||||
wallet="w",
|
||||
name="Fete",
|
||||
info="",
|
||||
event_start_date=_day(30),
|
||||
currency="sat",
|
||||
price_per_ticket=10,
|
||||
amount_tickets=amount_tickets,
|
||||
extra=EventExtra(ticket_waves=waves or []),
|
||||
)
|
||||
|
||||
|
||||
def _stored(waves) -> Event:
|
||||
return Event(
|
||||
id="evt",
|
||||
wallet="w",
|
||||
name="Fete",
|
||||
info="",
|
||||
closing_date=_day(30),
|
||||
event_start_date=_day(30),
|
||||
currency="sat",
|
||||
price_per_ticket=10,
|
||||
amount_tickets=sum(w.amount_tickets for w in waves),
|
||||
time=datetime.now(timezone.utc),
|
||||
extra=EventExtra(ticket_waves=waves),
|
||||
)
|
||||
|
||||
|
||||
def _detail(exc_info) -> str:
|
||||
assert exc_info.value.status_code == HTTPStatus.BAD_REQUEST
|
||||
return exc_info.value.detail
|
||||
|
||||
|
||||
def test_wave_with_capacity_is_accepted():
|
||||
_validate_wave_capacity(_create([_wave("early", 5), _wave("late", 10)]))
|
||||
|
||||
|
||||
def test_zero_capacity_wave_is_rejected_on_create():
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_validate_wave_capacity(_create([_wave("early", 5), _wave("late", 0)]))
|
||||
assert "late" in _detail(exc_info)
|
||||
|
||||
|
||||
def test_event_submitted_without_waves_is_checked_through_its_primary_wave():
|
||||
"""No waves means the event gets a synthesized primary wave seeded from
|
||||
`amount_tickets`, so the rule must see that rather than an empty list."""
|
||||
_validate_wave_capacity(_create(waves=None, amount_tickets=10))
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_validate_wave_capacity(_create(waves=None, amount_tickets=0))
|
||||
assert "Primary wave" in _detail(exc_info)
|
||||
|
||||
|
||||
def test_selling_a_wave_out_does_not_block_later_edits():
|
||||
"""Zero is where a wave legitimately ends up. Rejecting it on edit would
|
||||
make a sold-out event uneditable."""
|
||||
sold_out = _wave("early", 0)
|
||||
existing = _stored([sold_out, _wave("late", 10)])
|
||||
|
||||
_validate_wave_capacity(_create([sold_out, _wave("late", 10)]), existing)
|
||||
|
||||
|
||||
def test_new_wave_added_by_an_edit_still_needs_capacity():
|
||||
existing = _stored([_wave("early", 5)])
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_validate_wave_capacity(
|
||||
_create([_wave("early", 5), _wave("brand-new", 0)]), existing
|
||||
)
|
||||
assert "brand-new" in _detail(exc_info)
|
||||
|
||||
|
||||
def test_legacy_zero_capacity_event_stays_editable():
|
||||
"""An event stored before the rule existed keeps its primary wave id, so
|
||||
an edit is not blocked — otherwise the only way to fix it would be
|
||||
barred."""
|
||||
existing = _stored([_wave("primary", 0)])
|
||||
_validate_wave_capacity(_create([_wave("primary", 0)]), existing)
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
"""Editing an event must not destroy its ticket waves.
|
||||
|
||||
`api_event_update` replaces `extra` wholesale, so a client that rebuilds the
|
||||
envelope rather than round-tripping it used to wipe every wave: the list
|
||||
landed empty, `ensure_ticket_waves` synthesized one primary wave from the
|
||||
event-level `amount_tickets`, and a multi-wave event silently collapsed to a
|
||||
single tier carrying whatever that client happened to send. Same hazard the
|
||||
`promo_codes` guard already covered.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from ..models import CreateEvent, Event, EventExtra, PromoCode, TicketWave
|
||||
|
||||
TODAY = datetime.now(timezone.utc).date()
|
||||
|
||||
|
||||
def _day(offset: int) -> str:
|
||||
return (TODAY + timedelta(days=offset)).isoformat()
|
||||
|
||||
|
||||
def _wave(wave_id: str, price: float, stock: int) -> TicketWave:
|
||||
return TicketWave(
|
||||
id=wave_id,
|
||||
title=wave_id,
|
||||
opening_date=_day(0),
|
||||
closing_date=_day(20),
|
||||
currency="sat",
|
||||
price_per_ticket=price,
|
||||
amount_tickets=stock,
|
||||
)
|
||||
|
||||
|
||||
STORED = [_wave("early", 10, 5), _wave("regular", 25, 40)]
|
||||
|
||||
|
||||
def _stored_event() -> Event:
|
||||
return Event(
|
||||
id="evt",
|
||||
wallet="w",
|
||||
name="Fete",
|
||||
info="",
|
||||
closing_date=_day(20),
|
||||
event_start_date=_day(30),
|
||||
currency="sat",
|
||||
price_per_ticket=10,
|
||||
amount_tickets=45,
|
||||
time=datetime.now(timezone.utc),
|
||||
extra=EventExtra(
|
||||
ticket_waves=list(STORED), promo_codes=[PromoCode(code="KEEP")]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _incoming(extra_payload: dict) -> CreateEvent:
|
||||
"""A request built from raw JSON, so `__fields_set__` reflects exactly
|
||||
which keys the client actually sent."""
|
||||
return CreateEvent(
|
||||
wallet="w",
|
||||
name="Fete",
|
||||
info="",
|
||||
event_start_date=_day(30),
|
||||
currency="sat",
|
||||
price_per_ticket=77,
|
||||
amount_tickets=999,
|
||||
extra=EventExtra(**extra_payload),
|
||||
)
|
||||
|
||||
|
||||
def _apply_guard(data: CreateEvent, event: Event) -> CreateEvent:
|
||||
"""The carry-over as `api_event_update` performs it."""
|
||||
if "ticket_waves" not in data.extra.__fields_set__:
|
||||
data.extra.ticket_waves = event.extra.ticket_waves
|
||||
return data
|
||||
|
||||
|
||||
def test_client_that_omits_waves_keeps_them():
|
||||
"""The regression: a client rebuilding `extra` from scratch."""
|
||||
data = _apply_guard(_incoming({"email_notifications": False}), _stored_event())
|
||||
assert [w.id for w in data.extra.ticket_waves] == ["early", "regular"]
|
||||
assert [w.price_per_ticket for w in data.extra.ticket_waves] == [10, 25]
|
||||
|
||||
|
||||
def test_client_that_sends_waves_still_wins():
|
||||
replacement = [_wave("solo", 30, 12)]
|
||||
data = _apply_guard(_incoming({"ticket_waves": replacement}), _stored_event())
|
||||
assert [w.id for w in data.extra.ticket_waves] == ["solo"]
|
||||
|
||||
|
||||
def test_explicit_empty_list_still_resets():
|
||||
"""Matches the promo_codes contract: naming the key means you meant it."""
|
||||
data = _apply_guard(_incoming({"ticket_waves": []}), _stored_event())
|
||||
assert data.extra.ticket_waves == []
|
||||
|
||||
|
||||
def test_guard_runs_before_capacity_validation():
|
||||
"""Validation must see the carried-over waves.
|
||||
|
||||
Without the ordering, a client omitting both the waves and a real
|
||||
capacity would be rejected for a zero-capacity primary wave that only
|
||||
existed because its waves had just been dropped.
|
||||
"""
|
||||
from ..views_api import _validate_wave_capacity
|
||||
|
||||
stored = _stored_event()
|
||||
data = _incoming({"email_notifications": False})
|
||||
data.amount_tickets = 0
|
||||
|
||||
_validate_wave_capacity(_apply_guard(data, stored), stored)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["promo_codes", "ticket_waves"])
|
||||
def test_both_organiser_owned_extra_fields_are_guarded(key):
|
||||
"""Regression net: `extra` holds organiser state a client need not know
|
||||
about, and every such field needs the same carry-over."""
|
||||
stored = _stored_event()
|
||||
data = _incoming({"email_notifications": False})
|
||||
if "promo_codes" not in data.extra.__fields_set__:
|
||||
data.extra.promo_codes = stored.extra.promo_codes
|
||||
_apply_guard(data, stored)
|
||||
|
||||
assert getattr(data.extra, key), f"{key} was dropped on edit"
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
"""Wave boundaries trigger a republish (#61).
|
||||
|
||||
Every republish this extension performs is sale-driven. A wave boundary is
|
||||
a *date* boundary, so when early bird closes at midnight nothing fires and
|
||||
the relay keeps serving the closed wave's price until the next ticket
|
||||
happens to sell. `flag_wave_transitions` closes that gap by comparing the
|
||||
wave a row would advertise now against the one its last successful publish
|
||||
did, handing the work to the existing reconciliation sweep.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from .. import crud, nostr_hooks
|
||||
from ..models import Event, EventExtra, TicketWave
|
||||
|
||||
TODAY = datetime.now(timezone.utc).date()
|
||||
|
||||
|
||||
def _day(offset: int) -> str:
|
||||
return (TODAY + timedelta(days=offset)).isoformat()
|
||||
|
||||
|
||||
def _wave(wave_id: str, price: float, stock: int, opens: int, closes: int):
|
||||
return TicketWave(
|
||||
id=wave_id,
|
||||
title=wave_id,
|
||||
opening_date=_day(opens),
|
||||
closing_date=_day(closes),
|
||||
currency="EUR",
|
||||
price_per_ticket=price,
|
||||
amount_tickets=stock,
|
||||
)
|
||||
|
||||
|
||||
def _event(waves, published_wave_id, pending=False) -> Event:
|
||||
return Event(
|
||||
id="evt",
|
||||
wallet="w",
|
||||
name="Waves",
|
||||
info="",
|
||||
closing_date=_day(30),
|
||||
event_start_date=_day(30),
|
||||
event_end_date=_day(30),
|
||||
currency="sat",
|
||||
price_per_ticket=0,
|
||||
amount_tickets=0,
|
||||
time=datetime.now(timezone.utc),
|
||||
status="approved",
|
||||
nostr_publish_pending=pending,
|
||||
nostr_published_wave_id=published_wave_id,
|
||||
extra=EventExtra(ticket_waves=waves),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def swept(monkeypatch):
|
||||
"""Capture rows the detector writes back."""
|
||||
written: list[Event] = []
|
||||
|
||||
async def _update(event):
|
||||
written.append(event)
|
||||
return event
|
||||
|
||||
monkeypatch.setattr(crud, "update_event", _update)
|
||||
return written
|
||||
|
||||
|
||||
def _rows(monkeypatch, events):
|
||||
monkeypatch.setattr(crud.db, "fetchall", AsyncMock(return_value=events))
|
||||
|
||||
|
||||
CLOSED = _wave("early", 10.0, 5, -10, -1)
|
||||
OPEN = _wave("regular", 25.0, 40, 0, 20)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_moved_wave_is_flagged(monkeypatch, swept):
|
||||
"""Early bird closed; the relay still advertises it."""
|
||||
_rows(monkeypatch, [_event([CLOSED, OPEN], published_wave_id="early")])
|
||||
|
||||
assert await crud.flag_wave_transitions() == 1
|
||||
assert [e.nostr_publish_pending for e in swept] == [True]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unchanged_wave_is_left_alone(monkeypatch, swept):
|
||||
_rows(monkeypatch, [_event([CLOSED, OPEN], published_wave_id="regular")])
|
||||
|
||||
assert await crud.flag_wave_transitions() == 0
|
||||
assert swept == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selling_out_the_open_wave_is_a_transition(monkeypatch, swept):
|
||||
"""No wave open is itself an advertisable state, and a different one."""
|
||||
sold_out = _wave("regular", 25.0, 0, 0, 20)
|
||||
_rows(monkeypatch, [_event([sold_out], published_wave_id="regular")])
|
||||
|
||||
assert await crud.flag_wave_transitions() == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_already_advertising_nothing_is_not_reflagged(monkeypatch, swept):
|
||||
""" "" means "published while nothing was on sale" — not drift."""
|
||||
_rows(monkeypatch, [_event([CLOSED], published_wave_id="")])
|
||||
|
||||
assert await crud.flag_wave_transitions() == 0
|
||||
assert swept == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_never_published_rows_are_skipped(monkeypatch, swept):
|
||||
"""NULL is no evidence of drift. Flagging these would republish the
|
||||
whole table on the first boot after the upgrade."""
|
||||
_rows(monkeypatch, []) # the SQL filters them out
|
||||
|
||||
assert await crud.flag_wave_transitions() == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_records_the_advertised_wave(monkeypatch):
|
||||
"""The sweep can only detect drift if a success writes the wave down."""
|
||||
event = _event([CLOSED, OPEN], published_wave_id="early")
|
||||
monkeypatch.setattr(nostr_hooks, "update_event", AsyncMock(side_effect=lambda e: e))
|
||||
monkeypatch.setattr(
|
||||
"lnbits.core.signers.resolve_for_wallet",
|
||||
AsyncMock(return_value=SimpleNamespace(pubkey="pk")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
nostr_hooks,
|
||||
"publish_event_to_nostr",
|
||||
AsyncMock(return_value=SimpleNamespace(id="nid", created_at=123)),
|
||||
)
|
||||
|
||||
assert await nostr_hooks.publish_or_delete_nostr_event(event) is True
|
||||
assert event.nostr_published_wave_id == "regular"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_does_not_record_a_wave(monkeypatch):
|
||||
"""A takedown advertises nothing, so it must not claim a wave."""
|
||||
event = _event([CLOSED, OPEN], published_wave_id="early")
|
||||
monkeypatch.setattr(nostr_hooks, "update_event", AsyncMock(side_effect=lambda e: e))
|
||||
monkeypatch.setattr(
|
||||
"lnbits.core.signers.resolve_for_wallet",
|
||||
AsyncMock(return_value=SimpleNamespace(pubkey="pk")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
nostr_hooks,
|
||||
"publish_event_to_nostr",
|
||||
AsyncMock(return_value=SimpleNamespace(id="nid", created_at=123)),
|
||||
)
|
||||
|
||||
assert await nostr_hooks.publish_or_delete_nostr_event(event, delete=True) is True
|
||||
assert event.nostr_published_wave_id == "early"
|
||||
406
views_api.py
406
views_api.py
|
|
@ -2,7 +2,6 @@ import asyncio
|
|||
from datetime import datetime, timezone
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
|
@ -17,20 +16,17 @@ from fastapi import (
|
|||
)
|
||||
from fastapi.responses import StreamingResponse
|
||||
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.models import Account, User, WalletTypeInfo
|
||||
from lnbits.core.models.payments import CreateInvoice
|
||||
from lnbits.core.services import create_payment_request
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import (
|
||||
check_admin,
|
||||
check_user_exists,
|
||||
parse_filters,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import generate_filter_params_openapi, urlsafe_short_hash
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.exchange_rates import (
|
||||
fiat_amount_as_satoshis,
|
||||
|
|
@ -38,7 +34,6 @@ from lnbits.utils.exchange_rates import (
|
|||
satoshis_amount_as_fiat,
|
||||
)
|
||||
from lnbits.utils.nostr import normalize_public_key
|
||||
from PIL import Image
|
||||
|
||||
from .crud import (
|
||||
create_event,
|
||||
|
|
@ -58,38 +53,24 @@ from .crud import (
|
|||
get_tickets_by_event,
|
||||
get_tickets_by_payment_hash,
|
||||
get_tickets_by_user_id,
|
||||
get_tickets_paginated,
|
||||
purge_unpaid_tickets,
|
||||
update_event,
|
||||
update_settings,
|
||||
update_ticket,
|
||||
)
|
||||
from .models import (
|
||||
BasketTotals,
|
||||
CreateEvent,
|
||||
CreateTicket,
|
||||
Event,
|
||||
EventsSettings,
|
||||
PromoValidateRequest,
|
||||
PublicEvent,
|
||||
PublicTicket,
|
||||
Ticket,
|
||||
TicketFilters,
|
||||
TicketPaymentRequest,
|
||||
TicketResendResult,
|
||||
TicketWave,
|
||||
effective_payment_methods,
|
||||
ensure_ticket_waves,
|
||||
get_active_ticket_waves,
|
||||
)
|
||||
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 (
|
||||
image_png_bytes,
|
||||
load_qr_logo,
|
||||
|
|
@ -98,23 +79,16 @@ from .qr import (
|
|||
ticket_card_filename,
|
||||
)
|
||||
from .services import (
|
||||
event_promo_usage,
|
||||
hydrate_promo_usage,
|
||||
refund_tickets,
|
||||
resend_ticket_email_notification,
|
||||
send_ticket_notification_in_background,
|
||||
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")
|
||||
tickets_api_router = APIRouter(prefix="/api/v1/tickets")
|
||||
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:
|
||||
|
|
@ -123,6 +97,8 @@ def _is_fiat_currency(currency: str | None) -> bool:
|
|||
|
||||
# Literal-prefix routes (/public, /all, /pending, /settings) MUST be declared
|
||||
# before any "/{event_id}" route or FastAPI matches them as a path parameter.
|
||||
|
||||
|
||||
@events_api_router.get("")
|
||||
async def api_events(
|
||||
all_wallets: bool = Query(False),
|
||||
|
|
@ -132,17 +108,12 @@ async def api_events(
|
|||
if all_wallets:
|
||||
user = await get_user(wallet.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
events = await get_events(wallet_ids)
|
||||
for event in events:
|
||||
await hydrate_promo_usage(event)
|
||||
return events
|
||||
return await get_events(wallet_ids)
|
||||
|
||||
|
||||
@events_api_router.get("/public", response_model=list[PublicEvent])
|
||||
@events_api_router.get("/public")
|
||||
async def api_events_public() -> list[Event]:
|
||||
"""Approved, non-canceled events for an anonymous public listing.
|
||||
|
||||
Projected through `PublicEvent`: no wallet id, no promo codes."""
|
||||
"""Approved, non-canceled events for an anonymous public listing."""
|
||||
return await get_public_events()
|
||||
|
||||
|
||||
|
|
@ -160,7 +131,6 @@ async def api_events_all(
|
|||
events = await get_all_events()
|
||||
enriched: list[dict] = []
|
||||
for event in events:
|
||||
await hydrate_promo_usage(event)
|
||||
wallet = await get_wallet(event.wallet)
|
||||
row = event.dict()
|
||||
row["wallet_user_id"] = wallet.user if wallet else None
|
||||
|
|
@ -285,80 +255,33 @@ async def api_get_event(event_id: str) -> Event:
|
|||
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
|
||||
# may carry a time component since v1.3.0-aio.3 / our start-end-time
|
||||
# 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.
|
||||
# feature).
|
||||
try:
|
||||
closing_dt = datetime.fromisoformat(closing_date)
|
||||
except ValueError:
|
||||
closing_dt = datetime.strptime(closing_date[:10], "%Y-%m-%d")
|
||||
if closing_dt.tzinfo is None:
|
||||
closing_dt = closing_dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
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_window_open = datetime.now(timezone.utc) < closing_dt
|
||||
is_min_tickets_met = (
|
||||
event.sold >= event.extra.min_tickets if event.extra.conditional else True
|
||||
)
|
||||
if event.amount_tickets < 1:
|
||||
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
|
||||
if event.extra.conditional and not is_min_tickets_met and is_sales_closed:
|
||||
if event.extra.conditional and not is_min_tickets_met and not is_window_open:
|
||||
event.canceled = True
|
||||
await update_event(event)
|
||||
await refund_tickets(event_id)
|
||||
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.")
|
||||
|
||||
if not active_waves:
|
||||
if not is_window_open:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.GONE,
|
||||
detail=(
|
||||
"Ticket closing date has passed."
|
||||
if is_sales_closed
|
||||
else "No ticket wave is currently open."
|
||||
),
|
||||
status_code=HTTPStatus.GONE, detail="Ticket closing date has passed."
|
||||
)
|
||||
|
||||
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("")
|
||||
async def api_event_create(
|
||||
data: CreateEvent,
|
||||
|
|
@ -372,8 +295,6 @@ async def api_event_create(
|
|||
if not data.wallet:
|
||||
data.wallet = wallet.wallet.id
|
||||
|
||||
_validate_wave_capacity(data)
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
ext_settings = await get_settings()
|
||||
|
|
@ -416,22 +337,6 @@ async def api_event_update(
|
|||
if event.wallet != wallet.wallet.id:
|
||||
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
|
||||
|
||||
ext_settings = await get_settings()
|
||||
|
|
@ -448,13 +353,6 @@ async def api_event_update(
|
|||
if not data.closing_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.
|
||||
# Includes upstream v1.6.1 fields (allow_fiat, fiat_currency) so an
|
||||
# owner editing a fiat-enabled event keeps the fiat config.
|
||||
|
|
@ -613,31 +511,6 @@ async def api_tickets_by_user(
|
|||
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)
|
||||
async def api_get_ticket(ticket_id: str) -> Ticket:
|
||||
ticket = await get_ticket(ticket_id)
|
||||
|
|
@ -699,36 +572,6 @@ def _resolve_frontend_root(data: CreateTicket, request: Request) -> str:
|
|||
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(
|
||||
*,
|
||||
event: Event,
|
||||
|
|
@ -739,7 +582,6 @@ async def _issue_free_tickets(
|
|||
promo_code: str | None,
|
||||
nostr_identifier: str | None,
|
||||
frontend_root: str,
|
||||
selected_wave: TicketWave,
|
||||
) -> TicketPaymentRequest:
|
||||
"""Issue `quantity` free tickets without minting an invoice.
|
||||
|
||||
|
|
@ -768,11 +610,6 @@ async def _issue_free_tickets(
|
|||
ticket_id=row_id,
|
||||
extra={
|
||||
"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,
|
||||
"ticket_base_url": frontend_root,
|
||||
"sats_paid": 0,
|
||||
|
|
@ -808,23 +645,22 @@ async def api_ticket_create(
|
|||
if event.canceled:
|
||||
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.")
|
||||
quantity = data.quantity
|
||||
# `amount_tickets` IS the remaining count — `set_ticket_paid` decrements
|
||||
# it on every sale, and upstream reads it the same way everywhere. Do
|
||||
# not subtract `sold` from it: `sold` counts the same tickets the
|
||||
# decrement already removed, so doing both takes each sale off twice
|
||||
# (aiolabs/events#34).
|
||||
if event.amount_tickets < 1:
|
||||
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
|
||||
if quantity > event.amount_tickets:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Only {event.amount_tickets} ticket(s) remaining for this event.",
|
||||
)
|
||||
if event.amount_tickets > 0:
|
||||
if event.sold >= event.amount_tickets:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.GONE, detail="Event is sold out."
|
||||
)
|
||||
remaining = event.amount_tickets - event.sold
|
||||
if quantity > remaining:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Only {remaining} ticket(s) remaining for this event.",
|
||||
)
|
||||
|
||||
name = data.name
|
||||
email = data.email
|
||||
user_id = data.user_id
|
||||
promo_code = normalize_code(data.promo_code)
|
||||
promo_code = data.promo_code.upper() if data.promo_code else None
|
||||
refund_address = data.refund_address
|
||||
nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None
|
||||
payment_method = (data.payment_method or "lightning").lower()
|
||||
|
|
@ -833,11 +669,6 @@ async def api_ticket_create(
|
|||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
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:
|
||||
try:
|
||||
nostr_identifier = normalize_public_key(nostr_identifier)
|
||||
|
|
@ -846,50 +677,22 @@ async def api_ticket_create(
|
|||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Invalid Nostr identifier.",
|
||||
) from exc
|
||||
|
||||
selected_wave = _resolve_ticket_wave(event, data.ticket_wave_id)
|
||||
unit_price = event.price_per_ticket
|
||||
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
|
||||
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:
|
||||
promo = find_promo(event, promo_code)
|
||||
if not promo:
|
||||
# check if promo_code exists in event.extra.promo_codes
|
||||
if promo_code not in [pc.code for pc in event.extra.promo_codes]:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code."
|
||||
)
|
||||
if not promo.active:
|
||||
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."
|
||||
),
|
||||
)
|
||||
# get the promocode
|
||||
promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code)
|
||||
extra["promo_code"] = promo.code
|
||||
# Priced off `selected_wave`, not `event`: since v1.6.8 the price and
|
||||
# currency live on the wave, and the event-level fields are a roll-up
|
||||
# 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
|
||||
)
|
||||
unit_price = event.price_per_ticket * (1 - promo.discount_percent / 100)
|
||||
# Scale by quantity AFTER the promo applies. One invoice, N tickets.
|
||||
price = unit_price * quantity
|
||||
|
||||
# Free tickets (final charge 0 — a free event or a 100%-off promo).
|
||||
# Short-circuit before any invoice / fiat-provider logic: no Lightning
|
||||
|
|
@ -905,16 +708,6 @@ async def api_ticket_create(
|
|||
promo_code=promo_code,
|
||||
nostr_identifier=nostr_identifier,
|
||||
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
|
||||
|
|
@ -926,24 +719,23 @@ async def api_ticket_create(
|
|||
detail="Payment method not enabled for this event.",
|
||||
)
|
||||
|
||||
if _is_fiat_currency(selected_wave.currency):
|
||||
if _is_fiat_currency(event.currency):
|
||||
extra["fiat"] = True
|
||||
extra["currency"] = selected_wave.currency
|
||||
extra["currency"] = event.currency
|
||||
extra["fiatAmount"] = price
|
||||
extra["rate"] = await get_fiat_rate_satoshis(selected_wave.currency)
|
||||
extra["rate"] = await get_fiat_rate_satoshis(event.currency)
|
||||
|
||||
if payment_method != "fiat":
|
||||
price = await fiat_amount_as_satoshis(price, selected_wave.currency)
|
||||
price = await fiat_amount_as_satoshis(price, event.currency)
|
||||
|
||||
invoice_unit = selected_wave.currency
|
||||
invoice_unit = event.currency
|
||||
fiat_amount = price
|
||||
fiat_provider = None
|
||||
|
||||
if payment_method == "fiat":
|
||||
if _is_fiat_currency(selected_wave.currency):
|
||||
invoice_unit = selected_wave.currency
|
||||
if _is_fiat_currency(event.currency):
|
||||
invoice_unit = event.currency
|
||||
else:
|
||||
invoice_unit = selected_wave.fiat_currency
|
||||
invoice_unit = event.fiat_currency
|
||||
fiat_amount = await satoshis_amount_as_fiat(price, invoice_unit)
|
||||
extra["fiat"] = True
|
||||
extra["currency"] = invoice_unit
|
||||
|
|
@ -992,7 +784,6 @@ async def api_ticket_create(
|
|||
"event_id": event.id,
|
||||
"quantity": str(quantity),
|
||||
"ticket_ids": ",".join(ticket_ids),
|
||||
**({"promo_code": promo_code} if promo_code else {}),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -1019,12 +810,6 @@ async def api_ticket_create(
|
|||
ticket_id=row_id,
|
||||
extra={
|
||||
"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,
|
||||
"nostr_identifier": nostr_identifier,
|
||||
"ticket_base_url": frontend_root,
|
||||
|
|
@ -1128,9 +913,7 @@ async def api_ticket_delete(
|
|||
|
||||
@tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult)
|
||||
async def api_ticket_resend_email(
|
||||
ticket_id: str,
|
||||
request: Request,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> TicketResendResult:
|
||||
ticket = await get_ticket(ticket_id)
|
||||
if not ticket:
|
||||
|
|
@ -1148,13 +931,16 @@ async def api_ticket_resend_email(
|
|||
)
|
||||
|
||||
try:
|
||||
return await resend_ticket_email_notification(
|
||||
ticket, str(request.base_url).rstrip("/")
|
||||
)
|
||||
return await resend_ticket_email_notification(ticket)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail=str(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}")
|
||||
|
|
@ -1256,7 +1042,6 @@ async def api_event_ticket_stats(
|
|||
"registered_at": (
|
||||
t.reg_timestamp.isoformat() if t.reg_timestamp else None
|
||||
),
|
||||
"applied_promo_code": t.extra.applied_promo_code,
|
||||
}
|
||||
for t in paid_tickets
|
||||
],
|
||||
|
|
@ -1265,76 +1050,28 @@ async def api_event_ticket_stats(
|
|||
|
||||
@qr_api_router.get("/qr/{ticket_id}", response_class=StreamingResponse)
|
||||
async def api_ticket_qr(ticket_id: str):
|
||||
"""PNG of the ticket's scan payload (`ticket://<id>`).
|
||||
|
||||
Two shapes behind one route. Before the v1.6.8 merge this endpoint
|
||||
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.
|
||||
"""
|
||||
"""PNG of the ticket's scan payload (`ticket://<id>`), branded with the
|
||||
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.
|
||||
Port of upstream v1.6.8 without ticket-image compositing."""
|
||||
ticket = await get_ticket(ticket_id)
|
||||
if not ticket:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
|
||||
)
|
||||
event = await get_event(ticket.event)
|
||||
if not event:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
||||
)
|
||||
|
||||
headers = {
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0",
|
||||
}
|
||||
|
||||
waves = ensure_ticket_waves(event)
|
||||
wave = next(
|
||||
(wave for wave in waves if wave.id == ticket.extra.ticket_wave_id),
|
||||
waves[0],
|
||||
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={
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "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)
|
||||
async def api_ticket_card(ticket_id: str):
|
||||
|
|
@ -1363,24 +1100,3 @@ async def api_ticket_card(ticket_id: str):
|
|||
"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