Rebase onto upstream v1.6.8 #63
21 changed files with 2379 additions and 307 deletions
12
__init__.py
12
__init__.py
|
|
@ -139,12 +139,22 @@ def events_start():
|
||||||
Quiet by design: on a healthy instance the query returns nothing
|
Quiet by design: on a healthy instance the query returns nothing
|
||||||
and this logs nothing. It only speaks up when there is drift.
|
and this logs nothing. It only speaks up when there is drift.
|
||||||
"""
|
"""
|
||||||
from .crud import get_events_pending_republish
|
from .crud import flag_wave_transitions, get_events_pending_republish
|
||||||
from .nostr_hooks import publish_or_delete_nostr_event
|
from .nostr_hooks import publish_or_delete_nostr_event
|
||||||
|
|
||||||
await asyncio.sleep(REPUBLISH_SWEEP_FIRST_DELAY)
|
await asyncio.sleep(REPUBLISH_SWEEP_FIRST_DELAY)
|
||||||
while True:
|
while True:
|
||||||
try:
|
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()
|
pending = await get_events_pending_republish()
|
||||||
if pending:
|
if pending:
|
||||||
total = len(pending)
|
total = len(pending)
|
||||||
|
|
|
||||||
125
crud.py
125
crud.py
|
|
@ -1,10 +1,20 @@
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from lnbits.db import Database
|
from lnbits.db import Database, Filters, Page
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
|
||||||
from .models import CreateEvent, Event, EventsSettings, Ticket, TicketExtra
|
from .models import (
|
||||||
|
CreateEvent,
|
||||||
|
Event,
|
||||||
|
EventsSettings,
|
||||||
|
Ticket,
|
||||||
|
TicketExtra,
|
||||||
|
TicketFilters,
|
||||||
|
advertised_wave_key,
|
||||||
|
sync_event_ticket_waves,
|
||||||
|
)
|
||||||
|
|
||||||
db = Database("ext_events")
|
db = Database("ext_events")
|
||||||
|
|
||||||
|
|
@ -155,6 +165,31 @@ async def get_tickets_by_user_id(user_id: str) -> list[Ticket]:
|
||||||
return [Ticket(**_parse_ticket_row(row)) for row in rows]
|
return [Ticket(**_parse_ticket_row(row)) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tickets_paginated(
|
||||||
|
wallet_ids: str | list[str], filters: Filters[TicketFilters] | None = None
|
||||||
|
) -> Page[Ticket]:
|
||||||
|
if isinstance(wallet_ids, str):
|
||||||
|
wallet_ids = [wallet_ids]
|
||||||
|
|
||||||
|
wallet_where = []
|
||||||
|
values = {}
|
||||||
|
for idx, wallet_id in enumerate(wallet_ids):
|
||||||
|
key = f"wallet_id_{idx}"
|
||||||
|
wallet_where.append(f":{key}")
|
||||||
|
values[key] = wallet_id
|
||||||
|
|
||||||
|
where = [f"wallet IN ({', '.join(wallet_where)})", "paid = true"]
|
||||||
|
|
||||||
|
return await db.fetch_page(
|
||||||
|
"SELECT * FROM events.ticket",
|
||||||
|
where=where,
|
||||||
|
values=values,
|
||||||
|
filters=filters,
|
||||||
|
model=Ticket,
|
||||||
|
table_name="events.ticket",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def delete_ticket(payment_hash: str) -> None:
|
async def delete_ticket(payment_hash: str) -> None:
|
||||||
await db.execute("DELETE FROM events.ticket WHERE id = :id", {"id": payment_hash})
|
await db.execute("DELETE FROM events.ticket WHERE id = :id", {"id": payment_hash})
|
||||||
|
|
||||||
|
|
@ -184,44 +219,55 @@ async def create_event(data: CreateEvent) -> Event:
|
||||||
if not data.closing_date:
|
if not data.closing_date:
|
||||||
data.closing_date = data.event_end_date
|
data.closing_date = data.event_end_date
|
||||||
event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict())
|
event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict())
|
||||||
|
event = cast(Event, sync_event_ticket_waves(event))
|
||||||
await db.insert("events.events", event)
|
await db.insert("events.events", event)
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
|
||||||
async def update_event(event: Event) -> Event:
|
async def update_event(event: Event) -> Event:
|
||||||
|
event = cast(Event, sync_event_ticket_waves(event))
|
||||||
await db.update("events.events", event)
|
await db.update("events.events", event)
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
|
||||||
async def get_event(event_id: str) -> Event | None:
|
async def get_event(event_id: str) -> Event | None:
|
||||||
return await db.fetchone(
|
event = await db.fetchone(
|
||||||
"SELECT * FROM events.events WHERE id = :id",
|
"SELECT * FROM events.events WHERE id = :id",
|
||||||
{"id": event_id},
|
{"id": event_id},
|
||||||
Event,
|
Event,
|
||||||
)
|
)
|
||||||
|
return cast(Event, sync_event_ticket_waves(event)) if event else None
|
||||||
|
|
||||||
|
|
||||||
async def get_events(wallet_ids: str | list[str]) -> list[Event]:
|
async def get_events(wallet_ids: str | list[str]) -> list[Event]:
|
||||||
if isinstance(wallet_ids, str):
|
if isinstance(wallet_ids, str):
|
||||||
wallet_ids = [wallet_ids]
|
wallet_ids = [wallet_ids]
|
||||||
q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids])
|
q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids])
|
||||||
return await db.fetchall(
|
events = await db.fetchall(
|
||||||
f"SELECT * FROM events.events WHERE wallet IN ({q})",
|
f"SELECT * FROM events.events WHERE wallet IN ({q})",
|
||||||
model=Event,
|
model=Event,
|
||||||
)
|
)
|
||||||
|
return [cast(Event, sync_event_ticket_waves(event)) for event in events]
|
||||||
|
|
||||||
|
|
||||||
async def get_all_events() -> list[Event]:
|
async def get_all_events() -> list[Event]:
|
||||||
"""All events, no wallet filter. Admin-only callers."""
|
"""All events, no wallet filter. Admin-only callers."""
|
||||||
return await db.fetchall(
|
events = await db.fetchall(
|
||||||
"SELECT * FROM events.events ORDER BY time DESC",
|
"SELECT * FROM events.events ORDER BY time DESC",
|
||||||
model=Event,
|
model=Event,
|
||||||
)
|
)
|
||||||
|
# Wave-sync on read, exactly as upstream's `get_event` / `get_events`
|
||||||
|
# do. These four getters are fork-only, so upstream's v1.6.8 diff
|
||||||
|
# never reached them — and two of them publish: `get_all_events`
|
||||||
|
# backs /republish-all and `get_events_pending_republish` drives the
|
||||||
|
# #55 sweep. Without this they would emit the stale roll-up rather
|
||||||
|
# than the current per-wave figures.
|
||||||
|
return [cast(Event, sync_event_ticket_waves(e)) for e in events]
|
||||||
|
|
||||||
|
|
||||||
async def get_public_events() -> list[Event]:
|
async def get_public_events() -> list[Event]:
|
||||||
"""Approved, non-canceled events for the public listing."""
|
"""Approved, non-canceled events for the public listing."""
|
||||||
return await db.fetchall(
|
events = await db.fetchall(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM events.events
|
SELECT * FROM events.events
|
||||||
WHERE status = 'approved' AND canceled = FALSE
|
WHERE status = 'approved' AND canceled = FALSE
|
||||||
|
|
@ -229,14 +275,70 @@ async def get_public_events() -> list[Event]:
|
||||||
""",
|
""",
|
||||||
model=Event,
|
model=Event,
|
||||||
)
|
)
|
||||||
|
# Wave-sync on read, exactly as upstream's `get_event` / `get_events`
|
||||||
|
# do. These four getters are fork-only, so upstream's v1.6.8 diff
|
||||||
|
# never reached them — and two of them publish: `get_all_events`
|
||||||
|
# backs /republish-all and `get_events_pending_republish` drives the
|
||||||
|
# #55 sweep. Without this they would emit the stale roll-up rather
|
||||||
|
# than the current per-wave figures.
|
||||||
|
return [cast(Event, sync_event_ticket_waves(e)) for e in events]
|
||||||
|
|
||||||
|
|
||||||
async def get_pending_events() -> list[Event]:
|
async def get_pending_events() -> list[Event]:
|
||||||
"""Proposed events awaiting admin approval."""
|
"""Proposed events awaiting admin approval."""
|
||||||
return await db.fetchall(
|
events = await db.fetchall(
|
||||||
"SELECT * FROM events.events WHERE status = 'proposed' ORDER BY time DESC",
|
"SELECT * FROM events.events WHERE status = 'proposed' ORDER BY time DESC",
|
||||||
model=Event,
|
model=Event,
|
||||||
)
|
)
|
||||||
|
# Wave-sync on read, exactly as upstream's `get_event` / `get_events`
|
||||||
|
# do. These four getters are fork-only, so upstream's v1.6.8 diff
|
||||||
|
# never reached them — and two of them publish: `get_all_events`
|
||||||
|
# backs /republish-all and `get_events_pending_republish` drives the
|
||||||
|
# #55 sweep. Without this they would emit the stale roll-up rather
|
||||||
|
# than the current per-wave figures.
|
||||||
|
return [cast(Event, sync_event_ticket_waves(e)) for e in events]
|
||||||
|
|
||||||
|
|
||||||
|
async def flag_wave_transitions() -> int:
|
||||||
|
"""Flag events whose advertised ticket wave has moved on.
|
||||||
|
|
||||||
|
Every republish this extension performs is *sale*-driven. A wave
|
||||||
|
boundary is a *date* boundary, so when early bird closes at midnight
|
||||||
|
nothing fires and the relay keeps serving the closed wave's price until
|
||||||
|
the next ticket happens to sell (aiolabs/events#61).
|
||||||
|
|
||||||
|
Rather than add a scheduler and a second publish path, this compares the
|
||||||
|
wave a row would advertise now against the one its last successful
|
||||||
|
publish did (`nostr_published_wave_id`) and sets `nostr_publish_pending`
|
||||||
|
on a mismatch — handing the work to the existing reconciliation sweep,
|
||||||
|
which already retries, survives restarts and logs.
|
||||||
|
|
||||||
|
Rows with NULL `nostr_published_wave_id` are skipped: that means "never
|
||||||
|
published, or published before the column existed", which is no evidence
|
||||||
|
of drift. Flagging them would republish the whole table on first boot
|
||||||
|
after the upgrade.
|
||||||
|
|
||||||
|
Returns the number of rows newly flagged.
|
||||||
|
"""
|
||||||
|
events = await db.fetchall(
|
||||||
|
"""
|
||||||
|
SELECT * FROM events.events
|
||||||
|
WHERE nostr_published_wave_id IS NOT NULL
|
||||||
|
AND nostr_publish_pending = FALSE
|
||||||
|
AND canceled = FALSE
|
||||||
|
AND status = 'approved'
|
||||||
|
""",
|
||||||
|
model=Event,
|
||||||
|
)
|
||||||
|
flagged = 0
|
||||||
|
for event in events:
|
||||||
|
event = cast(Event, sync_event_ticket_waves(event))
|
||||||
|
if advertised_wave_key(event) == event.nostr_published_wave_id:
|
||||||
|
continue
|
||||||
|
event.nostr_publish_pending = True
|
||||||
|
await update_event(event)
|
||||||
|
flagged += 1
|
||||||
|
return flagged
|
||||||
|
|
||||||
|
|
||||||
async def get_events_pending_republish() -> list[Event]:
|
async def get_events_pending_republish() -> list[Event]:
|
||||||
|
|
@ -249,7 +351,7 @@ async def get_events_pending_republish() -> list[Event]:
|
||||||
|
|
||||||
Ordered oldest-first so a backlog drains in the order it accrued.
|
Ordered oldest-first so a backlog drains in the order it accrued.
|
||||||
"""
|
"""
|
||||||
return await db.fetchall(
|
events = await db.fetchall(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM events.events
|
SELECT * FROM events.events
|
||||||
WHERE nostr_publish_pending = TRUE
|
WHERE nostr_publish_pending = TRUE
|
||||||
|
|
@ -257,6 +359,13 @@ async def get_events_pending_republish() -> list[Event]:
|
||||||
""",
|
""",
|
||||||
model=Event,
|
model=Event,
|
||||||
)
|
)
|
||||||
|
# Wave-sync on read, exactly as upstream's `get_event` / `get_events`
|
||||||
|
# do. These four getters are fork-only, so upstream's v1.6.8 diff
|
||||||
|
# never reached them — and two of them publish: `get_all_events`
|
||||||
|
# backs /republish-all and `get_events_pending_republish` drives the
|
||||||
|
# #55 sweep. Without this they would emit the stale roll-up rather
|
||||||
|
# than the current per-wave figures.
|
||||||
|
return [cast(Event, sync_event_ticket_waves(e)) for e in events]
|
||||||
|
|
||||||
|
|
||||||
async def get_settings() -> EventsSettings:
|
async def get_settings() -> EventsSettings:
|
||||||
|
|
|
||||||
173
docs/rebase-playbook.md
Normal file
173
docs/rebase-playbook.md
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
# Rebase playbook
|
||||||
|
|
||||||
|
How to merge an upstream release into this fork without shipping the
|
||||||
|
failures a clean `git merge` cannot see.
|
||||||
|
|
||||||
|
Written during the v1.6.1 → v1.6.8 rebase (#33) after the first two
|
||||||
|
instances showed up within minutes of each other. Both were textually
|
||||||
|
clean merges that would have been semantically wrong in production.
|
||||||
|
Modes C and D were added later in the same rebase, from `services.py`.
|
||||||
|
|
||||||
|
## The four failure modes
|
||||||
|
|
||||||
|
Git resolves _text_. Neither of these produces a conflict marker.
|
||||||
|
|
||||||
|
### A. Missed application
|
||||||
|
|
||||||
|
Upstream establishes an invariant and applies it at every call site **it
|
||||||
|
knows about**. The fork has additional call sites upstream cannot see,
|
||||||
|
so the invariant silently does not hold there.
|
||||||
|
|
||||||
|
> **Worked example.** v1.6.8 made `event.amount_tickets` a per-wave
|
||||||
|
> roll-up recomputed by `sync_event_ticket_waves`, and added that call to
|
||||||
|
> `get_event` and `get_events` in `crud.py`. Both merged cleanly. But the
|
||||||
|
> fork has four getters upstream never had — `get_all_events`,
|
||||||
|
> `get_public_events`, `get_pending_events`,
|
||||||
|
> `get_events_pending_republish` — and two of them _publish_
|
||||||
|
> (`/republish-all` and the #55 sweep). Without the same call they emit
|
||||||
|
> the stale roll-up, so waves would have been wrong on exactly the paths
|
||||||
|
> that push to relays, and nowhere else.
|
||||||
|
|
||||||
|
### B. Changed meaning
|
||||||
|
|
||||||
|
Upstream redefines what an existing field _means_. Fork code that reads
|
||||||
|
it is untouched by the diff and keeps compiling, while now saying
|
||||||
|
something false.
|
||||||
|
|
||||||
|
> **Worked example.** After `sync_event_ticket_waves`,
|
||||||
|
> `event.price_per_ticket` is the **primary (first)** wave's price and
|
||||||
|
> `event.amount_tickets` is the **sum across all waves**. Our
|
||||||
|
> `nostr_publisher.build_nip52_event` reads both. Merged untouched, it
|
||||||
|
> would advertise the early-bird price after early-bird closed, and count
|
||||||
|
> stock in waves that have not opened. See #61.
|
||||||
|
|
||||||
|
### C. Misplaced conflict boundary
|
||||||
|
|
||||||
|
Git anchors a conflict on whatever lines happen to match. When both sides
|
||||||
|
rewrote the same region, an _incidental_ shared line inside it can become
|
||||||
|
the anchor — and everything past that line lands **outside** the markers,
|
||||||
|
where it reads as cleanly merged.
|
||||||
|
|
||||||
|
Resolving only what sits between the markers then leaves **both**
|
||||||
|
implementations in the file. Python does not complain: the later `def`
|
||||||
|
silently wins. Since the merge appends upstream after ours, the survivor
|
||||||
|
is upstream's — the fork's version is shadowed without a single warning.
|
||||||
|
|
||||||
|
> **Worked example.** In `services.py` the notification stack conflicted.
|
||||||
|
> Ours (220 lines: multipart HTML mail, the QR-card attachment, the
|
||||||
|
> Date/Message-ID headers that keep SpamAssassin quiet, npub DM support)
|
||||||
|
> appeared between the markers; upstream's showed as 4 lines. But both
|
||||||
|
> sides define the _same nine functions_, and git had anchored on a
|
||||||
|
> shared `_send_nostr_ticket_notification` line — so upstream's entire
|
||||||
|
> parallel stack sat below `>>>>>>>`, looking merged. Taking "ours" and
|
||||||
|
> moving on would have left upstream's definitions last in the file and
|
||||||
|
> therefore live, quietly reverting every one of those features.
|
||||||
|
|
||||||
|
**Do not trust the marker as the edit's boundary.** Before resolving a
|
||||||
|
hunk, list the function names on each side and compare them to the names
|
||||||
|
already in the file:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
grep -o '^\(async \)\?def \w*' <file>.py | sed 's/.*def //' | sort | uniq -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Run that per file **as you resolve**, not at the end. `ruff` does not
|
||||||
|
flag redefinition (verified: F ruleset passes on a duplicated `def`).
|
||||||
|
Only `mypy` does, via `no-redef` — and mypy refuses to run at all while
|
||||||
|
any file in the package still has conflict markers, so the one tool that
|
||||||
|
catches mode C is unavailable for the whole merge. The `uniq -d` line
|
||||||
|
above is the substitute.
|
||||||
|
|
||||||
|
### D. Collided names
|
||||||
|
|
||||||
|
Ours and upstream independently grew a function with the **same name for
|
||||||
|
a different feature**. Every resolution that reads as sane — take ours,
|
||||||
|
take theirs, take "the newer one" — silently deletes a feature, and the
|
||||||
|
diff looks like an ordinary reconciliation of one function.
|
||||||
|
|
||||||
|
> **Worked example.** Both sides had `_ticket_image_url`. Ours: the
|
||||||
|
> rendered QR ticket-card PNG, always attached, served by this extension
|
||||||
|
> off `lnbits_baseurl`. Upstream's: an organiser-uploaded template, opt-in
|
||||||
|
> per wave via `use_ticket_image`, served off `ticket_base_url` and
|
||||||
|
> returning `None` when the wave has not enabled it. Same name, different
|
||||||
|
> arity, different return type, unrelated features. Resolved by renaming
|
||||||
|
> ours to `_ticket_card_url` and keeping both — upstream's ticket-image
|
||||||
|
> upload UI had already merged into `index.vue`, so dropping their backend
|
||||||
|
> would have orphaned live UI.
|
||||||
|
|
||||||
|
Signals worth stopping on: the two versions differ in **arity**, in
|
||||||
|
**return type** (`str` vs `str | None`), or in which setting they build a
|
||||||
|
URL from. Any of those means it is probably not one function with two
|
||||||
|
histories.
|
||||||
|
|
||||||
|
## The procedure
|
||||||
|
|
||||||
|
Run this _after_ the merge resolves and _before_ the release.
|
||||||
|
|
||||||
|
### 1. Enumerate what upstream introduced
|
||||||
|
|
||||||
|
```sh
|
||||||
|
MB=$(git merge-base HEAD upstream/main)
|
||||||
|
# new public names
|
||||||
|
git diff $MB..<tag> -- '*.py' | grep -E "^\+(def |class |async def )"
|
||||||
|
# fields whose meaning was redefined
|
||||||
|
git show <tag>:models.py | sed -n '/^def sync_event_ticket_waves/,/return event/p'
|
||||||
|
```
|
||||||
|
|
||||||
|
Split the result into **new symbols** (mode A candidates) and
|
||||||
|
**redefined fields** (mode B candidates).
|
||||||
|
|
||||||
|
### 2. For each new symbol upstream _calls_, find the fork-only siblings
|
||||||
|
|
||||||
|
Ask what category of place the call belongs to — "every function that
|
||||||
|
returns an `Event` from the DB", "every path that prices a ticket" — then
|
||||||
|
enumerate that whole category in the merged tree and check coverage.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
grep -n "sync_event_ticket_waves" crud.py # where upstream put it
|
||||||
|
grep -n "^async def get_.*-> \(list\[\)\?Event" crud.py # where it belongs
|
||||||
|
```
|
||||||
|
|
||||||
|
The gap between those two lists is the work.
|
||||||
|
|
||||||
|
### 3. For each redefined field, grep the fork-only files
|
||||||
|
|
||||||
|
The 30-odd files upstream has never seen are where mode B hides, because
|
||||||
|
nothing in the diff touches them:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git diff --name-only $MB..HEAD > /tmp/fork.txt
|
||||||
|
git diff --name-only $MB..<tag> > /tmp/up.txt
|
||||||
|
comm -23 <(sort /tmp/fork.txt) <(sort /tmp/up.txt) # fork-only files
|
||||||
|
grep -n "price_per_ticket\|amount_tickets" $(comm -23 ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Prove each finding before fixing it
|
||||||
|
|
||||||
|
Both examples above were confirmed by reading the code path end to end,
|
||||||
|
not inferred from the diff. A wrong theory costs more than the check:
|
||||||
|
during this rebase an inference that `amount_tickets` "goes stale on
|
||||||
|
sale" was wrong — `sync_event_ticket_waves` also runs on _reads_, which
|
||||||
|
only the call-site list showed.
|
||||||
|
|
||||||
|
### 5. Write the reason at the site
|
||||||
|
|
||||||
|
Every fix from this procedure gets a comment saying **why upstream's diff
|
||||||
|
missed it**. That is what stops the next rebase re-dropping it, and it is
|
||||||
|
the only durable record that the omission was considered rather than
|
||||||
|
overlooked.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] `migrations.py` still byte-identical to upstream
|
||||||
|
- [ ] New upstream symbols enumerated; each call-site category audited
|
||||||
|
- [ ] Redefined fields enumerated; every fork-only reader checked
|
||||||
|
- [ ] Fork-only files listed and grepped for both modes
|
||||||
|
- [ ] Every resolved file checked for duplicate `def`s (mode C) — as it is
|
||||||
|
resolved, since mypy cannot run until the whole package is clean
|
||||||
|
- [ ] Same-named functions on both sides compared by arity and return type
|
||||||
|
before being treated as one function (mode D)
|
||||||
|
- [ ] Publishing paths specifically audited — they fail silently and
|
||||||
|
externally, so they are the worst place for either mode to land
|
||||||
|
- [ ] Every fix carries a comment explaining the omission
|
||||||
|
- [ ] Deviations recorded in `docs/upstream-candidates.md`
|
||||||
|
|
@ -5,7 +5,7 @@ Running log of fork features that are shaped so they could be offered to
|
||||||
in an upstream-compatible form; strike it when the PR merges upstream.
|
in an upstream-compatible form; strike it when the PR merges upstream.
|
||||||
|
|
||||||
| Feature | Where | Upstream target | Readiness |
|
| Feature | Where | Upstream target | Readiness |
|
||||||
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR |
|
| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR |
|
||||||
| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above |
|
| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above |
|
||||||
| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch |
|
| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch |
|
||||||
|
|
@ -18,3 +18,4 @@ in an upstream-compatible form; strike it when the PR merges upstream.
|
||||||
| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 |
|
| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 |
|
||||||
| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `<img>` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) |
|
| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `<img>` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) |
|
||||||
| Promo `max_uses` + derived `used_count` (per ticket; v2 counts per basket), `POST /promo/validate/{event_id}` with `quantity` instead of `items`, `PublicEventExtra` projection (v2 still exposes `extra` fully) | `promo.py`, `models.py`, `views_api.py` | lnbits/events (v2 PR #64) | review input on #64 |
|
| 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 |
|
||||||
|
|
|
||||||
|
|
@ -156,3 +156,32 @@ async def m003_event_nostr_publish_pending(db):
|
||||||
"ALTER TABLE events.events "
|
"ALTER TABLE events.events "
|
||||||
"ADD COLUMN nostr_publish_pending BOOLEAN NOT NULL DEFAULT FALSE",
|
"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",
|
||||||
|
)
|
||||||
|
|
|
||||||
183
models.py
183
models.py
|
|
@ -1,7 +1,9 @@
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import date, datetime
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from lnbits.db import FilterModel
|
||||||
from pydantic import BaseModel, EmailStr, Field, root_validator, validator
|
from pydantic import BaseModel, EmailStr, Field, root_validator, validator
|
||||||
|
|
||||||
PAYMENT_METHODS = ("lightning", "fiat")
|
PAYMENT_METHODS = ("lightning", "fiat")
|
||||||
|
|
@ -41,6 +43,20 @@ class PromoCode(BaseModel):
|
||||||
return v
|
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):
|
class EventExtraBase(BaseModel):
|
||||||
"""Everything in `extra` that is safe to show anyone. `EventExtra` adds
|
"""Everything in `extra` that is safe to show anyone. `EventExtra` adds
|
||||||
the organizer-only promo codes on top; `PublicEventExtra` is this base,
|
the organizer-only promo codes on top; `PublicEventExtra` is this base,
|
||||||
|
|
@ -76,6 +92,12 @@ class EventExtraBase(BaseModel):
|
||||||
|
|
||||||
class EventExtra(EventExtraBase):
|
class EventExtra(EventExtraBase):
|
||||||
promo_codes: list[PromoCode] = Field(default_factory=list)
|
promo_codes: list[PromoCode] = 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.
|
||||||
|
ticket_waves: list[TicketWave] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
PublicEventExtra = EventExtraBase
|
PublicEventExtra = EventExtraBase
|
||||||
|
|
@ -129,6 +151,12 @@ class Event(BaseModel):
|
||||||
# True means the relay's copy may be behind this row — see
|
# True means the relay's copy may be behind this row — see
|
||||||
# migrations_fork.m003 and the sweep in __init__.events_start.
|
# migrations_fork.m003 and the sweep in __init__.events_start.
|
||||||
nostr_publish_pending: bool = False
|
nostr_publish_pending: bool = False
|
||||||
|
# Which wave the last successful publish advertised (see
|
||||||
|
# `advertised_wave_key`). NULL = never published; "" = published while
|
||||||
|
# nothing was on sale. The sweep compares this against the current key
|
||||||
|
# to catch wave boundaries, which are time-driven and so fire no
|
||||||
|
# sale-triggered republish (aiolabs/events#61).
|
||||||
|
nostr_published_wave_id: str | None = None
|
||||||
|
|
||||||
@validator("categories", pre=True)
|
@validator("categories", pre=True)
|
||||||
def parse_categories(cls, v):
|
def parse_categories(cls, v):
|
||||||
|
|
@ -164,28 +192,44 @@ class PublicEvent(BaseModel):
|
||||||
return v or []
|
return v or []
|
||||||
|
|
||||||
|
|
||||||
def effective_payment_methods(event: "Event | PublicEvent | CreateEvent") -> list[str]:
|
def effective_payment_methods(
|
||||||
|
event: "Event | PublicEvent | CreateEvent",
|
||||||
|
wave: "TicketWave | None" = None,
|
||||||
|
) -> list[str]:
|
||||||
"""Rails a buyer may pick for `event`.
|
"""Rails a buyer may pick for `event`.
|
||||||
|
|
||||||
Explicit `extra.payment_methods` wins; an empty list falls back to the
|
Explicit `extra.payment_methods` wins; an empty list falls back to the
|
||||||
pre-#payment-methods rule so events created before the field existed
|
pre-#payment-methods rule so events created before the field existed
|
||||||
keep behaving the same (Lightning always, fiat iff `allow_fiat`).
|
keep behaving the same (Lightning always, fiat iff `allow_fiat`).
|
||||||
|
|
||||||
|
Pass `wave` when the answer is about one specific ticket wave. Fiat is a
|
||||||
|
per-wave opt-in since v1.6.8 and `event.allow_fiat` is only the PRIMARY
|
||||||
|
wave's, so without it a publisher can advertise a fiat rail for an
|
||||||
|
advertised wave that does not accept fiat — which the purchase endpoint
|
||||||
|
then rejects (aiolabs/events#61). Callers asking the event-level
|
||||||
|
question ("which rails did the organiser enable at all") leave it unset.
|
||||||
"""
|
"""
|
||||||
explicit = list(getattr(event.extra, "payment_methods", []) or [])
|
explicit = list(getattr(event.extra, "payment_methods", []) or [])
|
||||||
if explicit:
|
if explicit:
|
||||||
return explicit
|
return explicit
|
||||||
methods = ["lightning"]
|
methods = ["lightning"]
|
||||||
if event.allow_fiat:
|
if wave.allow_fiat if wave is not None else event.allow_fiat:
|
||||||
methods.append("fiat")
|
methods.append("fiat")
|
||||||
return methods
|
return methods
|
||||||
|
|
||||||
|
|
||||||
class PromoValidateRequest(BaseModel):
|
class PromoValidateRequest(BaseModel):
|
||||||
"""Upstream v2 shape. v2 sends `items` (ticket types); this fork has one
|
"""Upstream v2 shape. v2 sends `items` (ticket types); this fork prices a
|
||||||
price per event, so a plain `quantity` replaces it."""
|
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)
|
codes: list[str] = Field(default_factory=list)
|
||||||
quantity: int = Field(default=1, ge=1, le=10)
|
quantity: int = Field(default=1, ge=1, le=10)
|
||||||
|
ticket_wave_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class BasketDiscount(BaseModel):
|
class BasketDiscount(BaseModel):
|
||||||
|
|
@ -211,6 +255,8 @@ class EventsSettings(BaseModel):
|
||||||
|
|
||||||
class TicketExtra(BaseModel):
|
class TicketExtra(BaseModel):
|
||||||
applied_promo_code: str | None = None
|
applied_promo_code: str | None = None
|
||||||
|
ticket_wave_id: str | None = None
|
||||||
|
ticket_wave_title: str | None = None
|
||||||
sats_paid: int | None = None
|
sats_paid: int | None = None
|
||||||
refund_address: str | None = None
|
refund_address: str | None = None
|
||||||
nostr_identifier: str | None = None
|
nostr_identifier: str | None = None
|
||||||
|
|
@ -218,12 +264,18 @@ class TicketExtra(BaseModel):
|
||||||
email_notification_sent: bool = False
|
email_notification_sent: bool = False
|
||||||
nostr_notification_sent: bool = False
|
nostr_notification_sent: bool = False
|
||||||
refunded: bool = False
|
refunded: bool = False
|
||||||
|
# On-chain vocabulary, populated once native lnbits on-chain lands
|
||||||
|
# (aiolabs/events#41). Upstream's field names, minus the SatsPay charge
|
||||||
|
# id — SatsPay is the implementation we are not adopting.
|
||||||
|
onchain: bool = False
|
||||||
|
onchain_address: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class CreateTicket(BaseModel):
|
class CreateTicket(BaseModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
email: EmailStr | None = None
|
email: EmailStr | None = None
|
||||||
user_id: str | None = None # LNbits user id (alternative to name+email)
|
user_id: str | None = None # LNbits user id (alternative to name+email)
|
||||||
|
ticket_wave_id: str | None = None
|
||||||
promo_code: str | None = None
|
promo_code: str | None = None
|
||||||
refund_address: str | None = None
|
refund_address: str | None = None
|
||||||
nostr_identifier: str | None = None
|
nostr_identifier: str | None = None
|
||||||
|
|
@ -325,3 +377,124 @@ class TicketPaymentRequest(BaseModel):
|
||||||
# the door). Buyers fetch these after payment to render N QRs in
|
# the door). Buyers fetch these after payment to render N QRs in
|
||||||
# My Tickets.
|
# My Tickets.
|
||||||
ticket_ids: list[str] = Field(default_factory=list)
|
ticket_ids: list[str] = Field(default_factory=list)
|
||||||
|
onchain_amount_sat: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TicketFilters(FilterModel):
|
||||||
|
__search_fields__ = ["event", "name", "email", "id"] # noqa: RUF012
|
||||||
|
__sort_fields__ = [ # noqa: RUF012
|
||||||
|
"time",
|
||||||
|
"event",
|
||||||
|
"name",
|
||||||
|
"email",
|
||||||
|
"registered",
|
||||||
|
"id",
|
||||||
|
]
|
||||||
|
|
||||||
|
event: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
registered: bool | None = None
|
||||||
|
paid: bool | None = None
|
||||||
|
id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(value: str) -> date:
|
||||||
|
"""Date component of `value`.
|
||||||
|
|
||||||
|
Upstream only ever produces bare `YYYY-MM-DD` here, so its version is a
|
||||||
|
plain `strptime(value, "%Y-%m-%d")`. In this fork `event_end_date` may
|
||||||
|
carry a time (start/end times, v1.3.0-aio.3) and `create_event` defaults
|
||||||
|
`closing_date` to it, so a wave derived from an event inherits the full
|
||||||
|
ISO datetime and upstream's parser raises
|
||||||
|
`ValueError: unconverted data remains: T18:00:00` — on the purchase path,
|
||||||
|
the public event gate, and the promo preview.
|
||||||
|
"""
|
||||||
|
return date.fromisoformat(value[:10])
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_ticket_waves(event: Event | PublicEvent | CreateEvent) -> list[TicketWave]:
|
||||||
|
ticket_waves = list(getattr(event.extra, "ticket_waves", []) or [])
|
||||||
|
if ticket_waves:
|
||||||
|
return ticket_waves
|
||||||
|
|
||||||
|
# `TicketWave` requires both dates; `Event.closing_date` is Optional in
|
||||||
|
# this fork (it defaults from event_end_date at create time), so fall
|
||||||
|
# back the same way `create_event` does rather than handing None to a
|
||||||
|
# required field.
|
||||||
|
closing_date = event.closing_date or event.event_end_date or event.event_start_date
|
||||||
|
|
||||||
|
fallback_opening_date = None
|
||||||
|
event_time = getattr(event, "time", None)
|
||||||
|
if event_time:
|
||||||
|
fallback_opening_date = event_time.date().isoformat()
|
||||||
|
if not fallback_opening_date:
|
||||||
|
fallback_opening_date = closing_date
|
||||||
|
|
||||||
|
return [
|
||||||
|
TicketWave(
|
||||||
|
id="primary",
|
||||||
|
title="Primary wave",
|
||||||
|
opening_date=fallback_opening_date,
|
||||||
|
closing_date=closing_date,
|
||||||
|
currency=event.currency,
|
||||||
|
allow_fiat=event.allow_fiat,
|
||||||
|
fiat_currency=event.fiat_currency,
|
||||||
|
amount_tickets=getattr(event, "amount_tickets", 0),
|
||||||
|
price_per_ticket=event.price_per_ticket,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def advertised_ticket_wave(event: "Event | PublicEvent") -> "TicketWave | None":
|
||||||
|
"""The wave a public listing should describe, or None when nothing is
|
||||||
|
on sale (sold out, between waves, or not yet open).
|
||||||
|
|
||||||
|
Several waves can be open at once. The purchase endpoint refuses to
|
||||||
|
guess; a publisher has no one to ask, so it advertises the CHEAPEST
|
||||||
|
open wave — the price a buyer is actually able to obtain.
|
||||||
|
|
||||||
|
Shared by the NIP-52 publisher and the wave-transition detector so the
|
||||||
|
two cannot disagree about which wave is currently being advertised.
|
||||||
|
"""
|
||||||
|
active = get_active_ticket_waves(event)
|
||||||
|
if not active:
|
||||||
|
return None
|
||||||
|
return min(active, key=lambda wave: wave.price_per_ticket)
|
||||||
|
|
||||||
|
|
||||||
|
def advertised_wave_key(event: "Event | PublicEvent") -> str:
|
||||||
|
"""Stable key for what a publish advertised. Empty string means "nothing
|
||||||
|
on sale", which is a real published state and distinct from NULL in
|
||||||
|
`nostr_published_wave_id` (never published)."""
|
||||||
|
wave = advertised_ticket_wave(event)
|
||||||
|
return wave.id if wave else ""
|
||||||
|
|
||||||
|
|
||||||
|
def sync_event_ticket_waves(event: Event | CreateEvent) -> Event | CreateEvent:
|
||||||
|
ticket_waves = ensure_ticket_waves(event)
|
||||||
|
event.extra.ticket_waves = ticket_waves
|
||||||
|
|
||||||
|
primary_wave = ticket_waves[0]
|
||||||
|
event.closing_date = max(wave.closing_date for wave in ticket_waves)
|
||||||
|
event.currency = primary_wave.currency
|
||||||
|
event.allow_fiat = primary_wave.allow_fiat
|
||||||
|
event.fiat_currency = primary_wave.fiat_currency
|
||||||
|
event.amount_tickets = sum(wave.amount_tickets for wave in ticket_waves)
|
||||||
|
event.price_per_ticket = primary_wave.price_per_ticket
|
||||||
|
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_ticket_waves(
|
||||||
|
event: Event | PublicEvent, today: date | None = None
|
||||||
|
) -> list[TicketWave]:
|
||||||
|
current_day = today or datetime.utcnow().date()
|
||||||
|
return [
|
||||||
|
wave
|
||||||
|
for wave in ensure_ticket_waves(event)
|
||||||
|
if _parse_date(wave.opening_date)
|
||||||
|
<= current_day
|
||||||
|
<= _parse_date(wave.closing_date)
|
||||||
|
and wave.amount_tickets > 0
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import cycle (views_api -> nostr_hooks -> nostr_publisher -> models).
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from .crud import update_event
|
from .crud import update_event
|
||||||
from .models import Event
|
from .models import Event, advertised_wave_key
|
||||||
from .nostr_publisher import publish_event_to_nostr
|
from .nostr_publisher import publish_event_to_nostr
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -72,6 +72,11 @@ async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -
|
||||||
if not delete:
|
if not delete:
|
||||||
event.nostr_event_id = nostr_event.id
|
event.nostr_event_id = nostr_event.id
|
||||||
event.nostr_event_created_at = nostr_event.created_at
|
event.nostr_event_created_at = nostr_event.created_at
|
||||||
|
# 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)
|
await update_event(event)
|
||||||
return True
|
return True
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,12 @@ from datetime import datetime, timezone
|
||||||
from lnbits.core.signers import NostrSigner
|
from lnbits.core.signers import NostrSigner
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from .models import Event, effective_payment_methods
|
from .models import (
|
||||||
|
Event,
|
||||||
|
advertised_ticket_wave,
|
||||||
|
effective_payment_methods,
|
||||||
|
ensure_ticket_waves,
|
||||||
|
)
|
||||||
from .nostr.event import NostrEvent
|
from .nostr.event import NostrEvent
|
||||||
from .nostr_timestamp import monotonic_created_at
|
from .nostr_timestamp import monotonic_created_at
|
||||||
|
|
||||||
|
|
@ -46,11 +51,13 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
|
||||||
start - unix timestamp (31923) or YYYY-MM-DD (31922)
|
start - unix timestamp (31923) or YYYY-MM-DD (31922)
|
||||||
end - same encoding (optional)
|
end - same encoding (optional)
|
||||||
image, location, t (categories) - optional
|
image, location, t (categories) - optional
|
||||||
tickets_available - current remaining capacity (always emitted; 0 = sold out)
|
tickets_available - remaining capacity of the advertised wave
|
||||||
tickets_sold - running paid-count (always emitted; clients can
|
(always emitted; 0 = nothing on sale now)
|
||||||
derive original_capacity = available + sold)
|
tickets_sold - running paid-count across all waves (always
|
||||||
tickets_price - price_per_ticket (always emitted; 0 means free)
|
emitted)
|
||||||
tickets_currency - the currency string
|
tickets_price - the advertised wave's price (always emitted;
|
||||||
|
0 means free)
|
||||||
|
tickets_currency - the advertised wave's currency
|
||||||
tickets_allow_fiat - "true" when fiat checkout is enabled (omitted otherwise)
|
tickets_allow_fiat - "true" when fiat checkout is enabled (omitted otherwise)
|
||||||
tickets_fiat_currency - the fiat settle currency (only when allow_fiat)
|
tickets_fiat_currency - the fiat settle currency (only when allow_fiat)
|
||||||
Content: event.info
|
Content: event.info
|
||||||
|
|
@ -103,21 +110,52 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
|
||||||
# the detail page and the purchase both returned 410 (aiolabs/events#34).
|
# 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
|
# Clients that must still handle an absent tag — a NIP-52 event from
|
||||||
# some other publisher — are unaffected; we simply never omit it.
|
# some other publisher — are unaffected; we simply never omit it.
|
||||||
tags.append(["tickets_available", str(event.amount_tickets)])
|
#
|
||||||
|
# Since v1.6.8 price, currency and inventory belong to a ticket WAVE.
|
||||||
|
# The event-level fields `sync_event_ticket_waves` derives are the
|
||||||
|
# PRIMARY wave's price/currency and the SUM of every wave's stock, so
|
||||||
|
# publishing them would keep advertising the early-bird price after
|
||||||
|
# early bird closed, and count stock in waves that have not opened yet
|
||||||
|
# (aiolabs/events#61). Advertise the wave a buyer can actually buy from.
|
||||||
|
#
|
||||||
|
# Several waves can be open at once. The purchase endpoint refuses to
|
||||||
|
# guess ("Please select a ticket wave"); a publisher has no one to ask,
|
||||||
|
# so it advertises the CHEAPEST open wave — the price a buyer is able to
|
||||||
|
# obtain right now. Deviation recorded in docs/upstream-candidates.md.
|
||||||
|
open_wave = advertised_ticket_wave(event)
|
||||||
|
# Nothing open: sold out, between waves, or not yet on sale. Fall back
|
||||||
|
# to the primary wave so price/currency still describe the event,
|
||||||
|
# paired with the zero availability below.
|
||||||
|
advertised = open_wave or ensure_ticket_waves(event)[0]
|
||||||
|
|
||||||
|
# Always emitted, including zero — see #34 above. With no open wave
|
||||||
|
# there is nothing to sell regardless of what the waves hold, so this
|
||||||
|
# is 0 rather than the wave's stock.
|
||||||
|
available = advertised.amount_tickets if open_wave else 0
|
||||||
|
tags.append(["tickets_available", str(available)])
|
||||||
|
# Event-level: total paid across every wave, which is what "sold" means
|
||||||
|
# to a reader of the card.
|
||||||
tags.append(["tickets_sold", str(event.sold)])
|
tags.append(["tickets_sold", str(event.sold)])
|
||||||
tags.append(["tickets_price", str(event.price_per_ticket)])
|
tags.append(["tickets_price", str(advertised.price_per_ticket)])
|
||||||
tags.append(["tickets_currency", event.currency])
|
tags.append(["tickets_currency", advertised.currency])
|
||||||
# Fiat-checkout config — only emitted when allow_fiat is on so
|
# Fiat-checkout config — only emitted when allow_fiat is on so
|
||||||
# clients can branch the buy UI without re-reading the schema.
|
# clients can branch the buy UI without re-reading the schema.
|
||||||
if event.allow_fiat:
|
if advertised.allow_fiat:
|
||||||
tags.append(["tickets_allow_fiat", "true"])
|
tags.append(["tickets_allow_fiat", "true"])
|
||||||
if event.fiat_currency:
|
if advertised.fiat_currency:
|
||||||
tags.append(["tickets_fiat_currency", event.fiat_currency])
|
tags.append(["tickets_fiat_currency", advertised.fiat_currency])
|
||||||
# Rails the organizer accepts, resolved through the same helper the
|
# Rails the organizer accepts, resolved through the same helper the
|
||||||
# ticket endpoint enforces with, so a client can render exactly the
|
# ticket endpoint enforces with, so a client can render exactly the
|
||||||
# buttons that will be accepted (e.g. a card-only event) without a REST
|
# buttons that will be accepted (e.g. a card-only event) without a REST
|
||||||
# round-trip. Comma-separated, lowercase.
|
# round-trip. Comma-separated, lowercase.
|
||||||
tags.append(["tickets_payment_methods", ",".join(effective_payment_methods(event))])
|
tags.append(
|
||||||
|
[
|
||||||
|
"tickets_payment_methods",
|
||||||
|
# Scoped to the advertised wave so this cannot contradict
|
||||||
|
# `tickets_allow_fiat` above — they are the same fact.
|
||||||
|
",".join(effective_payment_methods(event, advertised)),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
# NIP-52 calendar events are replaceable: this d-tag is republished
|
# NIP-52 calendar events are replaceable: this d-tag is republished
|
||||||
# whenever inventory changes (a ticket sells). Use a strictly-monotonic
|
# whenever inventory changes (a ticket sells). Use a strictly-monotonic
|
||||||
|
|
|
||||||
17
promo.py
17
promo.py
|
|
@ -11,7 +11,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
||||||
from .models import BasketDiscount, BasketTotals, Event, PromoCode, Ticket
|
from .models import BasketDiscount, BasketTotals, Event, PromoCode, Ticket, TicketWave
|
||||||
|
|
||||||
SAT_UNITS = ("sat", "sats")
|
SAT_UNITS = ("sat", "sats")
|
||||||
|
|
||||||
|
|
@ -66,17 +66,26 @@ def basket_totals(
|
||||||
codes: list[str],
|
codes: list[str],
|
||||||
quantity: int,
|
quantity: int,
|
||||||
usage: dict[str, int],
|
usage: dict[str, int],
|
||||||
|
wave: TicketWave,
|
||||||
) -> BasketTotals:
|
) -> BasketTotals:
|
||||||
"""Price `quantity` tickets with the first applicable code in `codes`.
|
"""Price `quantity` tickets from `wave` with the first applicable code.
|
||||||
|
|
||||||
A code is applicable when it exists, is active, has enough uses left
|
A code is applicable when it exists, is active, has enough uses left
|
||||||
for the whole quantity, and actually saves something. Anything else is
|
for the whole quantity, and actually saves something. Anything else is
|
||||||
simply absent from `discounts_applied` (upstream v2 semantics — the
|
simply absent from `discounts_applied` (upstream v2 semantics — the
|
||||||
purchase endpoint is where hard errors are raised). Only one code is
|
purchase endpoint is where hard errors are raised). Only one code is
|
||||||
applied; v2's `combinable` stacking is out of scope here.
|
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 = event.currency or "sat"
|
currency = wave.currency or "sat"
|
||||||
subtotal = round_amount(event.price_per_ticket * quantity, currency)
|
subtotal = round_amount(wave.price_per_ticket * quantity, currency)
|
||||||
totals = BasketTotals(
|
totals = BasketTotals(
|
||||||
subtotal=subtotal, discount=0, total=subtotal, currency=currency
|
subtotal=subtotal, discount=0, total=subtotal, currency=currency
|
||||||
)
|
)
|
||||||
|
|
|
||||||
125
services.py
125
services.py
|
|
@ -26,7 +26,13 @@ from .crud import (
|
||||||
update_event,
|
update_event,
|
||||||
update_ticket,
|
update_ticket,
|
||||||
)
|
)
|
||||||
from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult
|
from .models import (
|
||||||
|
Event,
|
||||||
|
NotificationDeliveryResult,
|
||||||
|
Ticket,
|
||||||
|
TicketResendResult,
|
||||||
|
ensure_ticket_waves,
|
||||||
|
)
|
||||||
from .nostr_hooks import publish_or_delete_nostr_event
|
from .nostr_hooks import publish_or_delete_nostr_event
|
||||||
from .promo import promo_usage
|
from .promo import promo_usage
|
||||||
from .qr import (
|
from .qr import (
|
||||||
|
|
@ -70,6 +76,24 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket:
|
||||||
event = await get_event(ticket.event)
|
event = await get_event(ticket.event)
|
||||||
assert event, "Couldn't get event from ticket being paid"
|
assert event, "Couldn't get event from ticket being paid"
|
||||||
event.sold += 1
|
event.sold += 1
|
||||||
|
# Debit the wave the buyer actually bought from. v1.6.8 moved
|
||||||
|
# inventory onto waves; the event-level counter is only the
|
||||||
|
# fallback for events that predate them, and is itself a derived
|
||||||
|
# roll-up (`sync_event_ticket_waves`). Upstream's `> 0` guards are
|
||||||
|
# kept — ours decremented unconditionally and could go negative.
|
||||||
|
ticket_waves = event.extra.ticket_waves or []
|
||||||
|
if ticket_waves:
|
||||||
|
selected_wave = next(
|
||||||
|
(
|
||||||
|
wave
|
||||||
|
for wave in ticket_waves
|
||||||
|
if wave.id == ticket.extra.ticket_wave_id
|
||||||
|
),
|
||||||
|
ticket_waves[0],
|
||||||
|
)
|
||||||
|
if selected_wave.amount_tickets > 0:
|
||||||
|
selected_wave.amount_tickets -= 1
|
||||||
|
elif event.amount_tickets > 0:
|
||||||
event.amount_tickets -= 1
|
event.amount_tickets -= 1
|
||||||
# Flag inside this same write: the counters and "the relay does
|
# Flag inside this same write: the counters and "the relay does
|
||||||
# not know about them yet" land atomically, so a crash between
|
# not know about them yet" land atomically, so a crash between
|
||||||
|
|
@ -116,10 +140,17 @@ async def _send_ticket_notification(ticket: Ticket) -> None:
|
||||||
await _deliver_ticket_notifications(ticket, event)
|
await _deliver_ticket_notifications(ticket, event)
|
||||||
|
|
||||||
|
|
||||||
async def resend_ticket_email_notification(ticket: Ticket) -> TicketResendResult:
|
async def resend_ticket_email_notification(
|
||||||
|
ticket: Ticket, base_url: str | None = None
|
||||||
|
) -> TicketResendResult:
|
||||||
"""Organizer-triggered re-delivery of the ticket email. Bypasses the
|
"""Organizer-triggered re-delivery of the ticket email. Bypasses the
|
||||||
per-event `email_notifications` opt-in (the organizer asked explicitly)
|
per-event `email_notifications` opt-in (the organizer asked explicitly)
|
||||||
but still needs the instance mailer and an address on the ticket."""
|
but still needs the instance mailer and an address on the ticket.
|
||||||
|
|
||||||
|
`base_url` is upstream v1.6.8's: it re-points the ticket link at the
|
||||||
|
caller's frontend, which matters for us specifically because our
|
||||||
|
`ticket_base_url` can differ from `lnbits_baseurl` (separate web app).
|
||||||
|
"""
|
||||||
event = await get_event(ticket.event)
|
event = await get_event(ticket.event)
|
||||||
if not event:
|
if not event:
|
||||||
raise ValueError("Event does not exist.")
|
raise ValueError("Event does not exist.")
|
||||||
|
|
@ -127,7 +158,12 @@ async def resend_ticket_email_notification(ticket: Ticket) -> TicketResendResult
|
||||||
raise ValueError("Email notifications are not enabled.")
|
raise ValueError("Email notifications are not enabled.")
|
||||||
if not ticket.email:
|
if not ticket.email:
|
||||||
raise ValueError("Ticket does not have an email address.")
|
raise ValueError("Ticket does not have an email address.")
|
||||||
|
if base_url:
|
||||||
|
ticket.extra.ticket_base_url = base_url.rstrip("/")
|
||||||
|
|
||||||
|
# email-only: this is the *email* resend endpoint. Upstream calls
|
||||||
|
# `_deliver_ticket_notifications(ticket, event)` unqualified, which in
|
||||||
|
# our fork would also fire a Nostr DM the organiser did not ask for.
|
||||||
return await _deliver_ticket_notifications(ticket, event, email=True, nostr=False)
|
return await _deliver_ticket_notifications(ticket, event, email=True, nostr=False)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -163,17 +199,35 @@ def _ticket_details(ticket: Ticket, event: Event) -> str:
|
||||||
|
|
||||||
|
|
||||||
def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) -> str:
|
def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) -> str:
|
||||||
return (
|
"""Text part of the ticket mail.
|
||||||
|
|
||||||
|
Carries up to two images, which are NOT the same thing (see the note on
|
||||||
|
`_ticket_card_url` vs `_ticket_image_url`): our rendered QR card, always
|
||||||
|
present, and the organiser's uploaded template, only when the buyer's
|
||||||
|
wave opted into it. They had the same label before the v1.6.8 merge
|
||||||
|
because only one of them existed; now that both can appear the labels
|
||||||
|
have to distinguish them.
|
||||||
|
"""
|
||||||
|
message = (
|
||||||
f"{base_message}\n\n{_ticket_details(ticket, event)}"
|
f"{base_message}\n\n{_ticket_details(ticket, event)}"
|
||||||
f"\n\nTicket image: {_ticket_image_url(ticket)}"
|
f"\n\nTicket card: {_ticket_card_url(ticket)}"
|
||||||
)
|
)
|
||||||
|
ticket_image_url = _ticket_image_url(ticket, event)
|
||||||
|
if ticket_image_url:
|
||||||
|
message = f"{message}\n\nTicket image: {ticket_image_url}"
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) -> str:
|
def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) -> str:
|
||||||
"""HTML twin of the text part. Deliberately no <img>: the card travels
|
"""HTML twin of the text part.
|
||||||
as an attachment (renders inline in most clients, works offline, and
|
|
||||||
keeps SpamAssassin's HTML_IMAGE_ONLY rules quiet), and URLs become
|
Deliberately no <img> for our own ticket card: it travels as an
|
||||||
links."""
|
attachment (renders inline in most clients, works offline, and keeps
|
||||||
|
SpamAssassin's HTML_IMAGE_ONLY rules quiet), and its URL becomes a link.
|
||||||
|
The organiser's uploaded ticket image is a remote URL with no attachment
|
||||||
|
to fall back on, so that one does get upstream's <img> — the surrounding
|
||||||
|
ticket details keep the mail from being image-only either way.
|
||||||
|
"""
|
||||||
text_message = _ticket_delivery_message(ticket, event, base_message)
|
text_message = _ticket_delivery_message(ticket, event, base_message)
|
||||||
html = escape(text_message)
|
html = escape(text_message)
|
||||||
html = re.sub(
|
html = re.sub(
|
||||||
|
|
@ -181,7 +235,17 @@ def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str)
|
||||||
lambda m: f'<a href="{m.group(1)}">{m.group(1)}</a>',
|
lambda m: f'<a href="{m.group(1)}">{m.group(1)}</a>',
|
||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
return f"<p>{html.replace(chr(10), '<br />')}</p>"
|
html_message = f"<p>{html.replace(chr(10), '<br />')}</p>"
|
||||||
|
|
||||||
|
ticket_image_url = _ticket_image_url(ticket, event)
|
||||||
|
if not ticket_image_url:
|
||||||
|
return html_message
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"{html_message}"
|
||||||
|
f'<p><img src="{escape(ticket_image_url, quote=True)}" alt="Ticket image" '
|
||||||
|
'style="max-width: 200px; height: auto;" /></p>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]:
|
def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]:
|
||||||
|
|
@ -200,7 +264,16 @@ async def _deliver_ticket_notifications(
|
||||||
) -> TicketResendResult:
|
) -> TicketResendResult:
|
||||||
"""Send the ticket by every configured channel and report per-channel
|
"""Send the ticket by every configured channel and report per-channel
|
||||||
outcome (upstream v1.6.8 shape). `email` / `nostr` override the event's
|
outcome (upstream v1.6.8 shape). `email` / `nostr` override the event's
|
||||||
opt-ins when not None; the instance-level prerequisites always apply."""
|
opt-ins when not None; the instance-level prerequisites always apply.
|
||||||
|
|
||||||
|
Upstream v1.6.8 guards the nostr branch with a `_supports_nostr_delivery`
|
||||||
|
check that errors with "Only NIP-05 Nostr identifiers are supported."
|
||||||
|
That guard is NOT taken here: it encodes upstream's limitation, not ours.
|
||||||
|
`_send_nostr_ticket_notification` below dispatches NIP-05 identifiers to
|
||||||
|
core's notifier and bare npubs to `send_nostr_dm`, so adopting the guard
|
||||||
|
would silently refuse identifiers we deliver to today — and say so with
|
||||||
|
a message that would be false for this fork.
|
||||||
|
"""
|
||||||
subject, text_message, html_message = _ticket_notification_payload(ticket, event)
|
subject, text_message, html_message = _ticket_notification_payload(ticket, event)
|
||||||
updated = False
|
updated = False
|
||||||
|
|
||||||
|
|
@ -376,16 +449,36 @@ def _ticket_url(ticket: Ticket) -> str:
|
||||||
return f"{base_url}/events/ticket/{ticket.id}"
|
return f"{base_url}/events/ticket/{ticket.id}"
|
||||||
|
|
||||||
|
|
||||||
def _ticket_image_url(ticket: Ticket) -> str:
|
def _ticket_card_url(ticket: Ticket) -> str:
|
||||||
"""The ticket card PNG is served by THIS extension on the LNbits host, so
|
"""Our rendered ticket-card PNG — always available, and served by THIS
|
||||||
it is built from `lnbits_baseurl` even when `ticket_base_url` points at
|
extension on the LNbits host, so it is built from `lnbits_baseurl` even
|
||||||
a separate web app (deviation from upstream, which assumes both are the
|
when `ticket_base_url` points at a separate web app (deviation from
|
||||||
same host)."""
|
upstream, which assumes both are the same host)."""
|
||||||
return (
|
return (
|
||||||
f"{settings.lnbits_baseurl.rstrip('/')}/events/api/v1/ticket-card/{ticket.id}"
|
f"{settings.lnbits_baseurl.rstrip('/')}/events/api/v1/ticket-card/{ticket.id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ticket_image_url(ticket: Ticket, event: Event) -> str | None:
|
||||||
|
"""Upstream's organiser-uploaded ticket template, opted into per wave.
|
||||||
|
|
||||||
|
Distinct from `_ticket_card_url`: that is our own rendered card and is
|
||||||
|
always attached, this is a template the organiser uploads and enables
|
||||||
|
on a specific wave. They shared a name before the v1.6.8 merge, which
|
||||||
|
made them look like one feature.
|
||||||
|
"""
|
||||||
|
waves = ensure_ticket_waves(event)
|
||||||
|
wave = next(
|
||||||
|
(wave for wave in waves if wave.id == ticket.extra.ticket_wave_id),
|
||||||
|
waves[0],
|
||||||
|
)
|
||||||
|
if not wave.use_ticket_image:
|
||||||
|
return None
|
||||||
|
|
||||||
|
base_url = (ticket.extra.ticket_base_url or settings.lnbits_baseurl).rstrip("/")
|
||||||
|
return f"{base_url}/events/api/v1/qr/{ticket.id}"
|
||||||
|
|
||||||
|
|
||||||
async def refund_tickets(event_id: str):
|
async def refund_tickets(event_id: str):
|
||||||
"""
|
"""
|
||||||
Refund tickets for an event that has not met the minimum ticket requirement.
|
Refund tickets for an event that has not met the minimum ticket requirement.
|
||||||
|
|
|
||||||
BIN
static/image/ticket.jpg
Normal file
BIN
static/image/ticket.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
|
|
@ -13,6 +13,7 @@ window.PageEventsDisplay = {
|
||||||
email: '',
|
email: '',
|
||||||
refund: '',
|
refund: '',
|
||||||
nostr_identifier: '',
|
nostr_identifier: '',
|
||||||
|
ticket_wave_id: null,
|
||||||
payment_method: 'lightning'
|
payment_method: 'lightning'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -46,26 +47,62 @@ window.PageEventsDisplay = {
|
||||||
paymentMethods() {
|
paymentMethods() {
|
||||||
// Mirrors `effective_payment_methods` on the backend: an explicit
|
// Mirrors `effective_payment_methods` on the backend: an explicit
|
||||||
// extra.payment_methods list wins, else Lightning + fiat-if-allow_fiat.
|
// extra.payment_methods list wins, else Lightning + fiat-if-allow_fiat.
|
||||||
|
// Since v1.6.8 `allow_fiat` is a per-wave flag and `event.allow_fiat`
|
||||||
|
// is only the PRIMARY wave's, so prefer the active wave when there is
|
||||||
|
// one — otherwise a later fiat-enabled wave would not offer fiat.
|
||||||
const explicit = this.event?.extra?.payment_methods || []
|
const explicit = this.event?.extra?.payment_methods || []
|
||||||
if (explicit.length) return explicit
|
if (explicit.length) return explicit
|
||||||
return ['lightning', ...(this.event?.allow_fiat ? ['fiat'] : [])]
|
const allowFiat = this.selectedTicketWave
|
||||||
|
? this.selectedTicketWave.allow_fiat
|
||||||
|
: this.event?.allow_fiat
|
||||||
|
return ['lightning', ...(allowFiat ? ['fiat'] : [])]
|
||||||
|
},
|
||||||
|
activeTicketWaves() {
|
||||||
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
|
return (this.event?.extra?.ticket_waves || []).filter(
|
||||||
|
wave =>
|
||||||
|
wave.amount_tickets > 0 &&
|
||||||
|
wave.opening_date <= today &&
|
||||||
|
wave.closing_date >= today
|
||||||
|
)
|
||||||
|
},
|
||||||
|
selectedTicketWave() {
|
||||||
|
return (
|
||||||
|
this.activeTicketWaves.find(
|
||||||
|
wave => wave.id === this.formDialog.data.ticket_wave_id
|
||||||
|
) ||
|
||||||
|
this.activeTicketWaves[0] ||
|
||||||
|
null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
showTicketWaveSelector() {
|
||||||
|
return this.activeTicketWaves.length > 1
|
||||||
},
|
},
|
||||||
allowFiatCheckout() {
|
allowFiatCheckout() {
|
||||||
return this.paymentMethods.includes('fiat')
|
return this.paymentMethods.includes('fiat')
|
||||||
},
|
},
|
||||||
paymentMethodOptions() {
|
paymentMethodOptions() {
|
||||||
|
// Options come from `paymentMethods` — the same list the backend
|
||||||
|
// validates against in `effective_payment_methods` — rather than
|
||||||
|
// being re-derived from per-method booleans, so a rail the server
|
||||||
|
// would reject can never be offered. The `|| method` fallback covers
|
||||||
|
// a method with no label yet (on-chain, once #41 lands).
|
||||||
|
const labels = {
|
||||||
|
lightning: 'Lightning',
|
||||||
|
fiat: this.fiatCheckoutLabel
|
||||||
|
}
|
||||||
return this.paymentMethods.map(method => ({
|
return this.paymentMethods.map(method => ({
|
||||||
value: method,
|
value: method,
|
||||||
label: method === 'fiat' ? this.fiatCheckoutLabel : 'Lightning'
|
label: labels[method] || method
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
fiatCheckoutLabel() {
|
fiatCheckoutLabel() {
|
||||||
if (!this.allowFiatCheckout) return 'Fiat'
|
if (!this.allowFiatCheckout) return 'Fiat'
|
||||||
const unit = ['sat', 'sats'].includes(
|
const unit = ['sat', 'sats'].includes(
|
||||||
(this.event?.currency || '').toLowerCase()
|
(this.selectedTicketWave?.currency || '').toLowerCase()
|
||||||
)
|
)
|
||||||
? this.event?.fiat_currency
|
? this.selectedTicketWave?.fiat_currency
|
||||||
: this.event?.currency
|
: this.selectedTicketWave?.currency
|
||||||
return `Fiat (${(unit || 'GBP').toUpperCase()})`
|
return `Fiat (${(unit || 'GBP').toUpperCase()})`
|
||||||
},
|
},
|
||||||
allowEmailNotifications() {
|
allowEmailNotifications() {
|
||||||
|
|
@ -82,6 +119,16 @@ window.PageEventsDisplay = {
|
||||||
'GET',
|
'GET',
|
||||||
`/events/api/v1/events/${this.eventId}`
|
`/events/api/v1/events/${this.eventId}`
|
||||||
)
|
)
|
||||||
|
const activeWaves = (data.extra?.ticket_waves || []).filter(wave => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
|
return (
|
||||||
|
wave.amount_tickets > 0 &&
|
||||||
|
wave.opening_date <= today &&
|
||||||
|
wave.closing_date >= today
|
||||||
|
)
|
||||||
|
})
|
||||||
|
this.formDialog.data.ticket_wave_id =
|
||||||
|
activeWaves.length === 1 ? activeWaves[0].id : null
|
||||||
return data
|
return data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.eventErrorLabel = 'Event unavailable.'
|
this.eventErrorLabel = 'Event unavailable.'
|
||||||
|
|
@ -94,6 +141,10 @@ window.PageEventsDisplay = {
|
||||||
this.formDialog.data.email = ''
|
this.formDialog.data.email = ''
|
||||||
this.formDialog.data.refund = ''
|
this.formDialog.data.refund = ''
|
||||||
this.formDialog.data.nostr_identifier = ''
|
this.formDialog.data.nostr_identifier = ''
|
||||||
|
this.formDialog.data.ticket_wave_id =
|
||||||
|
this.activeTicketWaves.length === 1
|
||||||
|
? this.activeTicketWaves[0].id
|
||||||
|
: null
|
||||||
this.formDialog.data.promo_code = ''
|
this.formDialog.data.promo_code = ''
|
||||||
this.formDialog.data.payment_method =
|
this.formDialog.data.payment_method =
|
||||||
this.paymentMethods[0] || 'lightning'
|
this.paymentMethods[0] || 'lightning'
|
||||||
|
|
@ -108,6 +159,13 @@ window.PageEventsDisplay = {
|
||||||
this.paymentWebsocket.close()
|
this.paymentWebsocket.close()
|
||||||
this.paymentWebsocket = null
|
this.paymentWebsocket = null
|
||||||
}
|
}
|
||||||
|
this.paymentReq = null
|
||||||
|
this.receive = {
|
||||||
|
show: false,
|
||||||
|
status: 'pending',
|
||||||
|
paymentReq: null,
|
||||||
|
isFiat: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
nameValidation(val) {
|
nameValidation(val) {
|
||||||
const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g
|
const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g
|
||||||
|
|
@ -130,6 +188,10 @@ window.PageEventsDisplay = {
|
||||||
this.formDialog.data.email = ''
|
this.formDialog.data.email = ''
|
||||||
this.formDialog.data.refund = ''
|
this.formDialog.data.refund = ''
|
||||||
this.formDialog.data.nostr_identifier = ''
|
this.formDialog.data.nostr_identifier = ''
|
||||||
|
this.formDialog.data.ticket_wave_id =
|
||||||
|
this.activeTicketWaves.length === 1
|
||||||
|
? this.activeTicketWaves[0].id
|
||||||
|
: null
|
||||||
this.formDialog.data.payment_method =
|
this.formDialog.data.payment_method =
|
||||||
this.paymentMethods[0] || 'lightning'
|
this.paymentMethods[0] || 'lightning'
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
|
|
@ -160,10 +222,19 @@ window.PageEventsDisplay = {
|
||||||
{
|
{
|
||||||
name: this.formDialog.data.name,
|
name: this.formDialog.data.name,
|
||||||
email: this.formDialog.data.email,
|
email: this.formDialog.data.email,
|
||||||
|
ticket_wave_id: this.formDialog.data.ticket_wave_id || null,
|
||||||
promo_code: this.formDialog.data.promo_code || null,
|
promo_code: this.formDialog.data.promo_code || null,
|
||||||
refund_address: this.formDialog.data.refund || null,
|
refund_address: this.formDialog.data.refund || null,
|
||||||
nostr_identifier: this.formDialog.data.nostr_identifier || null,
|
nostr_identifier: this.formDialog.data.nostr_identifier || null,
|
||||||
payment_method: this.formDialog.data.payment_method
|
// Gate matches the template's (`paymentMethods.length > 1`).
|
||||||
|
// v1.6.8 gated on `showPaymentMethodSelector`
|
||||||
|
// (`allowFiatCheckout || allowOnchain`), a different condition:
|
||||||
|
// where the two disagreed the buyer's visible choice was
|
||||||
|
// silently replaced with 'lightning'.
|
||||||
|
payment_method:
|
||||||
|
this.paymentMethods.length > 1
|
||||||
|
? this.formDialog.data.payment_method
|
||||||
|
: this.paymentMethods[0] || 'lightning'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
const isFiat = Boolean(data.is_fiat)
|
const isFiat = Boolean(data.is_fiat)
|
||||||
|
|
@ -197,7 +268,7 @@ window.PageEventsDisplay = {
|
||||||
|
|
||||||
const url = new URL(window.location)
|
const url = new URL(window.location)
|
||||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
url.pathname = `/api/v1/ws/${paymentHash}`
|
url.pathname = `/events/api/v1/tickets/ws/${paymentHash}`
|
||||||
url.search = ''
|
url.search = ''
|
||||||
url.hash = ''
|
url.hash = ''
|
||||||
|
|
||||||
|
|
@ -206,7 +277,7 @@ window.PageEventsDisplay = {
|
||||||
|
|
||||||
ws.onmessage = event => {
|
ws.onmessage = event => {
|
||||||
const data = JSON.parse(event.data)
|
const data = JSON.parse(event.data)
|
||||||
if (data.pending === false) {
|
if (data.paid === true) {
|
||||||
this.paymentSuccess(paymentHash)
|
this.paymentSuccess(paymentHash)
|
||||||
ws.close()
|
ws.close()
|
||||||
}
|
}
|
||||||
|
|
@ -215,8 +286,12 @@ window.PageEventsDisplay = {
|
||||||
console.error('WebSocket error:', error)
|
console.error('WebSocket error:', error)
|
||||||
}
|
}
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
if (this.paymentWebsocket === ws) {
|
if (this.paymentWebsocket !== ws) return
|
||||||
this.paymentWebsocket = null
|
this.paymentWebsocket = null
|
||||||
|
if (this.receive.show) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.receive.show) this.paymentWatcher(paymentHash)
|
||||||
|
}, 3000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.trim="formDialog.data.nostr_identifier"
|
v-model.trim="formDialog.data.nostr_identifier"
|
||||||
label="(optional) Nostr NIP-05 or npub"
|
label="(optional) Nostr NIP-05"
|
||||||
hint="If provided, we'll DM your ticket link after payment."
|
hint="If provided, we'll DM your ticket link after payment."
|
||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -89,6 +89,21 @@
|
||||||
lazy-rules
|
lazy-rules
|
||||||
:hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`"
|
:hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`"
|
||||||
></q-input>
|
></q-input>
|
||||||
|
<q-select
|
||||||
|
v-if="showTicketWaveSelector"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="formDialog.data.ticket_wave_id"
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
label="Ticket wave"
|
||||||
|
:options="
|
||||||
|
activeTicketWaves.map(wave => ({
|
||||||
|
label: `${wave.title} - ${wave.price_per_ticket} ${wave.currency}`,
|
||||||
|
value: wave.id
|
||||||
|
}))
|
||||||
|
"
|
||||||
|
></q-select>
|
||||||
<div class="row q-col-gutter-md q-pt-lg items-center">
|
<div class="row q-col-gutter-md q-pt-lg items-center">
|
||||||
<div v-if="paymentMethods.length > 1" class="col-auto">
|
<div v-if="paymentMethods.length > 1" class="col-auto">
|
||||||
<q-option-group
|
<q-option-group
|
||||||
|
|
@ -117,7 +132,8 @@
|
||||||
:disable="
|
:disable="
|
||||||
formDialog.data.name == '' ||
|
formDialog.data.name == '' ||
|
||||||
formDialog.data.email == '' ||
|
formDialog.data.email == '' ||
|
||||||
Boolean(paymentReq)
|
(showTicketWaveSelector && !formDialog.data.ticket_wave_id) ||
|
||||||
|
(receive.show && Boolean(paymentReq))
|
||||||
"
|
"
|
||||||
type="submit"
|
type="submit"
|
||||||
>Submit</q-btn
|
>Submit</q-btn
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@ window.PageEvents = {
|
||||||
return {
|
return {
|
||||||
events: [],
|
events: [],
|
||||||
tickets: [],
|
tickets: [],
|
||||||
|
allPaidTickets: [],
|
||||||
resendingTicketEmails: [],
|
resendingTicketEmails: [],
|
||||||
|
isUploadingTicketTemplate: false,
|
||||||
|
ticketImageUploadTarget: null,
|
||||||
currencies: [],
|
currencies: [],
|
||||||
pendingEvents: [],
|
pendingEvents: [],
|
||||||
allUserEvents: [],
|
allUserEvents: [],
|
||||||
|
|
@ -25,7 +28,12 @@ window.PageEvents = {
|
||||||
label: 'Owner',
|
label: 'Owner',
|
||||||
field: 'wallet_user_id'
|
field: 'wallet_user_id'
|
||||||
},
|
},
|
||||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
{
|
||||||
|
name: 'id',
|
||||||
|
align: 'left',
|
||||||
|
label: 'ID',
|
||||||
|
field: row => this.shortenId(row.id)
|
||||||
|
},
|
||||||
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||||
{
|
{
|
||||||
name: 'event_start_date',
|
name: 'event_start_date',
|
||||||
|
|
@ -39,12 +47,6 @@ window.PageEvents = {
|
||||||
label: 'End date',
|
label: 'End date',
|
||||||
field: 'event_end_date'
|
field: 'event_end_date'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'closing_date',
|
|
||||||
align: 'left',
|
|
||||||
label: 'Ticket close',
|
|
||||||
field: 'closing_date'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'canceled',
|
name: 'canceled',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
|
|
@ -129,8 +131,14 @@ window.PageEvents = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
ticketsTable: {
|
ticketsTable: {
|
||||||
|
loading: false,
|
||||||
columns: [
|
columns: [
|
||||||
{name: 'event', align: 'left', label: 'Event', field: 'event'},
|
{
|
||||||
|
name: 'event',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Event',
|
||||||
|
field: row => this.shortenId(row.event)
|
||||||
|
},
|
||||||
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||||
{name: 'email', align: 'left', label: 'Email', field: 'email'},
|
{name: 'email', align: 'left', label: 'Email', field: 'email'},
|
||||||
{
|
{
|
||||||
|
|
@ -152,6 +160,12 @@ window.PageEvents = {
|
||||||
label: 'Registered',
|
label: 'Registered',
|
||||||
field: 'registered'
|
field: 'registered'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'nostr',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Nostr',
|
||||||
|
field: row => row.extra?.nostr_identifier || ''
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'promo_code',
|
name: 'promo_code',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
|
|
@ -161,7 +175,11 @@ window.PageEvents = {
|
||||||
{name: 'id', align: 'left', label: 'ID', field: 'id'}
|
{name: 'id', align: 'left', label: 'ID', field: 'id'}
|
||||||
],
|
],
|
||||||
pagination: {
|
pagination: {
|
||||||
rowsPerPage: 10
|
sortBy: 'time',
|
||||||
|
descending: true,
|
||||||
|
page: 1,
|
||||||
|
rowsPerPage: 10,
|
||||||
|
rowsNumber: 10
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Rails an organizer can enable per event. Mirrors the webapp's
|
// Rails an organizer can enable per event. Mirrors the webapp's
|
||||||
|
|
@ -218,11 +236,42 @@ window.PageEvents = {
|
||||||
categories: [],
|
categories: [],
|
||||||
extra: {
|
extra: {
|
||||||
payment_methods: ['lightning'],
|
payment_methods: ['lightning'],
|
||||||
|
ticket_waves: [],
|
||||||
promo_codes: [],
|
promo_codes: [],
|
||||||
notification_subject: '',
|
notification_subject: '',
|
||||||
notification_body: ''
|
notification_body: ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
ticketWaveDialog: {
|
||||||
|
show: false,
|
||||||
|
eventId: null,
|
||||||
|
wallet: null,
|
||||||
|
editingWaveId: null,
|
||||||
|
data: {
|
||||||
|
id: null,
|
||||||
|
title: '',
|
||||||
|
opening_date: '',
|
||||||
|
closing_date: '',
|
||||||
|
currency: 'sats',
|
||||||
|
use_ticket_image: false,
|
||||||
|
ticket_image_id: null,
|
||||||
|
allow_fiat: false,
|
||||||
|
fiat_currency: 'GBP',
|
||||||
|
amount_tickets: 0,
|
||||||
|
price_per_ticket: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
promoCodesDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {
|
||||||
|
id: null,
|
||||||
|
wallet: null,
|
||||||
|
name: '',
|
||||||
|
extra: {
|
||||||
|
promo_codes: []
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -248,19 +297,160 @@ window.PageEvents = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
shortenId(value) {
|
||||||
|
if (!value) return ''
|
||||||
|
return value.length > 4 ? `${value.slice(0, 4)}...` : value
|
||||||
|
},
|
||||||
|
primaryTicketWave(data = this.formDialog.data) {
|
||||||
|
if (!data.extra) data.extra = {}
|
||||||
|
if (!data.extra.ticket_waves || data.extra.ticket_waves.length === 0) {
|
||||||
|
data.extra.ticket_waves = [
|
||||||
|
{
|
||||||
|
id: 'primary',
|
||||||
|
title: 'Primary wave',
|
||||||
|
opening_date: data.closing_date || '',
|
||||||
|
closing_date: data.closing_date || '',
|
||||||
|
currency: data.currency || 'sats',
|
||||||
|
use_ticket_image: false,
|
||||||
|
ticket_image_id: null,
|
||||||
|
allow_fiat: Boolean(data.allow_fiat),
|
||||||
|
fiat_currency: data.fiat_currency || 'GBP',
|
||||||
|
amount_tickets: data.amount_tickets || 0,
|
||||||
|
price_per_ticket: data.price_per_ticket || 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return data.extra.ticket_waves[0]
|
||||||
|
},
|
||||||
|
syncPrimaryWaveFromForm(data = this.formDialog.data) {
|
||||||
|
const primaryWave = this.primaryTicketWave(data)
|
||||||
|
primaryWave.title = primaryWave.title || 'Primary wave'
|
||||||
|
primaryWave.opening_date = primaryWave.opening_date || ''
|
||||||
|
primaryWave.closing_date = data.closing_date || ''
|
||||||
|
primaryWave.currency = data.currency || 'sats'
|
||||||
|
primaryWave.use_ticket_image = Boolean(primaryWave.use_ticket_image)
|
||||||
|
primaryWave.ticket_image_id = primaryWave.ticket_image_id || null
|
||||||
|
primaryWave.allow_fiat = Boolean(data.allow_fiat)
|
||||||
|
primaryWave.fiat_currency = data.fiat_currency || 'GBP'
|
||||||
|
primaryWave.amount_tickets = Number(data.amount_tickets || 0)
|
||||||
|
primaryWave.price_per_ticket = Number(data.price_per_ticket || 0)
|
||||||
|
return primaryWave
|
||||||
|
},
|
||||||
|
hydrateEventForm(data) {
|
||||||
|
const formData = {
|
||||||
|
...data,
|
||||||
|
extra: {
|
||||||
|
...(data.extra || {}),
|
||||||
|
ticket_waves: [...((data.extra && data.extra.ticket_waves) || [])]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const primaryWave = this.primaryTicketWave(formData)
|
||||||
|
formData.currency = primaryWave.currency || formData.currency || 'sats'
|
||||||
|
formData.allow_fiat = Boolean(primaryWave.allow_fiat)
|
||||||
|
formData.fiat_currency = primaryWave.fiat_currency || 'GBP'
|
||||||
|
formData.amount_tickets = primaryWave.amount_tickets
|
||||||
|
formData.price_per_ticket = primaryWave.price_per_ticket
|
||||||
|
formData.closing_date =
|
||||||
|
primaryWave.closing_date || formData.closing_date || ''
|
||||||
|
return formData
|
||||||
|
},
|
||||||
isFiatCurrency(currency) {
|
isFiatCurrency(currency) {
|
||||||
return !['sat', 'sats'].includes((currency || '').toLowerCase())
|
return !['sat', 'sats'].includes((currency || '').toLowerCase())
|
||||||
},
|
},
|
||||||
getTickets() {
|
normalizePromoCodes(promoCodes = []) {
|
||||||
LNbits.api
|
return promoCodes
|
||||||
.request(
|
.filter(code => code.code?.trim() !== '')
|
||||||
|
.map(code => ({
|
||||||
|
...code,
|
||||||
|
code: code.code.trim().toUpperCase(),
|
||||||
|
// fork-only: blank / 0 = unlimited; used_count is derived server-side
|
||||||
|
max_uses: code.max_uses ? Number(code.max_uses) : null
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
templateDownloadUrl() {
|
||||||
|
return '/events/static/image/ticket.jpg'
|
||||||
|
},
|
||||||
|
async uploadAssetFile(file) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
form.append('public_asset', 'true')
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'POST',
|
||||||
|
'/api/v1/assets?public_asset=true',
|
||||||
|
null,
|
||||||
|
form
|
||||||
|
)
|
||||||
|
return data.id
|
||||||
|
},
|
||||||
|
triggerTicketImageUpload(target) {
|
||||||
|
this.ticketImageUploadTarget = target
|
||||||
|
this.$refs.ticketImageUpload.value = null
|
||||||
|
this.$refs.ticketImageUpload.click()
|
||||||
|
},
|
||||||
|
async handleTicketImageSelected(event) {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file || !this.ticketImageUploadTarget) return
|
||||||
|
|
||||||
|
this.isUploadingTicketTemplate = true
|
||||||
|
try {
|
||||||
|
const assetId = await this.uploadAssetFile(file)
|
||||||
|
if (this.ticketImageUploadTarget === 'primary') {
|
||||||
|
const wave = this.primaryTicketWave()
|
||||||
|
wave.use_ticket_image = true
|
||||||
|
wave.ticket_image_id = assetId
|
||||||
|
} else if (this.ticketImageUploadTarget === 'dialog') {
|
||||||
|
this.ticketWaveDialog.data.use_ticket_image = true
|
||||||
|
this.ticketWaveDialog.data.ticket_image_id = assetId
|
||||||
|
}
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Ticket template uploaded.',
|
||||||
|
icon: null
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
} finally {
|
||||||
|
this.isUploadingTicketTemplate = false
|
||||||
|
this.ticketImageUploadTarget = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
soldTicketsForWave(eventId, waveId) {
|
||||||
|
return this.allPaidTickets.filter(
|
||||||
|
ticket =>
|
||||||
|
ticket.event === eventId &&
|
||||||
|
ticket.paid &&
|
||||||
|
(ticket.extra?.ticket_wave_id === waveId ||
|
||||||
|
(!ticket.extra?.ticket_wave_id && waveId === 'primary'))
|
||||||
|
).length
|
||||||
|
},
|
||||||
|
async getAllTickets() {
|
||||||
|
try {
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
'/events/api/v1/tickets?all_wallets=true',
|
'/events/api/v1/tickets?all_wallets=true',
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
.then(response => {
|
this.allPaidTickets = data.filter(ticket => ticket.paid)
|
||||||
this.tickets = response.data.filter(e => e.paid)
|
} catch (error) {
|
||||||
})
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getTickets(props) {
|
||||||
|
try {
|
||||||
|
this.ticketsTable.loading = true
|
||||||
|
const params = LNbits.utils.prepareFilterQuery(this.ticketsTable, props)
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
`/events/api/v1/tickets/paginated?all_wallets=true&${params}`,
|
||||||
|
this.g.user.wallets[0].adminkey
|
||||||
|
)
|
||||||
|
this.tickets = data.data
|
||||||
|
this.ticketsTable.pagination.rowsNumber = data.total
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
} finally {
|
||||||
|
this.ticketsTable.loading = false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
deleteTicket(ticketId) {
|
deleteTicket(ticketId) {
|
||||||
const tickets = _.findWhere(this.tickets, {id: ticketId})
|
const tickets = _.findWhere(this.tickets, {id: ticketId})
|
||||||
|
|
@ -275,10 +465,9 @@ window.PageEvents = {
|
||||||
'/events/api/v1/tickets/' + ticketId,
|
'/events/api/v1/tickets/' + ticketId,
|
||||||
wallet.adminkey
|
wallet.adminkey
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then(async () => {
|
||||||
this.tickets = _.reject(this.tickets, function (obj) {
|
await this.getTickets()
|
||||||
return obj.id == ticketId
|
await this.getAllTickets()
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.catch(LNbits.utils.notifyApiError)
|
.catch(LNbits.utils.notifyApiError)
|
||||||
})
|
})
|
||||||
|
|
@ -296,14 +485,30 @@ window.PageEvents = {
|
||||||
wallet.adminkey
|
wallet.adminkey
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
|
const result = response.data
|
||||||
this.tickets = this.tickets.map(obj =>
|
this.tickets = this.tickets.map(obj =>
|
||||||
obj.id === ticket.id ? response.data : obj
|
obj.id === ticket.id ? result.ticket : obj
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (result.email?.attempted) {
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
type: 'positive',
|
type: result.email.sent ? 'positive' : 'negative',
|
||||||
message: 'Ticket email resent.',
|
message: result.email.sent
|
||||||
|
? 'Ticket email resent.'
|
||||||
|
: `Ticket email failed: ${result.email.error || 'Unknown error.'}`,
|
||||||
icon: null
|
icon: null
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.nostr?.attempted) {
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: result.nostr.sent ? 'positive' : 'negative',
|
||||||
|
message: result.nostr.sent
|
||||||
|
? 'Ticket Nostr DM resent.'
|
||||||
|
: `Ticket Nostr DM failed: ${result.nostr.error || 'Unknown error.'}`,
|
||||||
|
icon: null
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(LNbits.utils.notifyApiError)
|
.catch(LNbits.utils.notifyApiError)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|
@ -313,7 +518,7 @@ window.PageEvents = {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
exportticketsCSV() {
|
exportticketsCSV() {
|
||||||
LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets)
|
LNbits.utils.exportCSV(this.ticketsTable.columns, this.allPaidTickets)
|
||||||
},
|
},
|
||||||
getEvents() {
|
getEvents() {
|
||||||
LNbits.api
|
LNbits.api
|
||||||
|
|
@ -520,16 +725,14 @@ window.PageEvents = {
|
||||||
data.location = (data.location || '').trim() || null
|
data.location = (data.location || '').trim() || null
|
||||||
data.categories = data.categories || []
|
data.categories = data.categories || []
|
||||||
data.closing_date = data.closing_date || null
|
data.closing_date = data.closing_date || null
|
||||||
|
// Fold the form's day+time pairs first, then let upstream's helper
|
||||||
|
// mirror the form fields onto the primary wave — order matters, the
|
||||||
|
// sync reads closing_date/currency/amount_tickets off `data`.
|
||||||
|
this.syncPrimaryWaveFromForm(data)
|
||||||
if (data.extra?.promo_codes) {
|
if (data.extra?.promo_codes) {
|
||||||
data.extra.promo_codes = data.extra.promo_codes
|
data.extra.promo_codes = this.normalizePromoCodes(
|
||||||
.filter(code => code.code?.trim() !== '')
|
data.extra.promo_codes
|
||||||
.map(code => ({
|
)
|
||||||
...code,
|
|
||||||
code: code.code.trim().toUpperCase(),
|
|
||||||
// blank / 0 = unlimited; used_count is derived server-side
|
|
||||||
max_uses: code.max_uses ? Number(code.max_uses) : null
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
const methods = data.extra?.payment_methods || []
|
const methods = data.extra?.payment_methods || []
|
||||||
if (methods.length === 0) {
|
if (methods.length === 0) {
|
||||||
|
|
@ -549,6 +752,7 @@ window.PageEvents = {
|
||||||
} else if (!data.allow_fiat) {
|
} else if (!data.allow_fiat) {
|
||||||
data.fiat_currency = 'GBP'
|
data.fiat_currency = 'GBP'
|
||||||
}
|
}
|
||||||
|
this.syncPrimaryWaveFromForm(data)
|
||||||
|
|
||||||
if (data.id) {
|
if (data.id) {
|
||||||
this.updateEvent(wallet, data)
|
this.updateEvent(wallet, data)
|
||||||
|
|
@ -561,10 +765,14 @@ window.PageEvents = {
|
||||||
if (data && data.id) {
|
if (data && data.id) {
|
||||||
const start = this.splitDateTime(data.event_start_date)
|
const start = this.splitDateTime(data.event_start_date)
|
||||||
const end = this.splitDateTime(data.event_end_date)
|
const end = this.splitDateTime(data.event_end_date)
|
||||||
|
// Seed from upstream's hydrator (it materialises ticket_waves and
|
||||||
|
// mirrors the primary wave onto the flat form fields), then layer
|
||||||
|
// our fork-only fields on top.
|
||||||
|
const hydrated = this.hydrateEventForm(data)
|
||||||
this.formDialog.data = {
|
this.formDialog.data = {
|
||||||
...data,
|
...hydrated,
|
||||||
extra: {
|
extra: {
|
||||||
...(data.extra || {}),
|
...(hydrated.extra || {}),
|
||||||
// Events created before extra.payment_methods existed carry an
|
// Events created before extra.payment_methods existed carry an
|
||||||
// empty list; show the rails the backend actually accepts for
|
// empty list; show the rails the backend actually accepts for
|
||||||
// them (Lightning always, fiat when allow_fiat).
|
// them (Lightning always, fiat when allow_fiat).
|
||||||
|
|
@ -596,6 +804,21 @@ window.PageEvents = {
|
||||||
min_tickets: 1,
|
min_tickets: 1,
|
||||||
email_notifications: false,
|
email_notifications: false,
|
||||||
nostr_notifications: false,
|
nostr_notifications: false,
|
||||||
|
ticket_waves: [
|
||||||
|
{
|
||||||
|
id: 'primary',
|
||||||
|
title: 'Primary wave',
|
||||||
|
opening_date: '',
|
||||||
|
closing_date: '',
|
||||||
|
currency: 'sats',
|
||||||
|
use_ticket_image: false,
|
||||||
|
ticket_image_id: null,
|
||||||
|
allow_fiat: false,
|
||||||
|
fiat_currency: 'GBP',
|
||||||
|
amount_tickets: 0,
|
||||||
|
price_per_ticket: 0
|
||||||
|
}
|
||||||
|
],
|
||||||
promo_codes: [],
|
promo_codes: [],
|
||||||
notification_subject: '',
|
notification_subject: '',
|
||||||
notification_body: ''
|
notification_body: ''
|
||||||
|
|
@ -614,8 +837,25 @@ window.PageEvents = {
|
||||||
categories: [],
|
categories: [],
|
||||||
extra: {
|
extra: {
|
||||||
payment_methods: ['lightning'],
|
payment_methods: ['lightning'],
|
||||||
|
conditional: false,
|
||||||
|
min_tickets: 1,
|
||||||
email_notifications: false,
|
email_notifications: false,
|
||||||
nostr_notifications: false,
|
nostr_notifications: false,
|
||||||
|
ticket_waves: [
|
||||||
|
{
|
||||||
|
id: 'primary',
|
||||||
|
title: 'Primary wave',
|
||||||
|
opening_date: '',
|
||||||
|
closing_date: '',
|
||||||
|
currency: 'sats',
|
||||||
|
use_ticket_image: false,
|
||||||
|
ticket_image_id: null,
|
||||||
|
allow_fiat: false,
|
||||||
|
fiat_currency: 'GBP',
|
||||||
|
amount_tickets: 0,
|
||||||
|
price_per_ticket: 0
|
||||||
|
}
|
||||||
|
],
|
||||||
promo_codes: [],
|
promo_codes: [],
|
||||||
notification_subject: '',
|
notification_subject: '',
|
||||||
notification_body: ''
|
notification_body: ''
|
||||||
|
|
@ -636,6 +876,179 @@ window.PageEvents = {
|
||||||
const link = _.findWhere(this.events, {id: formId})
|
const link = _.findWhere(this.events, {id: formId})
|
||||||
this.openEventDialog(link)
|
this.openEventDialog(link)
|
||||||
},
|
},
|
||||||
|
openTicketWaveDialog(event, wave = null) {
|
||||||
|
const primaryWave = (event.extra?.ticket_waves || [])[0] || {}
|
||||||
|
const isEditing = Boolean(wave)
|
||||||
|
this.ticketWaveDialog = {
|
||||||
|
show: true,
|
||||||
|
eventId: event.id,
|
||||||
|
wallet: event.wallet,
|
||||||
|
editingWaveId: wave?.id || null,
|
||||||
|
data: {
|
||||||
|
id: wave?.id || null,
|
||||||
|
title: wave?.title || '',
|
||||||
|
opening_date: wave?.opening_date || '',
|
||||||
|
closing_date: wave?.closing_date || '',
|
||||||
|
currency:
|
||||||
|
wave?.currency || primaryWave.currency || event.currency || 'sats',
|
||||||
|
use_ticket_image: Boolean(wave?.use_ticket_image),
|
||||||
|
ticket_image_id: wave?.ticket_image_id || null,
|
||||||
|
allow_fiat: isEditing
|
||||||
|
? Boolean(wave?.allow_fiat)
|
||||||
|
: Boolean(primaryWave.allow_fiat ?? event.allow_fiat),
|
||||||
|
fiat_currency:
|
||||||
|
wave?.fiat_currency ||
|
||||||
|
primaryWave.fiat_currency ||
|
||||||
|
event.fiat_currency ||
|
||||||
|
'GBP',
|
||||||
|
amount_tickets: wave?.amount_tickets || 0,
|
||||||
|
price_per_ticket:
|
||||||
|
wave?.price_per_ticket ||
|
||||||
|
primaryWave.price_per_ticket ||
|
||||||
|
event.price_per_ticket ||
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resetTicketWaveDialog() {
|
||||||
|
this.ticketWaveDialog = {
|
||||||
|
show: false,
|
||||||
|
eventId: null,
|
||||||
|
wallet: null,
|
||||||
|
editingWaveId: null,
|
||||||
|
data: {
|
||||||
|
id: null,
|
||||||
|
title: '',
|
||||||
|
opening_date: '',
|
||||||
|
closing_date: '',
|
||||||
|
currency: 'sats',
|
||||||
|
use_ticket_image: false,
|
||||||
|
ticket_image_id: null,
|
||||||
|
allow_fiat: false,
|
||||||
|
fiat_currency: 'GBP',
|
||||||
|
amount_tickets: 0,
|
||||||
|
price_per_ticket: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
saveTicketWave() {
|
||||||
|
const event = _.findWhere(this.events, {
|
||||||
|
id: this.ticketWaveDialog.eventId
|
||||||
|
})
|
||||||
|
const wallet = _.findWhere(this.g.user.wallets, {
|
||||||
|
id: this.ticketWaveDialog.wallet
|
||||||
|
})
|
||||||
|
if (!event || !wallet) return
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
...event,
|
||||||
|
extra: {
|
||||||
|
...event.extra,
|
||||||
|
ticket_waves: (event.extra?.ticket_waves || []).map(existingWave =>
|
||||||
|
existingWave.id === this.ticketWaveDialog.editingWaveId
|
||||||
|
? {...this.ticketWaveDialog.data}
|
||||||
|
: existingWave
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.ticketWaveDialog.editingWaveId) {
|
||||||
|
payload.extra.ticket_waves.push({...this.ticketWaveDialog.data})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.extra?.promo_codes) {
|
||||||
|
payload.extra.promo_codes = this.normalizePromoCodes(
|
||||||
|
payload.extra.promo_codes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'PUT',
|
||||||
|
'/events/api/v1/events/' + payload.id,
|
||||||
|
wallet.adminkey,
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
this.events = this.events.map(item =>
|
||||||
|
item.id === payload.id ? response.data : item
|
||||||
|
)
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: this.ticketWaveDialog.editingWaveId
|
||||||
|
? 'Ticket wave updated.'
|
||||||
|
: 'Ticket wave added.',
|
||||||
|
icon: null
|
||||||
|
})
|
||||||
|
this.resetTicketWaveDialog()
|
||||||
|
})
|
||||||
|
.catch(LNbits.utils.notifyApiError)
|
||||||
|
},
|
||||||
|
openPromoCodesDialog(event) {
|
||||||
|
this.promoCodesDialog.data = {
|
||||||
|
...event,
|
||||||
|
extra: {
|
||||||
|
...event.extra,
|
||||||
|
promo_codes: [...(event.extra?.promo_codes || [])]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.promoCodesDialog.show = true
|
||||||
|
},
|
||||||
|
resetPromoCodesDialog() {
|
||||||
|
this.promoCodesDialog.show = false
|
||||||
|
this.promoCodesDialog.data = {
|
||||||
|
id: null,
|
||||||
|
wallet: null,
|
||||||
|
name: '',
|
||||||
|
extra: {
|
||||||
|
promo_codes: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addPromoCodeToDialog() {
|
||||||
|
this.promoCodesDialog.data.extra.promo_codes.push({
|
||||||
|
code: '',
|
||||||
|
discount_percent: 0,
|
||||||
|
active: true,
|
||||||
|
// fork-only: null = unlimited uses
|
||||||
|
max_uses: null
|
||||||
|
})
|
||||||
|
},
|
||||||
|
savePromoCodes() {
|
||||||
|
const data = this.promoCodesDialog.data
|
||||||
|
const wallet = _.findWhere(this.g.user.wallets, {
|
||||||
|
id: data.wallet
|
||||||
|
})
|
||||||
|
if (!wallet) return
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
...data,
|
||||||
|
extra: {
|
||||||
|
...data.extra,
|
||||||
|
promo_codes: this.normalizePromoCodes(data.extra?.promo_codes || [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'PUT',
|
||||||
|
'/events/api/v1/events/' + data.id,
|
||||||
|
wallet.adminkey,
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
this.events = this.events.map(event =>
|
||||||
|
event.id === data.id ? response.data : event
|
||||||
|
)
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Promo codes updated.',
|
||||||
|
icon: null
|
||||||
|
})
|
||||||
|
this.resetPromoCodesDialog()
|
||||||
|
})
|
||||||
|
.catch(LNbits.utils.notifyApiError)
|
||||||
|
},
|
||||||
updateEvent(wallet, data) {
|
updateEvent(wallet, data) {
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request(
|
.request(
|
||||||
|
|
@ -702,6 +1115,7 @@ window.PageEvents = {
|
||||||
async created() {
|
async created() {
|
||||||
if (this.g.user.wallets.length) {
|
if (this.g.user.wallets.length) {
|
||||||
this.getTickets()
|
this.getTickets()
|
||||||
|
this.getAllTickets()
|
||||||
this.getEvents()
|
this.getEvents()
|
||||||
this.getSettings()
|
this.getSettings()
|
||||||
this.getPendingEvents()
|
this.getPendingEvents()
|
||||||
|
|
|
||||||
|
|
@ -243,44 +243,101 @@
|
||||||
<q-tr v-show="props.expand" :props="props">
|
<q-tr v-show="props.expand" :props="props">
|
||||||
<q-td colspan="100%">
|
<q-td colspan="100%">
|
||||||
<div class="q-pa-md">
|
<div class="q-pa-md">
|
||||||
<div class="text-subtitle1 q-mb-md">Promo codes</div>
|
<div class="row items-center q-mb-md">
|
||||||
|
<div class="text-subtitle1">Ticket waves</div>
|
||||||
|
<q-btn
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
icon="add"
|
||||||
|
class="q-ml-sm"
|
||||||
|
@click="openTicketWaveDialog(props.row)"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
<div class="column q-mb-lg">
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<div
|
<div
|
||||||
v-if="props.row.extra.promo_codes.length == 0"
|
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="
|
||||||
|
`${wave.title} - ${wave.opening_date} to ${
|
||||||
|
wave.closing_date
|
||||||
|
} - ${
|
||||||
|
isFiatCurrency(wave.currency)
|
||||||
|
? LNbits.utils.formatCurrency(
|
||||||
|
Number(
|
||||||
|
wave.price_per_ticket || 0
|
||||||
|
).toFixed(2),
|
||||||
|
wave.currency
|
||||||
|
)
|
||||||
|
: `${wave.price_per_ticket} sats`
|
||||||
|
} - ${wave.amount_tickets} tickets - ${soldTicketsForWave(
|
||||||
|
props.row.id,
|
||||||
|
wave.id
|
||||||
|
)} sold`
|
||||||
|
"
|
||||||
|
></span>
|
||||||
|
</q-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row items-center q-mb-md">
|
||||||
|
<div class="text-subtitle1">Promo codes</div>
|
||||||
|
<q-btn
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
icon="edit"
|
||||||
|
class="q-ml-sm"
|
||||||
|
@click="openPromoCodesDialog(props.row)"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
props.row.extra.promo_codes.filter(
|
||||||
|
code => code.active
|
||||||
|
).length == 0
|
||||||
|
"
|
||||||
class="text-caption"
|
class="text-caption"
|
||||||
|
>
|
||||||
|
No active 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"
|
||||||
>
|
>
|
||||||
No promo codes for this event.
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-for="(code, index) in props.row.extra.promo_codes"
|
|
||||||
:key="index"
|
|
||||||
class="row items-center q-col-gutter-sm q-mb-sm"
|
|
||||||
>
|
|
||||||
<div class="col-auto">
|
|
||||||
<q-chip
|
<q-chip
|
||||||
square
|
square
|
||||||
size="md"
|
|
||||||
clickable
|
clickable
|
||||||
@click="utils.copyText(code.code.toUpperCase())"
|
@click="utils.copyText(code.code.toUpperCase())"
|
||||||
>
|
>
|
||||||
<q-avatar
|
|
||||||
icon="bookmark"
|
|
||||||
:color="code.active ? 'green' : 'grey'"
|
|
||||||
text-color="white"
|
|
||||||
></q-avatar>
|
|
||||||
<span v-text="code.code.toUpperCase()"></span>
|
|
||||||
</q-chip>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
Discount:
|
|
||||||
<span v-text="code.discount_percent"></span>%
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
Status:
|
|
||||||
<span
|
<span
|
||||||
:class="code.active ? 'text-green' : 'text-grey'"
|
v-text="
|
||||||
v-text="code.active ? 'Active' : 'Inactive'"
|
`${code.code.toUpperCase()} - ${code.discount_percent}%`
|
||||||
|
"
|
||||||
></span>
|
></span>
|
||||||
|
</q-chip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -308,9 +365,11 @@
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
:rows="tickets"
|
:rows="tickets"
|
||||||
|
:loading="ticketsTable.loading"
|
||||||
row-key="id"
|
row-key="id"
|
||||||
:columns="ticketsTable.columns"
|
:columns="ticketsTable.columns"
|
||||||
v-model:pagination="ticketsTable.pagination"
|
v-model:pagination="ticketsTable.pagination"
|
||||||
|
@request="getTickets"
|
||||||
>
|
>
|
||||||
<template v-slot:header="props">
|
<template v-slot:header="props">
|
||||||
<q-tr :props="props">
|
<q-tr :props="props">
|
||||||
|
|
@ -597,6 +656,39 @@
|
||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<q-separator class="q-my-md"></q-separator>
|
||||||
|
<div class="text-subtitle1 q-mt-lg q-mb-md">
|
||||||
|
Primary ticket wave (you can add other waves later)
|
||||||
|
</div>
|
||||||
|
<div class="row q-col-gutter-sm">
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="primaryTicketWave().title"
|
||||||
|
type="text"
|
||||||
|
label="Wave title"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="primaryTicketWave().opening_date"
|
||||||
|
type="date"
|
||||||
|
label="Ticket opening date"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.closing_date"
|
||||||
|
type="date"
|
||||||
|
label="Ticket closing date"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row q-col-gutter-sm">
|
<div class="row q-col-gutter-sm">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<q-select
|
<q-select
|
||||||
|
|
@ -674,6 +766,49 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<q-toggle
|
||||||
|
v-model="primaryTicketWave().use_ticket_image"
|
||||||
|
label="Use ticket image"
|
||||||
|
left-label
|
||||||
|
></q-toggle>
|
||||||
|
<div
|
||||||
|
v-if="primaryTicketWave().use_ticket_image"
|
||||||
|
class="row items-center q-col-gutter-sm q-mb-sm"
|
||||||
|
>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
type="a"
|
||||||
|
:href="templateDownloadUrl()"
|
||||||
|
download="ticket.jpg"
|
||||||
|
>
|
||||||
|
Download template
|
||||||
|
<q-tooltip>400/733 jpg</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="primary"
|
||||||
|
:loading="isUploadingTicketTemplate"
|
||||||
|
@click="triggerTicketImageUpload('primary')"
|
||||||
|
>Replace</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="primaryTicketWave().ticket_image_id"
|
||||||
|
class="col-12 text-caption"
|
||||||
|
>
|
||||||
|
Custom ticket template uploaded.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-toggle
|
||||||
|
v-model="formDialog.data.allow_fiat"
|
||||||
|
label="Allow fiat checkout"
|
||||||
|
left-label
|
||||||
|
hint="Lets attendees pay through a configured fiat provider using the event currency."
|
||||||
|
></q-toggle>
|
||||||
<q-select
|
<q-select
|
||||||
v-if="acceptsFiat && isSatPrice"
|
v-if="acceptsFiat && isSatPrice"
|
||||||
filled
|
filled
|
||||||
|
|
@ -718,119 +853,30 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<q-separator class="q-my-md"></q-separator>
|
<q-separator class="q-my-md"></q-separator>
|
||||||
<div class="text-subtitle1 q-mb-md">Promo Codes</div>
|
|
||||||
<div class="text-caption">
|
|
||||||
Allow users to enter a promo code for discounts.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="(code, index) in formDialog.data.extra.promo_codes"
|
|
||||||
:key="index"
|
|
||||||
class="row q-col-gutter-sm q-mt-md"
|
|
||||||
>
|
|
||||||
<q-input
|
|
||||||
class="col-6"
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formDialog.data.extra.promo_codes[index].code"
|
|
||||||
type="text"
|
|
||||||
label="Promo Code"
|
|
||||||
:hint="
|
|
||||||
formDialog.data.extra.promo_codes[index].used_count
|
|
||||||
? 'Used ' +
|
|
||||||
formDialog.data.extra.promo_codes[index].used_count +
|
|
||||||
(formDialog.data.extra.promo_codes[index].max_uses
|
|
||||||
? ' / ' +
|
|
||||||
formDialog.data.extra.promo_codes[index].max_uses
|
|
||||||
: '')
|
|
||||||
: ''
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<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-3"
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.number="
|
|
||||||
formDialog.data.extra.promo_codes[index].discount_percent
|
|
||||||
"
|
|
||||||
type="number"
|
|
||||||
label="Discount (%)"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
></q-input>
|
|
||||||
<q-input
|
|
||||||
class="col-3"
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.number="
|
|
||||||
formDialog.data.extra.promo_codes[index].max_uses
|
|
||||||
"
|
|
||||||
type="number"
|
|
||||||
label="Max uses"
|
|
||||||
placeholder="∞"
|
|
||||||
min="1"
|
|
||||||
hint="Blank = unlimited"
|
|
||||||
>
|
|
||||||
<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,
|
|
||||||
max_uses: null
|
|
||||||
})
|
|
||||||
"
|
|
||||||
>Add Promo Code</q-btn
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<q-separator class="q-my-md"></q-separator>
|
|
||||||
<div class="text-subtitle1 q-mb-md">Ticket Delivery</div>
|
<div class="text-subtitle1 q-mb-md">Ticket Delivery</div>
|
||||||
<div class="text-caption">
|
<div class="text-caption">
|
||||||
Send the paid ticket link automatically by email or Nostr DM.
|
Send the paid ticket link automatically by email or Nostr DM.
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row items-center q-col-gutter-md">
|
||||||
|
<div class="col-auto">
|
||||||
<q-toggle
|
<q-toggle
|
||||||
v-model="formDialog.data.extra.email_notifications"
|
v-model="formDialog.data.extra.email_notifications"
|
||||||
label="Email notifications"
|
label="Email notifications"
|
||||||
left-label
|
left-label
|
||||||
></q-toggle>
|
></q-toggle>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
<q-toggle
|
<q-toggle
|
||||||
v-model="formDialog.data.extra.nostr_notifications"
|
v-model="formDialog.data.extra.nostr_notifications"
|
||||||
label="Nostr notifications"
|
label="Nostr notifications"
|
||||||
left-label
|
left-label
|
||||||
></q-toggle>
|
></q-toggle>
|
||||||
</q-expansion-item>
|
</div>
|
||||||
|
</div>
|
||||||
<q-separator class="q-my-md"></q-separator>
|
<div
|
||||||
|
v-if="formDialog.data.extra.email_notifications"
|
||||||
|
class="q-mt-md"
|
||||||
|
>
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
|
|
@ -840,6 +886,7 @@
|
||||||
hint="Used as the email subject when sending paid ticket links."
|
hint="Used as the email subject when sending paid ticket links."
|
||||||
></q-input>
|
></q-input>
|
||||||
<q-input
|
<q-input
|
||||||
|
class="q-mt-md"
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.trim="formDialog.data.extra.notification_body"
|
v-model.trim="formDialog.data.extra.notification_body"
|
||||||
|
|
@ -847,6 +894,8 @@
|
||||||
label="Ticket notification body"
|
label="Ticket notification body"
|
||||||
hint="Shown before the ticket link in the paid ticket notification."
|
hint="Shown before the ticket link in the paid ticket notification."
|
||||||
></q-input>
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</q-expansion-item>
|
||||||
|
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
<q-btn
|
<q-btn
|
||||||
|
|
@ -863,7 +912,9 @@
|
||||||
:disable="
|
:disable="
|
||||||
!formDialog.data.wallet ||
|
!formDialog.data.wallet ||
|
||||||
!formDialog.data.name ||
|
!formDialog.data.name ||
|
||||||
!formDialog.data.event_start_day
|
!formDialog.data.event_start_day ||
|
||||||
|
primaryTicketWave().title == null ||
|
||||||
|
primaryTicketWave().opening_date == null
|
||||||
"
|
"
|
||||||
type="submit"
|
type="submit"
|
||||||
>Create Event</q-btn
|
>Create Event</q-btn
|
||||||
|
|
@ -875,5 +926,275 @@
|
||||||
</q-form>
|
</q-form>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
||||||
|
<q-dialog v-model="promoCodesDialog.show" position="top">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
<q-form @submit="savePromoCodes" class="q-gutter-md">
|
||||||
|
<div class="text-subtitle1">Promo Codes</div>
|
||||||
|
<div class="text-caption">
|
||||||
|
Allow users to enter a promo code for discounts.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(code, index) in promoCodesDialog.data.extra.promo_codes"
|
||||||
|
:key="index"
|
||||||
|
class="row q-col-gutter-sm q-mt-md"
|
||||||
|
>
|
||||||
|
<q-input
|
||||||
|
class="col-8"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="promoCodesDialog.data.extra.promo_codes[index].code"
|
||||||
|
type="text"
|
||||||
|
label="Promo Code"
|
||||||
|
>
|
||||||
|
<template v-slot:before>
|
||||||
|
<q-checkbox
|
||||||
|
left-label
|
||||||
|
v-model="
|
||||||
|
promoCodesDialog.data.extra.promo_codes[index].active
|
||||||
|
"
|
||||||
|
checked-icon="radio_button_checked"
|
||||||
|
unchecked-icon="radio_button_unchecked"
|
||||||
|
></q-checkbox>
|
||||||
|
<q-tooltip>
|
||||||
|
<span
|
||||||
|
v-text="
|
||||||
|
promoCodesDialog.data.extra.promo_codes[index].active
|
||||||
|
? 'Active'
|
||||||
|
: 'Inactive'
|
||||||
|
"
|
||||||
|
></span>
|
||||||
|
</q-tooltip>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
<q-input
|
||||||
|
class="col-2"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.number="
|
||||||
|
promoCodesDialog.data.extra.promo_codes[index].discount_percent
|
||||||
|
"
|
||||||
|
type="number"
|
||||||
|
label="Discount (%)"
|
||||||
|
:hint="
|
||||||
|
promoCodesDialog.data.extra.promo_codes[index].used_count
|
||||||
|
? 'Used ' +
|
||||||
|
promoCodesDialog.data.extra.promo_codes[index].used_count +
|
||||||
|
(promoCodesDialog.data.extra.promo_codes[index].max_uses
|
||||||
|
? ' / ' +
|
||||||
|
promoCodesDialog.data.extra.promo_codes[index].max_uses
|
||||||
|
: '')
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
>
|
||||||
|
<template v-slot:after>
|
||||||
|
<q-btn
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
icon="delete"
|
||||||
|
@click="
|
||||||
|
promoCodesDialog.data.extra.promo_codes.splice(index, 1)
|
||||||
|
"
|
||||||
|
></q-btn>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
<q-input
|
||||||
|
class="col-2"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.number="
|
||||||
|
promoCodesDialog.data.extra.promo_codes[index].max_uses
|
||||||
|
"
|
||||||
|
type="number"
|
||||||
|
label="Max uses"
|
||||||
|
placeholder="∞"
|
||||||
|
min="1"
|
||||||
|
hint="Blank = unlimited"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 q-mt-md">
|
||||||
|
<q-btn @click="addPromoCodeToDialog">Add Promo Code</q-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn unelevated color="primary" type="submit"
|
||||||
|
>Save Promo Codes</q-btn
|
||||||
|
>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
color="grey"
|
||||||
|
class="q-ml-auto"
|
||||||
|
@click="resetPromoCodesDialog"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<q-dialog v-model="ticketWaveDialog.show" position="top">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
<q-form @submit="saveTicketWave" class="q-gutter-md">
|
||||||
|
<div
|
||||||
|
class="text-subtitle1"
|
||||||
|
v-text="
|
||||||
|
ticketWaveDialog.editingWaveId
|
||||||
|
? 'Edit Ticket Wave'
|
||||||
|
: 'Add Ticket Wave'
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
<div class="row q-col-gutter-sm">
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="ticketWaveDialog.data.title"
|
||||||
|
type="text"
|
||||||
|
label="Wave title"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="ticketWaveDialog.data.opening_date"
|
||||||
|
type="date"
|
||||||
|
label="Ticket opening date"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="ticketWaveDialog.data.closing_date"
|
||||||
|
type="date"
|
||||||
|
label="Ticket closing date"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row q-col-gutter-sm">
|
||||||
|
<div class="col">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="ticketWaveDialog.data.currency"
|
||||||
|
type="text"
|
||||||
|
label="Unit"
|
||||||
|
:options="currencies"
|
||||||
|
></q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.number="ticketWaveDialog.data.amount_tickets"
|
||||||
|
type="number"
|
||||||
|
label="Amount of tickets"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.number="ticketWaveDialog.data.price_per_ticket"
|
||||||
|
type="number"
|
||||||
|
:label="'Price (' + ticketWaveDialog.data.currency + ') *'"
|
||||||
|
:step="ticketWaveDialog.data.currency != 'sats' ? '0.01' : '1'"
|
||||||
|
:mask="ticketWaveDialog.data.currency != 'sats' ? '#.##' : '#'"
|
||||||
|
fill-mask="0"
|
||||||
|
reverse-fill-mask
|
||||||
|
:disable="ticketWaveDialog.data.currency == null"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-toggle
|
||||||
|
v-model="ticketWaveDialog.data.use_ticket_image"
|
||||||
|
label="Use ticket image"
|
||||||
|
left-label
|
||||||
|
></q-toggle>
|
||||||
|
<div
|
||||||
|
v-if="ticketWaveDialog.data.use_ticket_image"
|
||||||
|
class="row items-center q-col-gutter-sm q-mb-sm"
|
||||||
|
>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
type="a"
|
||||||
|
:href="templateDownloadUrl()"
|
||||||
|
download="ticket.jpg"
|
||||||
|
>
|
||||||
|
Download template
|
||||||
|
<q-tooltip>400/733 jpg</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="primary"
|
||||||
|
:loading="isUploadingTicketTemplate"
|
||||||
|
@click="triggerTicketImageUpload('dialog')"
|
||||||
|
>Replace</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="ticketWaveDialog.data.ticket_image_id"
|
||||||
|
class="col-12 text-caption"
|
||||||
|
>
|
||||||
|
Custom ticket template uploaded.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-toggle
|
||||||
|
v-model="ticketWaveDialog.data.allow_fiat"
|
||||||
|
label="Allow fiat checkout"
|
||||||
|
left-label
|
||||||
|
hint="Lets attendees pay through a configured fiat provider using this wave currency."
|
||||||
|
></q-toggle>
|
||||||
|
<q-select
|
||||||
|
v-if="
|
||||||
|
ticketWaveDialog.data.allow_fiat &&
|
||||||
|
['sat', 'sats'].includes(
|
||||||
|
(ticketWaveDialog.data.currency || '').toLowerCase()
|
||||||
|
)
|
||||||
|
"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="ticketWaveDialog.data.fiat_currency"
|
||||||
|
label="Fiat checkout currency"
|
||||||
|
:options="
|
||||||
|
currencies.filter(
|
||||||
|
c => !['sat', 'sats'].includes((c || '').toLowerCase())
|
||||||
|
)
|
||||||
|
"
|
||||||
|
></q-select>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn unelevated color="primary" type="submit">{{
|
||||||
|
ticketWaveDialog.editingWaveId
|
||||||
|
? 'Update Ticket Wave'
|
||||||
|
: 'Save Ticket Wave'
|
||||||
|
}}</q-btn>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
color="grey"
|
||||||
|
class="q-ml-auto"
|
||||||
|
@click="resetTicketWaveDialog"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
<input
|
||||||
|
ref="ticketImageUpload"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
style="display: none"
|
||||||
|
@change="handleTicketImageSelected"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,36 @@ window.PageEventsTicket = {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
ticketId: null,
|
ticketId: null,
|
||||||
ticket: null
|
ticket: null,
|
||||||
|
printMode: false,
|
||||||
|
qrSrc: ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
printWindow() {
|
async printWindow() {
|
||||||
window.print()
|
this.printMode = true
|
||||||
|
await this.$nextTick()
|
||||||
|
await this.waitForPrintAssets()
|
||||||
|
setTimeout(() => window.print(), 50)
|
||||||
|
},
|
||||||
|
async waitForPrintAssets() {
|
||||||
|
await this.$nextTick()
|
||||||
|
const img = document.querySelector('.ticket-print-qr')
|
||||||
|
if (!img) return
|
||||||
|
if (img.complete && img.naturalWidth > 0) return
|
||||||
|
await new Promise(resolve => {
|
||||||
|
const done = () => resolve()
|
||||||
|
img.addEventListener('load', done, {once: true})
|
||||||
|
img.addEventListener('error', done, {once: true})
|
||||||
|
setTimeout(done, 500)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async created() {
|
async created() {
|
||||||
this.ticketId = this.$route.params.id
|
this.ticketId = this.$route.params.id
|
||||||
|
this.qrSrc = `/api/v1/qrcode?data=${encodeURIComponent(
|
||||||
|
`ticket://${this.ticketId}`
|
||||||
|
)}`
|
||||||
try {
|
try {
|
||||||
const {data} = await LNbits.api.request(
|
const {data} = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
|
|
@ -22,5 +42,8 @@ window.PageEventsTicket = {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
LNbits.utils.notifyApiError(error)
|
LNbits.utils.notifyApiError(error)
|
||||||
}
|
}
|
||||||
|
window.addEventListener('afterprint', () => {
|
||||||
|
this.printMode = false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,4 +36,53 @@
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div class="ticket-print-sheet" v-if="printMode">
|
||||||
|
<img class="ticket-print-qr" :src="qrSrc" alt="Ticket QR" v-if="qrSrc" />
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@media print {
|
||||||
|
@page {
|
||||||
|
size: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
color: black !important;
|
||||||
|
background: white !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body > * {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-print-sheet {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100vw;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
background: white !important;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-print-qr {
|
||||||
|
display: block;
|
||||||
|
width: 320px;
|
||||||
|
height: 320px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,15 @@ from datetime import datetime, timezone
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from ..models import Event, EventExtra, PromoCode, PublicEvent, Ticket
|
from ..models import (
|
||||||
|
Event,
|
||||||
|
EventExtra,
|
||||||
|
PromoCode,
|
||||||
|
PublicEvent,
|
||||||
|
Ticket,
|
||||||
|
TicketWave,
|
||||||
|
ensure_ticket_waves,
|
||||||
|
)
|
||||||
from ..promo import basket_totals, normalize_code, promo_usage, remaining_uses
|
from ..promo import basket_totals, normalize_code, promo_usage, remaining_uses
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -24,6 +32,15 @@ def _event(currency="sat", price=1000.0, codes=None) -> Event:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def _ticket(code, paid=True) -> Ticket:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
return Ticket(
|
return Ticket(
|
||||||
|
|
@ -103,7 +120,7 @@ def test_remaining_uses():
|
||||||
|
|
||||||
def test_sat_totals_round_to_whole_sats():
|
def test_sat_totals_round_to_whole_sats():
|
||||||
event = _event(price=333, codes=[PromoCode(code="OFF15", discount_percent=15)])
|
event = _event(price=333, codes=[PromoCode(code="OFF15", discount_percent=15)])
|
||||||
totals = basket_totals(event, ["off15"], 1, {})
|
totals = basket_totals(event, ["off15"], 1, {}, _wave(event))
|
||||||
assert (totals.subtotal, totals.total, totals.discount) == (333, 283, 50)
|
assert (totals.subtotal, totals.total, totals.discount) == (333, 283, 50)
|
||||||
assert totals.currency == "sat"
|
assert totals.currency == "sat"
|
||||||
assert totals.discounts_applied[0].dict() == {
|
assert totals.discounts_applied[0].dict() == {
|
||||||
|
|
@ -120,7 +137,7 @@ def test_fiat_totals_round_to_cents_and_scale_by_quantity():
|
||||||
price=19.99,
|
price=19.99,
|
||||||
codes=[PromoCode(code="THIRD", discount_percent=33)],
|
codes=[PromoCode(code="THIRD", discount_percent=33)],
|
||||||
)
|
)
|
||||||
totals = basket_totals(event, ["THIRD"], 3, {})
|
totals = basket_totals(event, ["THIRD"], 3, {}, _wave(event))
|
||||||
assert totals.subtotal == 59.97
|
assert totals.subtotal == 59.97
|
||||||
assert totals.total == 40.18
|
assert totals.total == 40.18
|
||||||
assert totals.discount == 19.79
|
assert totals.discount == 19.79
|
||||||
|
|
@ -135,7 +152,10 @@ def test_first_applicable_code_wins():
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
assert (
|
assert (
|
||||||
basket_totals(event, ["NOPE", "B", "A"], 1, {}).discounts_applied[0].code == "B"
|
basket_totals(event, ["NOPE", "B", "A"], 1, {}, _wave(event))
|
||||||
|
.discounts_applied[0]
|
||||||
|
.code
|
||||||
|
== "B"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -154,7 +174,7 @@ def test_inactive_unknown_zero_and_exhausted_codes_are_absent():
|
||||||
(["TWO"], {"TWO": 2}, 1),
|
(["TWO"], {"TWO": 2}, 1),
|
||||||
(["TWO"], {"TWO": 1}, 2), # not enough left for the whole quantity
|
(["TWO"], {"TWO": 1}, 2), # not enough left for the whole quantity
|
||||||
):
|
):
|
||||||
totals = basket_totals(event, codes, qty, usage)
|
totals = basket_totals(event, codes, qty, usage, _wave(event))
|
||||||
assert totals.discounts_applied == []
|
assert totals.discounts_applied == []
|
||||||
assert totals.total == totals.subtotal and totals.discount == 0
|
assert totals.total == totals.subtotal and totals.discount == 0
|
||||||
|
|
||||||
|
|
@ -166,10 +186,33 @@ def test_unlimited_and_partially_used_codes_apply():
|
||||||
PromoCode(code="INF", discount_percent=10),
|
PromoCode(code="INF", discount_percent=10),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
assert basket_totals(event, ["TWO"], 1, {"TWO": 1}).total == 500
|
assert basket_totals(event, ["TWO"], 1, {"TWO": 1}, _wave(event)).total == 500
|
||||||
assert basket_totals(event, ["INF"], 10, {"INF": 999}).total == 9000
|
assert basket_totals(event, ["INF"], 10, {"INF": 999}, _wave(event)).total == 9000
|
||||||
|
|
||||||
|
|
||||||
def test_full_discount_prices_to_zero():
|
def test_full_discount_prices_to_zero():
|
||||||
event = _event(codes=[PromoCode(code="FREE", discount_percent=100)])
|
event = _event(codes=[PromoCode(code="FREE", discount_percent=100)])
|
||||||
assert basket_totals(event, ["FREE"], 2, {}).total == 0
|
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
|
||||||
|
|
|
||||||
160
tests/test_publish_active_wave.py
Normal file
160
tests/test_publish_active_wave.py
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
"""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])) == ""
|
||||||
159
tests/test_wave_transition_sweep.py
Normal file
159
tests/test_wave_transition_sweep.py
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
"""Wave boundaries trigger a republish (#61).
|
||||||
|
|
||||||
|
Every republish this extension performs is sale-driven. A wave boundary is
|
||||||
|
a *date* boundary, so when early bird closes at midnight nothing fires and
|
||||||
|
the relay keeps serving the closed wave's price until the next ticket
|
||||||
|
happens to sell. `flag_wave_transitions` closes that gap by comparing the
|
||||||
|
wave a row would advertise now against the one its last successful publish
|
||||||
|
did, handing the work to the existing reconciliation sweep.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from .. import crud, nostr_hooks
|
||||||
|
from ..models import Event, EventExtra, TicketWave
|
||||||
|
|
||||||
|
TODAY = datetime.now(timezone.utc).date()
|
||||||
|
|
||||||
|
|
||||||
|
def _day(offset: int) -> str:
|
||||||
|
return (TODAY + timedelta(days=offset)).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _wave(wave_id: str, price: float, stock: int, opens: int, closes: int):
|
||||||
|
return TicketWave(
|
||||||
|
id=wave_id,
|
||||||
|
title=wave_id,
|
||||||
|
opening_date=_day(opens),
|
||||||
|
closing_date=_day(closes),
|
||||||
|
currency="EUR",
|
||||||
|
price_per_ticket=price,
|
||||||
|
amount_tickets=stock,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _event(waves, published_wave_id, pending=False) -> Event:
|
||||||
|
return Event(
|
||||||
|
id="evt",
|
||||||
|
wallet="w",
|
||||||
|
name="Waves",
|
||||||
|
info="",
|
||||||
|
closing_date=_day(30),
|
||||||
|
event_start_date=_day(30),
|
||||||
|
event_end_date=_day(30),
|
||||||
|
currency="sat",
|
||||||
|
price_per_ticket=0,
|
||||||
|
amount_tickets=0,
|
||||||
|
time=datetime.now(timezone.utc),
|
||||||
|
status="approved",
|
||||||
|
nostr_publish_pending=pending,
|
||||||
|
nostr_published_wave_id=published_wave_id,
|
||||||
|
extra=EventExtra(ticket_waves=waves),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def swept(monkeypatch):
|
||||||
|
"""Capture rows the detector writes back."""
|
||||||
|
written: list[Event] = []
|
||||||
|
|
||||||
|
async def _update(event):
|
||||||
|
written.append(event)
|
||||||
|
return event
|
||||||
|
|
||||||
|
monkeypatch.setattr(crud, "update_event", _update)
|
||||||
|
return written
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(monkeypatch, events):
|
||||||
|
monkeypatch.setattr(crud.db, "fetchall", AsyncMock(return_value=events))
|
||||||
|
|
||||||
|
|
||||||
|
CLOSED = _wave("early", 10.0, 5, -10, -1)
|
||||||
|
OPEN = _wave("regular", 25.0, 40, 0, 20)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_moved_wave_is_flagged(monkeypatch, swept):
|
||||||
|
"""Early bird closed; the relay still advertises it."""
|
||||||
|
_rows(monkeypatch, [_event([CLOSED, OPEN], published_wave_id="early")])
|
||||||
|
|
||||||
|
assert await crud.flag_wave_transitions() == 1
|
||||||
|
assert [e.nostr_publish_pending for e in swept] == [True]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unchanged_wave_is_left_alone(monkeypatch, swept):
|
||||||
|
_rows(monkeypatch, [_event([CLOSED, OPEN], published_wave_id="regular")])
|
||||||
|
|
||||||
|
assert await crud.flag_wave_transitions() == 0
|
||||||
|
assert swept == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_selling_out_the_open_wave_is_a_transition(monkeypatch, swept):
|
||||||
|
"""No wave open is itself an advertisable state, and a different one."""
|
||||||
|
sold_out = _wave("regular", 25.0, 0, 0, 20)
|
||||||
|
_rows(monkeypatch, [_event([sold_out], published_wave_id="regular")])
|
||||||
|
|
||||||
|
assert await crud.flag_wave_transitions() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_already_advertising_nothing_is_not_reflagged(monkeypatch, swept):
|
||||||
|
""" "" means "published while nothing was on sale" — not drift."""
|
||||||
|
_rows(monkeypatch, [_event([CLOSED], published_wave_id="")])
|
||||||
|
|
||||||
|
assert await crud.flag_wave_transitions() == 0
|
||||||
|
assert swept == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_never_published_rows_are_skipped(monkeypatch, swept):
|
||||||
|
"""NULL is no evidence of drift. Flagging these would republish the
|
||||||
|
whole table on the first boot after the upgrade."""
|
||||||
|
_rows(monkeypatch, []) # the SQL filters them out
|
||||||
|
|
||||||
|
assert await crud.flag_wave_transitions() == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_publish_records_the_advertised_wave(monkeypatch):
|
||||||
|
"""The sweep can only detect drift if a success writes the wave down."""
|
||||||
|
event = _event([CLOSED, OPEN], published_wave_id="early")
|
||||||
|
monkeypatch.setattr(nostr_hooks, "update_event", AsyncMock(side_effect=lambda e: e))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"lnbits.core.signers.resolve_for_wallet",
|
||||||
|
AsyncMock(return_value=SimpleNamespace(pubkey="pk")),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
nostr_hooks,
|
||||||
|
"publish_event_to_nostr",
|
||||||
|
AsyncMock(return_value=SimpleNamespace(id="nid", created_at=123)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await nostr_hooks.publish_or_delete_nostr_event(event) is True
|
||||||
|
assert event.nostr_published_wave_id == "regular"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_does_not_record_a_wave(monkeypatch):
|
||||||
|
"""A takedown advertises nothing, so it must not claim a wave."""
|
||||||
|
event = _event([CLOSED, OPEN], published_wave_id="early")
|
||||||
|
monkeypatch.setattr(nostr_hooks, "update_event", AsyncMock(side_effect=lambda e: e))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"lnbits.core.signers.resolve_for_wallet",
|
||||||
|
AsyncMock(return_value=SimpleNamespace(pubkey="pk")),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
nostr_hooks,
|
||||||
|
"publish_event_to_nostr",
|
||||||
|
AsyncMock(return_value=SimpleNamespace(id="nid", created_at=123)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await nostr_hooks.publish_or_delete_nostr_event(event, delete=True) is True
|
||||||
|
assert event.nostr_published_wave_id == "early"
|
||||||
244
views_api.py
244
views_api.py
|
|
@ -2,6 +2,7 @@ import asyncio
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
|
@ -16,17 +17,20 @@ from fastapi import (
|
||||||
)
|
)
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from lnbits.core.crud import get_user
|
from lnbits.core.crud import get_user
|
||||||
|
from lnbits.core.crud.assets import get_public_asset
|
||||||
from lnbits.core.crud.wallets import get_wallet
|
from lnbits.core.crud.wallets import get_wallet
|
||||||
from lnbits.core.models import Account, User, WalletTypeInfo
|
from lnbits.core.models import Account, User, WalletTypeInfo
|
||||||
from lnbits.core.models.payments import CreateInvoice
|
from lnbits.core.models.payments import CreateInvoice
|
||||||
from lnbits.core.services import create_payment_request
|
from lnbits.core.services import create_payment_request
|
||||||
|
from lnbits.db import Filters, Page
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
check_admin,
|
check_admin,
|
||||||
check_user_exists,
|
check_user_exists,
|
||||||
|
parse_filters,
|
||||||
require_admin_key,
|
require_admin_key,
|
||||||
require_invoice_key,
|
require_invoice_key,
|
||||||
)
|
)
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
from lnbits.helpers import generate_filter_params_openapi, urlsafe_short_hash
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.utils.exchange_rates import (
|
from lnbits.utils.exchange_rates import (
|
||||||
fiat_amount_as_satoshis,
|
fiat_amount_as_satoshis,
|
||||||
|
|
@ -34,6 +38,7 @@ from lnbits.utils.exchange_rates import (
|
||||||
satoshis_amount_as_fiat,
|
satoshis_amount_as_fiat,
|
||||||
)
|
)
|
||||||
from lnbits.utils.nostr import normalize_public_key
|
from lnbits.utils.nostr import normalize_public_key
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
from .crud import (
|
from .crud import (
|
||||||
create_event,
|
create_event,
|
||||||
|
|
@ -53,6 +58,7 @@ from .crud import (
|
||||||
get_tickets_by_event,
|
get_tickets_by_event,
|
||||||
get_tickets_by_payment_hash,
|
get_tickets_by_payment_hash,
|
||||||
get_tickets_by_user_id,
|
get_tickets_by_user_id,
|
||||||
|
get_tickets_paginated,
|
||||||
purge_unpaid_tickets,
|
purge_unpaid_tickets,
|
||||||
update_event,
|
update_event,
|
||||||
update_settings,
|
update_settings,
|
||||||
|
|
@ -68,9 +74,13 @@ from .models import (
|
||||||
PublicEvent,
|
PublicEvent,
|
||||||
PublicTicket,
|
PublicTicket,
|
||||||
Ticket,
|
Ticket,
|
||||||
|
TicketFilters,
|
||||||
TicketPaymentRequest,
|
TicketPaymentRequest,
|
||||||
TicketResendResult,
|
TicketResendResult,
|
||||||
|
TicketWave,
|
||||||
effective_payment_methods,
|
effective_payment_methods,
|
||||||
|
ensure_ticket_waves,
|
||||||
|
get_active_ticket_waves,
|
||||||
)
|
)
|
||||||
from .nostr_hooks import publish_or_delete_nostr_event
|
from .nostr_hooks import publish_or_delete_nostr_event
|
||||||
from .promo import (
|
from .promo import (
|
||||||
|
|
@ -95,12 +105,16 @@ from .services import (
|
||||||
send_ticket_notification_in_background,
|
send_ticket_notification_in_background,
|
||||||
set_ticket_paid,
|
set_ticket_paid,
|
||||||
)
|
)
|
||||||
from .tasks import deregister_payment_listener, register_payment_listener
|
from .tasks import (
|
||||||
|
deregister_payment_listener,
|
||||||
|
register_payment_listener,
|
||||||
|
)
|
||||||
|
|
||||||
events_api_router = APIRouter(prefix="/api/v1/events")
|
events_api_router = APIRouter(prefix="/api/v1/events")
|
||||||
tickets_api_router = APIRouter(prefix="/api/v1/tickets")
|
tickets_api_router = APIRouter(prefix="/api/v1/tickets")
|
||||||
qr_api_router = APIRouter(prefix="/api/v1")
|
qr_api_router = APIRouter(prefix="/api/v1")
|
||||||
promo_api_router = APIRouter(prefix="/api/v1/promo")
|
promo_api_router = APIRouter(prefix="/api/v1/promo")
|
||||||
|
tickets_filters = parse_filters(TicketFilters)
|
||||||
|
|
||||||
|
|
||||||
def _is_fiat_currency(currency: str | None) -> bool:
|
def _is_fiat_currency(currency: str | None) -> bool:
|
||||||
|
|
@ -109,8 +123,6 @@ def _is_fiat_currency(currency: str | None) -> bool:
|
||||||
|
|
||||||
# Literal-prefix routes (/public, /all, /pending, /settings) MUST be declared
|
# Literal-prefix routes (/public, /all, /pending, /settings) MUST be declared
|
||||||
# before any "/{event_id}" route or FastAPI matches them as a path parameter.
|
# before any "/{event_id}" route or FastAPI matches them as a path parameter.
|
||||||
|
|
||||||
|
|
||||||
@events_api_router.get("")
|
@events_api_router.get("")
|
||||||
async def api_events(
|
async def api_events(
|
||||||
all_wallets: bool = Query(False),
|
all_wallets: bool = Query(False),
|
||||||
|
|
@ -273,28 +285,44 @@ async def api_get_event(event_id: str) -> Event:
|
||||||
closing_date = event.closing_date or event.event_end_date or event.event_start_date
|
closing_date = event.closing_date or event.event_end_date or event.event_start_date
|
||||||
# Accept either YYYY-MM-DD or full ISO 8601 datetime (event_end_date
|
# Accept either YYYY-MM-DD or full ISO 8601 datetime (event_end_date
|
||||||
# may carry a time component since v1.3.0-aio.3 / our start-end-time
|
# may carry a time component since v1.3.0-aio.3 / our start-end-time
|
||||||
# feature).
|
# feature). Upstream v1.6.8 parses closing_date with a bare
|
||||||
|
# strptime("%Y-%m-%d") here; that raises ValueError on any event of ours
|
||||||
|
# whose closing date carries a time, which is most of them.
|
||||||
try:
|
try:
|
||||||
closing_dt = datetime.fromisoformat(closing_date)
|
closing_dt = datetime.fromisoformat(closing_date)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
closing_dt = datetime.strptime(closing_date[:10], "%Y-%m-%d")
|
closing_dt = datetime.strptime(closing_date[:10], "%Y-%m-%d")
|
||||||
if closing_dt.tzinfo is None:
|
if closing_dt.tzinfo is None:
|
||||||
closing_dt = closing_dt.replace(tzinfo=timezone.utc)
|
closing_dt = closing_dt.replace(tzinfo=timezone.utc)
|
||||||
is_window_open = datetime.now(timezone.utc) < closing_dt
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
today = now.date()
|
||||||
|
active_waves = get_active_ticket_waves(event, today)
|
||||||
|
# `is_sales_closed` is upstream's name for `not is_window_open`; the
|
||||||
|
# comparison stays ours (instant-precise) rather than upstream's
|
||||||
|
# whole-day `today > closing_date.date()`. Upstream's form keeps sales
|
||||||
|
# open for the whole of the closing day, which for our datetime-bearing
|
||||||
|
# closing dates would extend every existing event's sales window.
|
||||||
|
is_sales_closed = now >= closing_dt
|
||||||
is_min_tickets_met = (
|
is_min_tickets_met = (
|
||||||
event.sold >= event.extra.min_tickets if event.extra.conditional else True
|
event.sold >= event.extra.min_tickets if event.extra.conditional else True
|
||||||
)
|
)
|
||||||
if event.amount_tickets < 1:
|
if event.amount_tickets < 1:
|
||||||
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
|
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
|
||||||
if event.extra.conditional and not is_min_tickets_met and not is_window_open:
|
if event.extra.conditional and not is_min_tickets_met and is_sales_closed:
|
||||||
event.canceled = True
|
event.canceled = True
|
||||||
await update_event(event)
|
await update_event(event)
|
||||||
await refund_tickets(event_id)
|
await refund_tickets(event_id)
|
||||||
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.")
|
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.")
|
||||||
|
|
||||||
if not is_window_open:
|
if not active_waves:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.GONE, detail="Ticket closing date has passed."
|
status_code=HTTPStatus.GONE,
|
||||||
|
detail=(
|
||||||
|
"Ticket closing date has passed."
|
||||||
|
if is_sales_closed
|
||||||
|
else "No ticket wave is currently open."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
return event
|
return event
|
||||||
|
|
@ -536,6 +564,31 @@ async def api_tickets_by_user(
|
||||||
return await get_tickets_by_user_id(user_id)
|
return await get_tickets_by_user_id(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@tickets_api_router.get(
|
||||||
|
"/paginated",
|
||||||
|
summary="Get paginated list of tickets",
|
||||||
|
openapi_extra=generate_filter_params_openapi(TicketFilters),
|
||||||
|
response_model=Page[Ticket],
|
||||||
|
)
|
||||||
|
async def api_tickets_paginated(
|
||||||
|
all_wallets: bool = Query(False),
|
||||||
|
filters: Filters = Depends(tickets_filters),
|
||||||
|
key_info: WalletTypeInfo = Depends(require_admin_key),
|
||||||
|
) -> Page[Ticket]:
|
||||||
|
wallet_ids = [key_info.wallet.id]
|
||||||
|
|
||||||
|
if all_wallets:
|
||||||
|
user = await get_user(key_info.wallet.user)
|
||||||
|
wallet_ids = user.wallet_ids if user else []
|
||||||
|
|
||||||
|
if not filters.sortby:
|
||||||
|
filters.sortby = "time"
|
||||||
|
if not filters.direction:
|
||||||
|
filters.direction = "desc"
|
||||||
|
|
||||||
|
return await get_tickets_paginated(wallet_ids, filters)
|
||||||
|
|
||||||
|
|
||||||
@tickets_api_router.get("/{ticket_id}", response_model=PublicTicket)
|
@tickets_api_router.get("/{ticket_id}", response_model=PublicTicket)
|
||||||
async def api_get_ticket(ticket_id: str) -> Ticket:
|
async def api_get_ticket(ticket_id: str) -> Ticket:
|
||||||
ticket = await get_ticket(ticket_id)
|
ticket = await get_ticket(ticket_id)
|
||||||
|
|
@ -597,6 +650,36 @@ def _resolve_frontend_root(data: CreateTicket, request: Request) -> str:
|
||||||
return data.frontend_url.rstrip("/")
|
return data.frontend_url.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_ticket_wave(event: Event, ticket_wave_id: str | None) -> TicketWave:
|
||||||
|
"""The wave a purchase or a price preview is priced against.
|
||||||
|
|
||||||
|
Shared by the purchase and promo-validate endpoints so the two cannot
|
||||||
|
drift: whatever wave the preview quoted is the wave the invoice charges.
|
||||||
|
Selection is upstream v1.6.8's — an explicit id must be an OPEN wave, a
|
||||||
|
single open wave is implied, and an ambiguous choice is an error rather
|
||||||
|
than a silent pick.
|
||||||
|
"""
|
||||||
|
active_waves = get_active_ticket_waves(event)
|
||||||
|
if not active_waves:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.GONE, detail="No ticket wave is currently open."
|
||||||
|
)
|
||||||
|
if ticket_wave_id:
|
||||||
|
wave = next((wave for wave in active_waves if wave.id == ticket_wave_id), None)
|
||||||
|
if not wave:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail="Invalid ticket wave selected.",
|
||||||
|
)
|
||||||
|
return wave
|
||||||
|
if len(active_waves) == 1:
|
||||||
|
return active_waves[0]
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail="Please select a ticket wave.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _issue_free_tickets(
|
async def _issue_free_tickets(
|
||||||
*,
|
*,
|
||||||
event: Event,
|
event: Event,
|
||||||
|
|
@ -607,6 +690,7 @@ async def _issue_free_tickets(
|
||||||
promo_code: str | None,
|
promo_code: str | None,
|
||||||
nostr_identifier: str | None,
|
nostr_identifier: str | None,
|
||||||
frontend_root: str,
|
frontend_root: str,
|
||||||
|
selected_wave: TicketWave,
|
||||||
) -> TicketPaymentRequest:
|
) -> TicketPaymentRequest:
|
||||||
"""Issue `quantity` free tickets without minting an invoice.
|
"""Issue `quantity` free tickets without minting an invoice.
|
||||||
|
|
||||||
|
|
@ -635,6 +719,11 @@ async def _issue_free_tickets(
|
||||||
ticket_id=row_id,
|
ticket_id=row_id,
|
||||||
extra={
|
extra={
|
||||||
"applied_promo_code": promo_code,
|
"applied_promo_code": promo_code,
|
||||||
|
# Free tickets consume wave stock exactly like paid ones —
|
||||||
|
# `set_ticket_paid` runs on this path too — so they must name
|
||||||
|
# their wave or the decrement lands on the primary wave.
|
||||||
|
"ticket_wave_id": selected_wave.id,
|
||||||
|
"ticket_wave_title": selected_wave.title,
|
||||||
"nostr_identifier": nostr_identifier,
|
"nostr_identifier": nostr_identifier,
|
||||||
"ticket_base_url": frontend_root,
|
"ticket_base_url": frontend_root,
|
||||||
"sats_paid": 0,
|
"sats_paid": 0,
|
||||||
|
|
@ -695,6 +784,11 @@ async def api_ticket_create(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail="Unsupported payment method.",
|
detail="Unsupported payment method.",
|
||||||
)
|
)
|
||||||
|
# npub or NIP-05, both normalised to a hex pubkey. v1.6.8 replaced this
|
||||||
|
# with a hard "Only NIP-05 Nostr identifiers are supported." rejection —
|
||||||
|
# true for upstream, false here: `_send_nostr_ticket_notification` sends
|
||||||
|
# bare pubkeys via `send_nostr_dm`. That rejection merged in outside any
|
||||||
|
# conflict marker, so it would have silently dropped npub checkout.
|
||||||
if nostr_identifier and "@" not in nostr_identifier:
|
if nostr_identifier and "@" not in nostr_identifier:
|
||||||
try:
|
try:
|
||||||
nostr_identifier = normalize_public_key(nostr_identifier)
|
nostr_identifier = normalize_public_key(nostr_identifier)
|
||||||
|
|
@ -703,6 +797,8 @@ async def api_ticket_create(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail="Invalid Nostr identifier.",
|
detail="Invalid Nostr identifier.",
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
selected_wave = _resolve_ticket_wave(event, data.ticket_wave_id)
|
||||||
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
|
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
|
||||||
frontend_root = _resolve_frontend_root(data, request)
|
frontend_root = _resolve_frontend_root(data, request)
|
||||||
|
|
||||||
|
|
@ -733,9 +829,18 @@ async def api_ticket_create(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
extra["promo_code"] = promo.code
|
extra["promo_code"] = promo.code
|
||||||
price = basket_totals(event, [promo.code], quantity, usage).total
|
# 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:
|
else:
|
||||||
price = round_amount(event.price_per_ticket * quantity, event.currency)
|
price = round_amount(
|
||||||
|
selected_wave.price_per_ticket * quantity, selected_wave.currency
|
||||||
|
)
|
||||||
|
|
||||||
# Free tickets (final charge 0 — a free event or a 100%-off promo).
|
# Free tickets (final charge 0 — a free event or a 100%-off promo).
|
||||||
# Short-circuit before any invoice / fiat-provider logic: no Lightning
|
# Short-circuit before any invoice / fiat-provider logic: no Lightning
|
||||||
|
|
@ -751,6 +856,16 @@ async def api_ticket_create(
|
||||||
promo_code=promo_code,
|
promo_code=promo_code,
|
||||||
nostr_identifier=nostr_identifier,
|
nostr_identifier=nostr_identifier,
|
||||||
frontend_root=frontend_root,
|
frontend_root=frontend_root,
|
||||||
|
selected_wave=selected_wave,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fiat is a per-wave opt-in since v1.6.8. `effective_payment_methods`
|
||||||
|
# below reads `event.allow_fiat`, which is only the PRIMARY wave's, so
|
||||||
|
# this check is what actually protects a non-fiat wave.
|
||||||
|
if payment_method == "fiat" and not selected_wave.allow_fiat:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail="Fiat payments are not enabled for this ticket wave.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Organizer-controlled rails (extra.payment_methods; legacy events fall
|
# Organizer-controlled rails (extra.payment_methods; legacy events fall
|
||||||
|
|
@ -762,23 +877,24 @@ async def api_ticket_create(
|
||||||
detail="Payment method not enabled for this event.",
|
detail="Payment method not enabled for this event.",
|
||||||
)
|
)
|
||||||
|
|
||||||
if _is_fiat_currency(event.currency):
|
if _is_fiat_currency(selected_wave.currency):
|
||||||
extra["fiat"] = True
|
extra["fiat"] = True
|
||||||
extra["currency"] = event.currency
|
extra["currency"] = selected_wave.currency
|
||||||
extra["fiatAmount"] = price
|
extra["fiatAmount"] = price
|
||||||
extra["rate"] = await get_fiat_rate_satoshis(event.currency)
|
extra["rate"] = await get_fiat_rate_satoshis(selected_wave.currency)
|
||||||
|
|
||||||
if payment_method != "fiat":
|
if payment_method != "fiat":
|
||||||
price = await fiat_amount_as_satoshis(price, event.currency)
|
price = await fiat_amount_as_satoshis(price, selected_wave.currency)
|
||||||
|
|
||||||
invoice_unit = event.currency
|
invoice_unit = selected_wave.currency
|
||||||
fiat_amount = price
|
fiat_amount = price
|
||||||
fiat_provider = None
|
fiat_provider = None
|
||||||
|
|
||||||
if payment_method == "fiat":
|
if payment_method == "fiat":
|
||||||
if _is_fiat_currency(event.currency):
|
if _is_fiat_currency(selected_wave.currency):
|
||||||
invoice_unit = event.currency
|
invoice_unit = selected_wave.currency
|
||||||
else:
|
else:
|
||||||
invoice_unit = event.fiat_currency
|
invoice_unit = selected_wave.fiat_currency
|
||||||
fiat_amount = await satoshis_amount_as_fiat(price, invoice_unit)
|
fiat_amount = await satoshis_amount_as_fiat(price, invoice_unit)
|
||||||
extra["fiat"] = True
|
extra["fiat"] = True
|
||||||
extra["currency"] = invoice_unit
|
extra["currency"] = invoice_unit
|
||||||
|
|
@ -854,6 +970,12 @@ async def api_ticket_create(
|
||||||
ticket_id=row_id,
|
ticket_id=row_id,
|
||||||
extra={
|
extra={
|
||||||
"applied_promo_code": promo_code,
|
"applied_promo_code": promo_code,
|
||||||
|
# Which wave this ticket came from. `set_ticket_paid` debits
|
||||||
|
# the wave named here and falls back to waves[0] when it is
|
||||||
|
# absent, so omitting it would take every sale off the
|
||||||
|
# primary wave no matter what the buyer bought.
|
||||||
|
"ticket_wave_id": selected_wave.id,
|
||||||
|
"ticket_wave_title": selected_wave.title,
|
||||||
"refund_address": refund_address,
|
"refund_address": refund_address,
|
||||||
"nostr_identifier": nostr_identifier,
|
"nostr_identifier": nostr_identifier,
|
||||||
"ticket_base_url": frontend_root,
|
"ticket_base_url": frontend_root,
|
||||||
|
|
@ -957,7 +1079,9 @@ async def api_ticket_delete(
|
||||||
|
|
||||||
@tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult)
|
@tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult)
|
||||||
async def api_ticket_resend_email(
|
async def api_ticket_resend_email(
|
||||||
ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
ticket_id: str,
|
||||||
|
request: Request,
|
||||||
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> TicketResendResult:
|
) -> TicketResendResult:
|
||||||
ticket = await get_ticket(ticket_id)
|
ticket = await get_ticket(ticket_id)
|
||||||
if not ticket:
|
if not ticket:
|
||||||
|
|
@ -975,16 +1099,13 @@ async def api_ticket_resend_email(
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return await resend_ticket_email_notification(ticket)
|
return await resend_ticket_email_notification(
|
||||||
|
ticket, str(request.base_url).rstrip("/")
|
||||||
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
|
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
|
||||||
) from exc
|
) from exc
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Failed to resend ticket email.",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@tickets_api_router.put("/register/{ticket_id}")
|
@tickets_api_router.put("/register/{ticket_id}")
|
||||||
|
|
@ -1095,28 +1216,76 @@ async def api_event_ticket_stats(
|
||||||
|
|
||||||
@qr_api_router.get("/qr/{ticket_id}", response_class=StreamingResponse)
|
@qr_api_router.get("/qr/{ticket_id}", response_class=StreamingResponse)
|
||||||
async def api_ticket_qr(ticket_id: str):
|
async def api_ticket_qr(ticket_id: str):
|
||||||
"""PNG of the ticket's scan payload (`ticket://<id>`), branded with the
|
"""PNG of the ticket's scan payload (`ticket://<id>`).
|
||||||
instance QR logo. Anonymous by design — it is what the ticket email
|
|
||||||
embeds — and the id is the same bearer token the ticket page exposes.
|
Two shapes behind one route. Before the v1.6.8 merge this endpoint
|
||||||
Port of upstream v1.6.8 without ticket-image compositing."""
|
existed on both sides — ours was "upstream's, without ticket-image
|
||||||
|
compositing", theirs added the compositing but dropped the logo. They
|
||||||
|
are the same endpoint, so they are merged rather than registered twice
|
||||||
|
(FastAPI serves whichever route is declared first, so the second copy
|
||||||
|
would have been silently unreachable):
|
||||||
|
|
||||||
|
- wave opted into `use_ticket_image`: upstream's composite — the QR
|
||||||
|
pasted onto the organiser's uploaded template, or the bundled
|
||||||
|
default. QR size and paste coordinates are upstream's and are tied
|
||||||
|
to each other; no logo, because the template carries the branding.
|
||||||
|
- otherwise: our standalone QR at 300px with the instance logo.
|
||||||
|
|
||||||
|
Anonymous by design — it is what the ticket email links to — and the id
|
||||||
|
is the same bearer token the ticket page exposes.
|
||||||
|
"""
|
||||||
ticket = await get_ticket(ticket_id)
|
ticket = await get_ticket(ticket_id)
|
||||||
if not ticket:
|
if not ticket:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
|
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
|
||||||
)
|
)
|
||||||
|
event = await get_event(ticket.event)
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
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],
|
||||||
|
)
|
||||||
|
|
||||||
|
if not wave.use_ticket_image:
|
||||||
logo = await load_qr_logo()
|
logo = await load_qr_logo()
|
||||||
image = make_qr_png(f"ticket://{ticket_id}", size=300, logo=logo)
|
image = make_qr_png(f"ticket://{ticket_id}", size=300, logo=logo)
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
BytesIO(image_png_bytes(image)),
|
BytesIO(image_png_bytes(image)),
|
||||||
media_type="image/png",
|
media_type="image/png",
|
||||||
headers={
|
headers=headers,
|
||||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
||||||
"Pragma": "no-cache",
|
|
||||||
"Expires": "0",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
background_bytes = None
|
||||||
|
if wave.ticket_image_id:
|
||||||
|
asset = await get_public_asset(wave.ticket_image_id)
|
||||||
|
if asset:
|
||||||
|
background_bytes = asset.data
|
||||||
|
|
||||||
|
if background_bytes:
|
||||||
|
ticket_image = Image.open(BytesIO(background_bytes)).convert("RGBA")
|
||||||
|
else:
|
||||||
|
default_template = (
|
||||||
|
Path(__file__).resolve().parent / "static" / "image" / "ticket.jpg"
|
||||||
|
)
|
||||||
|
ticket_image = Image.open(default_template).convert("RGBA")
|
||||||
|
|
||||||
|
ticket_image.paste(make_qr_png(f"ticket://{ticket_id}", size=157), (122, 505))
|
||||||
|
output = BytesIO()
|
||||||
|
ticket_image.save(output, format="PNG")
|
||||||
|
output.seek(0)
|
||||||
|
return StreamingResponse(output, media_type="image/png", headers=headers)
|
||||||
|
|
||||||
|
|
||||||
@qr_api_router.get("/ticket-card/{ticket_id}", response_class=StreamingResponse)
|
@qr_api_router.get("/ticket-card/{ticket_id}", response_class=StreamingResponse)
|
||||||
async def api_ticket_card(ticket_id: str):
|
async def api_ticket_card(ticket_id: str):
|
||||||
|
|
@ -1162,4 +1331,7 @@ async def api_validate_promo_codes(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
||||||
)
|
)
|
||||||
usage = await event_promo_usage(event_id) if event.extra.promo_codes else {}
|
usage = await event_promo_usage(event_id) if event.extra.promo_codes else {}
|
||||||
return basket_totals(event, data.codes, data.quantity, usage)
|
# 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