Merge upstream v1.6.8 into the aio fork
Brings in ticket waves (per-wave price/currency/stock/fiat), the paginated ticket endpoint, the organiser ticket-image template, and the SatsPay on-chain surface. Refs #33. Resolutions that were not mechanical, and why: - set_ticket_paid debits the wave named on the ticket, keeping upstream's `> 0` guards; ours decremented unconditionally and could go negative. The purchase and free-ticket paths now stamp ticket_wave_id / ticket_wave_title, without which every sale would debit the primary wave. - Pricing moved onto the selected wave (basket_totals takes it as a required argument, the promo-validate endpoint resolves the same wave through the shared _resolve_ticket_wave). event.price_per_ticket is a roll-up of the PRIMARY wave since sync_event_ticket_waves, so pricing off the event quoted and charged the first wave's price to buyers who picked a later one. Regression test added. - Kept npub support in two places upstream removed it: the purchase endpoint's normalize_public_key path and the notification dispatcher. Upstream's replacement rejects with "Only NIP-05 Nostr identifiers are supported", which is false for this fork. The purchase-side rejection had merged in outside any conflict marker. - _ticket_image_url existed on both sides as two unrelated features. Ours (always-attached rendered QR card) is now _ticket_card_url; upstream's (organiser template, opt-in per wave) keeps the name. Both are wired into the mail, and the /qr/{ticket_id} endpoint — also duplicated on both sides, on the same route — is merged into one handler rather than registered twice, where the second copy would have been unreachable. - models._parse_date now accepts a full ISO datetime. Upstream's date-only strptime raised ValueError on any event whose closing_date carries a time, which create_event produces by defaulting it from event_end_date — it would have 500'd the purchase path, the public event gate and the promo preview. Reproduced before fixing. - Dropped upstream's inline make_qr_png (we import a superset from .qr) and its duplicate paymentMethodOptions in display.js, which re-derived payment options from per-method booleans and offered an on-chain option the backend rejects; the submit gate now matches the template's condition. - Restored imports the merge silently dropped with upstream's npub removal (normalize_public_key, normalize_private_key, DEFAULT_NOSTR_RELAYS). Event create/update stays ours: upstream's combined endpoint would have replaced the approval workflow and the explicit field allowlist that keeps `status` out of the request body. mypy error set is unchanged from HEAD; ruff, black and the 97 tests pass.
This commit is contained in:
commit
ae5affa44f
14 changed files with 2228 additions and 275 deletions
82
crud.py
82
crud.py
|
|
@ -1,10 +1,19 @@
|
|||
import json
|
||||
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 .models import CreateEvent, Event, EventsSettings, Ticket, TicketExtra
|
||||
from .models import (
|
||||
CreateEvent,
|
||||
Event,
|
||||
EventsSettings,
|
||||
Ticket,
|
||||
TicketExtra,
|
||||
TicketFilters,
|
||||
sync_event_ticket_waves,
|
||||
)
|
||||
|
||||
db = Database("ext_events")
|
||||
|
||||
|
|
@ -155,6 +164,31 @@ async def get_tickets_by_user_id(user_id: str) -> list[Ticket]:
|
|||
return [Ticket(**_parse_ticket_row(row)) for row in rows]
|
||||
|
||||
|
||||
async def get_tickets_paginated(
|
||||
wallet_ids: str | list[str], filters: Filters[TicketFilters] | None = None
|
||||
) -> Page[Ticket]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
wallet_where = []
|
||||
values = {}
|
||||
for idx, wallet_id in enumerate(wallet_ids):
|
||||
key = f"wallet_id_{idx}"
|
||||
wallet_where.append(f":{key}")
|
||||
values[key] = wallet_id
|
||||
|
||||
where = [f"wallet IN ({', '.join(wallet_where)})", "paid = true"]
|
||||
|
||||
return await db.fetch_page(
|
||||
"SELECT * FROM events.ticket",
|
||||
where=where,
|
||||
values=values,
|
||||
filters=filters,
|
||||
model=Ticket,
|
||||
table_name="events.ticket",
|
||||
)
|
||||
|
||||
|
||||
async def delete_ticket(payment_hash: str) -> None:
|
||||
await db.execute("DELETE FROM events.ticket WHERE id = :id", {"id": payment_hash})
|
||||
|
||||
|
|
@ -184,44 +218,55 @@ async def create_event(data: CreateEvent) -> Event:
|
|||
if not data.closing_date:
|
||||
data.closing_date = data.event_end_date
|
||||
event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict())
|
||||
event = cast(Event, sync_event_ticket_waves(event))
|
||||
await db.insert("events.events", event)
|
||||
return event
|
||||
|
||||
|
||||
async def update_event(event: Event) -> Event:
|
||||
event = cast(Event, sync_event_ticket_waves(event))
|
||||
await db.update("events.events", event)
|
||||
return event
|
||||
|
||||
|
||||
async def get_event(event_id: str) -> Event | None:
|
||||
return await db.fetchone(
|
||||
event = await db.fetchone(
|
||||
"SELECT * FROM events.events WHERE id = :id",
|
||||
{"id": event_id},
|
||||
Event,
|
||||
)
|
||||
return cast(Event, sync_event_ticket_waves(event)) if event else None
|
||||
|
||||
|
||||
async def get_events(wallet_ids: str | list[str]) -> list[Event]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids])
|
||||
return await db.fetchall(
|
||||
events = await db.fetchall(
|
||||
f"SELECT * FROM events.events WHERE wallet IN ({q})",
|
||||
model=Event,
|
||||
)
|
||||
return [cast(Event, sync_event_ticket_waves(event)) for event in events]
|
||||
|
||||
|
||||
async def get_all_events() -> list[Event]:
|
||||
"""All events, no wallet filter. Admin-only callers."""
|
||||
return await db.fetchall(
|
||||
events = await db.fetchall(
|
||||
"SELECT * FROM events.events ORDER BY time DESC",
|
||||
model=Event,
|
||||
)
|
||||
# Wave-sync on read, exactly as upstream's `get_event` / `get_events`
|
||||
# do. These four getters are fork-only, so upstream's v1.6.8 diff
|
||||
# never reached them — and two of them publish: `get_all_events`
|
||||
# backs /republish-all and `get_events_pending_republish` drives the
|
||||
# #55 sweep. Without this they would emit the stale roll-up rather
|
||||
# than the current per-wave figures.
|
||||
return [cast(Event, sync_event_ticket_waves(e)) for e in events]
|
||||
|
||||
|
||||
async def get_public_events() -> list[Event]:
|
||||
"""Approved, non-canceled events for the public listing."""
|
||||
return await db.fetchall(
|
||||
events = await db.fetchall(
|
||||
"""
|
||||
SELECT * FROM events.events
|
||||
WHERE status = 'approved' AND canceled = FALSE
|
||||
|
|
@ -229,14 +274,28 @@ async def get_public_events() -> list[Event]:
|
|||
""",
|
||||
model=Event,
|
||||
)
|
||||
# Wave-sync on read, exactly as upstream's `get_event` / `get_events`
|
||||
# do. These four getters are fork-only, so upstream's v1.6.8 diff
|
||||
# never reached them — and two of them publish: `get_all_events`
|
||||
# backs /republish-all and `get_events_pending_republish` drives the
|
||||
# #55 sweep. Without this they would emit the stale roll-up rather
|
||||
# than the current per-wave figures.
|
||||
return [cast(Event, sync_event_ticket_waves(e)) for e in events]
|
||||
|
||||
|
||||
async def get_pending_events() -> list[Event]:
|
||||
"""Proposed events awaiting admin approval."""
|
||||
return await db.fetchall(
|
||||
events = await db.fetchall(
|
||||
"SELECT * FROM events.events WHERE status = 'proposed' ORDER BY time DESC",
|
||||
model=Event,
|
||||
)
|
||||
# Wave-sync on read, exactly as upstream's `get_event` / `get_events`
|
||||
# do. These four getters are fork-only, so upstream's v1.6.8 diff
|
||||
# never reached them — and two of them publish: `get_all_events`
|
||||
# backs /republish-all and `get_events_pending_republish` drives the
|
||||
# #55 sweep. Without this they would emit the stale roll-up rather
|
||||
# than the current per-wave figures.
|
||||
return [cast(Event, sync_event_ticket_waves(e)) for e in events]
|
||||
|
||||
|
||||
async def get_events_pending_republish() -> list[Event]:
|
||||
|
|
@ -249,7 +308,7 @@ async def get_events_pending_republish() -> list[Event]:
|
|||
|
||||
Ordered oldest-first so a backlog drains in the order it accrued.
|
||||
"""
|
||||
return await db.fetchall(
|
||||
events = await db.fetchall(
|
||||
"""
|
||||
SELECT * FROM events.events
|
||||
WHERE nostr_publish_pending = TRUE
|
||||
|
|
@ -257,6 +316,13 @@ async def get_events_pending_republish() -> list[Event]:
|
|||
""",
|
||||
model=Event,
|
||||
)
|
||||
# Wave-sync on read, exactly as upstream's `get_event` / `get_events`
|
||||
# do. These four getters are fork-only, so upstream's v1.6.8 diff
|
||||
# never reached them — and two of them publish: `get_all_events`
|
||||
# backs /republish-all and `get_events_pending_republish` drives the
|
||||
# #55 sweep. Without this they would emit the stale roll-up rather
|
||||
# than the current per-wave figures.
|
||||
return [cast(Event, sync_event_ticket_waves(e)) for e in events]
|
||||
|
||||
|
||||
async def get_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`
|
||||
145
models.py
145
models.py
|
|
@ -1,7 +1,9 @@
|
|||
import json
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
from urllib.parse import urlsplit
|
||||
from uuid import uuid4
|
||||
|
||||
from lnbits.db import FilterModel
|
||||
from pydantic import BaseModel, EmailStr, Field, root_validator, validator
|
||||
|
||||
PAYMENT_METHODS = ("lightning", "fiat")
|
||||
|
|
@ -41,6 +43,20 @@ class PromoCode(BaseModel):
|
|||
return v
|
||||
|
||||
|
||||
class TicketWave(BaseModel):
|
||||
id: str = Field(default_factory=lambda: uuid4().hex[:8])
|
||||
title: str = "Primary wave"
|
||||
opening_date: str
|
||||
closing_date: str
|
||||
currency: str = "sat"
|
||||
use_ticket_image: bool = False
|
||||
ticket_image_id: str | None = None
|
||||
allow_fiat: bool = False
|
||||
fiat_currency: str = "GBP"
|
||||
amount_tickets: int = Field(default=0, ge=0)
|
||||
price_per_ticket: float = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class EventExtraBase(BaseModel):
|
||||
"""Everything in `extra` that is safe to show anyone. `EventExtra` adds
|
||||
the organizer-only promo codes on top; `PublicEventExtra` is this base,
|
||||
|
|
@ -76,6 +92,20 @@ class EventExtraBase(BaseModel):
|
|||
|
||||
class EventExtra(EventExtraBase):
|
||||
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)
|
||||
# Upstream's SatsPay-backed on-chain config. Removed in the commit
|
||||
# that follows this merge — we use native lnbits on-chain instead
|
||||
# (aiolabs/events#41). Kept here so the removal is a legible diff
|
||||
# against what upstream shipped rather than an invisible omission.
|
||||
onchain_enabled: bool = False
|
||||
onchain_wallet_id: str | None = None
|
||||
onchain_zeroconf: bool = False
|
||||
onchain_fasttrack: bool = False
|
||||
|
||||
|
||||
PublicEventExtra = EventExtraBase
|
||||
|
|
@ -181,11 +211,17 @@ def effective_payment_methods(event: "Event | PublicEvent | CreateEvent") -> lis
|
|||
|
||||
|
||||
class PromoValidateRequest(BaseModel):
|
||||
"""Upstream v2 shape. v2 sends `items` (ticket types); this fork has one
|
||||
price per event, so a plain `quantity` replaces it."""
|
||||
"""Upstream v2 shape. v2 sends `items` (ticket types); this fork prices a
|
||||
plain `quantity` against one ticket wave.
|
||||
|
||||
`ticket_wave_id` may be omitted when exactly one wave is open, matching
|
||||
how the purchase endpoint resolves it — the preview has to price the same
|
||||
wave the invoice will, and since v1.6.8 price and currency are per-wave.
|
||||
"""
|
||||
|
||||
codes: list[str] = Field(default_factory=list)
|
||||
quantity: int = Field(default=1, ge=1, le=10)
|
||||
ticket_wave_id: str | None = None
|
||||
|
||||
|
||||
class BasketDiscount(BaseModel):
|
||||
|
|
@ -211,6 +247,8 @@ class EventsSettings(BaseModel):
|
|||
|
||||
class TicketExtra(BaseModel):
|
||||
applied_promo_code: str | None = None
|
||||
ticket_wave_id: str | None = None
|
||||
ticket_wave_title: str | None = None
|
||||
sats_paid: int | None = None
|
||||
refund_address: str | None = None
|
||||
nostr_identifier: str | None = None
|
||||
|
|
@ -218,12 +256,16 @@ class TicketExtra(BaseModel):
|
|||
email_notification_sent: bool = False
|
||||
nostr_notification_sent: bool = False
|
||||
refunded: bool = False
|
||||
onchain: bool = False
|
||||
onchain_address: str | None = None
|
||||
satspay_charge_id: str | None = None
|
||||
|
||||
|
||||
class CreateTicket(BaseModel):
|
||||
name: str | None = None
|
||||
email: EmailStr | None = None
|
||||
user_id: str | None = None # LNbits user id (alternative to name+email)
|
||||
ticket_wave_id: str | None = None
|
||||
promo_code: str | None = None
|
||||
refund_address: str | None = None
|
||||
nostr_identifier: str | None = None
|
||||
|
|
@ -325,3 +367,100 @@ class TicketPaymentRequest(BaseModel):
|
|||
# the door). Buyers fetch these after payment to render N QRs in
|
||||
# My Tickets.
|
||||
ticket_ids: list[str] = Field(default_factory=list)
|
||||
onchain_amount_sat: int | None = None
|
||||
satspay_charge_url: str | 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 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
|
||||
]
|
||||
|
|
|
|||
17
promo.py
17
promo.py
|
|
@ -11,7 +11,7 @@ from __future__ import annotations
|
|||
|
||||
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")
|
||||
|
||||
|
|
@ -66,17 +66,26 @@ def basket_totals(
|
|||
codes: list[str],
|
||||
quantity: int,
|
||||
usage: dict[str, int],
|
||||
wave: TicketWave,
|
||||
) -> 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
|
||||
for the whole quantity, and actually saves something. Anything else is
|
||||
simply absent from `discounts_applied` (upstream v2 semantics — the
|
||||
purchase endpoint is where hard errors are raised). Only one code is
|
||||
applied; v2's `combinable` stacking is out of scope here.
|
||||
|
||||
`wave` is the pricing authority, not `event`. Since v1.6.8 a ticket's
|
||||
price and currency belong to the wave it is bought from, and the
|
||||
event-level fields are a derived roll-up of the PRIMARY wave
|
||||
(`sync_event_ticket_waves`) — pricing off `event` would quote and charge
|
||||
the first wave's price to a buyer who picked a later one. It is a
|
||||
required argument rather than an optional override precisely because
|
||||
that failure is silent: both call sites have to name the wave.
|
||||
"""
|
||||
currency = event.currency or "sat"
|
||||
subtotal = round_amount(event.price_per_ticket * quantity, currency)
|
||||
currency = wave.currency or "sat"
|
||||
subtotal = round_amount(wave.price_per_ticket * quantity, currency)
|
||||
totals = BasketTotals(
|
||||
subtotal=subtotal, discount=0, total=subtotal, currency=currency
|
||||
)
|
||||
|
|
|
|||
182
services.py
182
services.py
|
|
@ -9,7 +9,9 @@ from email.mime.multipart import MIMEMultipart
|
|||
from email.mime.text import MIMEText
|
||||
from email.utils import formataddr, formatdate, make_msgid
|
||||
from html import escape
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from lnbits.core.models.users import UserNotifications
|
||||
from lnbits.core.services.nostr import send_nostr_dm
|
||||
from lnbits.core.services.notifications import send_user_notification
|
||||
|
|
@ -26,7 +28,13 @@ from .crud import (
|
|||
update_event,
|
||||
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 .promo import promo_usage
|
||||
from .qr import (
|
||||
|
|
@ -37,6 +45,59 @@ from .qr import (
|
|||
ticket_card_filename,
|
||||
)
|
||||
|
||||
|
||||
async def fetch_watchonly_config(api_key: str) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/config",
|
||||
headers={"X-API-KEY": api_key},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def fetch_watchonly_wallets(api_key: str, network: str) -> list[dict[str, Any]]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/wallet",
|
||||
headers={"X-API-KEY": api_key},
|
||||
params={"network": network},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def fetch_watchonly_wallet(api_key: str, wallet_id: str) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/wallet/{wallet_id}",
|
||||
headers={"X-API-KEY": api_key},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def get_satspay_charge(api_key: str, charge_id: str) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
url=f"http://{settings.host}:{settings.port}/satspay/api/v1/charge/{charge_id}",
|
||||
headers={"X-API-KEY": api_key},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def create_satspay_charge(api_key: str, data: dict) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
url=f"http://{settings.host}:{settings.port}/satspay/api/v1/charge",
|
||||
headers={"X-API-KEY": api_key},
|
||||
json=data,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
DEFAULT_NOSTR_RELAYS = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
|
|
@ -70,7 +131,25 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket:
|
|||
event = await get_event(ticket.event)
|
||||
assert event, "Couldn't get event from ticket being paid"
|
||||
event.sold += 1
|
||||
event.amount_tickets -= 1
|
||||
# Debit the wave the buyer actually bought from. v1.6.8 moved
|
||||
# inventory onto waves; the event-level counter is only the
|
||||
# fallback for events that predate them, and is itself a derived
|
||||
# roll-up (`sync_event_ticket_waves`). Upstream's `> 0` guards are
|
||||
# kept — ours decremented unconditionally and could go negative.
|
||||
ticket_waves = event.extra.ticket_waves or []
|
||||
if ticket_waves:
|
||||
selected_wave = next(
|
||||
(
|
||||
wave
|
||||
for wave in ticket_waves
|
||||
if wave.id == ticket.extra.ticket_wave_id
|
||||
),
|
||||
ticket_waves[0],
|
||||
)
|
||||
if selected_wave.amount_tickets > 0:
|
||||
selected_wave.amount_tickets -= 1
|
||||
elif event.amount_tickets > 0:
|
||||
event.amount_tickets -= 1
|
||||
# Flag inside this same write: the counters and "the relay does
|
||||
# not know about them yet" land atomically, so a crash between
|
||||
# here and the publish still leaves the drift discoverable.
|
||||
|
|
@ -116,10 +195,17 @@ async def _send_ticket_notification(ticket: Ticket) -> None:
|
|||
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
|
||||
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)
|
||||
if not event:
|
||||
raise ValueError("Event does not exist.")
|
||||
|
|
@ -127,7 +213,12 @@ async def resend_ticket_email_notification(ticket: Ticket) -> TicketResendResult
|
|||
raise ValueError("Email notifications are not enabled.")
|
||||
if not ticket.email:
|
||||
raise ValueError("Ticket does not have an email address.")
|
||||
if base_url:
|
||||
ticket.extra.ticket_base_url = base_url.rstrip("/")
|
||||
|
||||
# email-only: this is the *email* resend endpoint. Upstream calls
|
||||
# `_deliver_ticket_notifications(ticket, event)` unqualified, which in
|
||||
# our fork would also fire a Nostr DM the organiser did not ask for.
|
||||
return await _deliver_ticket_notifications(ticket, event, email=True, nostr=False)
|
||||
|
||||
|
||||
|
|
@ -163,17 +254,35 @@ def _ticket_details(ticket: Ticket, event: Event) -> 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"\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:
|
||||
"""HTML twin of the text part. Deliberately no <img>: the card travels
|
||||
as an attachment (renders inline in most clients, works offline, and
|
||||
keeps SpamAssassin's HTML_IMAGE_ONLY rules quiet), and URLs become
|
||||
links."""
|
||||
"""HTML twin of the text part.
|
||||
|
||||
Deliberately no <img> for our own ticket card: it travels as an
|
||||
attachment (renders inline in most clients, works offline, and keeps
|
||||
SpamAssassin's HTML_IMAGE_ONLY rules quiet), and its URL becomes a link.
|
||||
The organiser's uploaded ticket image is a remote URL with no attachment
|
||||
to fall back on, so that one does get upstream's <img> — the surrounding
|
||||
ticket details keep the mail from being image-only either way.
|
||||
"""
|
||||
text_message = _ticket_delivery_message(ticket, event, base_message)
|
||||
html = escape(text_message)
|
||||
html = re.sub(
|
||||
|
|
@ -181,7 +290,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>',
|
||||
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]:
|
||||
|
|
@ -200,7 +319,16 @@ async def _deliver_ticket_notifications(
|
|||
) -> TicketResendResult:
|
||||
"""Send the ticket by every configured channel and report per-channel
|
||||
outcome (upstream v1.6.8 shape). `email` / `nostr` override the event's
|
||||
opt-ins when not None; the instance-level prerequisites always apply."""
|
||||
opt-ins when not None; the instance-level prerequisites always apply.
|
||||
|
||||
Upstream v1.6.8 guards the nostr branch with a `_supports_nostr_delivery`
|
||||
check that errors with "Only NIP-05 Nostr identifiers are supported."
|
||||
That guard is NOT taken here: it encodes upstream's limitation, not ours.
|
||||
`_send_nostr_ticket_notification` below dispatches NIP-05 identifiers to
|
||||
core's notifier and bare npubs to `send_nostr_dm`, so adopting the guard
|
||||
would silently refuse identifiers we deliver to today — and say so with
|
||||
a message that would be false for this fork.
|
||||
"""
|
||||
subject, text_message, html_message = _ticket_notification_payload(ticket, event)
|
||||
updated = False
|
||||
|
||||
|
|
@ -376,16 +504,36 @@ def _ticket_url(ticket: Ticket) -> str:
|
|||
return f"{base_url}/events/ticket/{ticket.id}"
|
||||
|
||||
|
||||
def _ticket_image_url(ticket: Ticket) -> str:
|
||||
"""The ticket card PNG is served by THIS extension on the LNbits host, so
|
||||
it is built from `lnbits_baseurl` even when `ticket_base_url` points at
|
||||
a separate web app (deviation from upstream, which assumes both are the
|
||||
same host)."""
|
||||
def _ticket_card_url(ticket: Ticket) -> str:
|
||||
"""Our rendered ticket-card PNG — always available, and served by THIS
|
||||
extension on the LNbits host, so it is built from `lnbits_baseurl` even
|
||||
when `ticket_base_url` points at a separate web app (deviation from
|
||||
upstream, which assumes both are the same host)."""
|
||||
return (
|
||||
f"{settings.lnbits_baseurl.rstrip('/')}/events/api/v1/ticket-card/{ticket.id}"
|
||||
)
|
||||
|
||||
|
||||
def _ticket_image_url(ticket: Ticket, event: Event) -> str | None:
|
||||
"""Upstream's organiser-uploaded ticket template, opted into per wave.
|
||||
|
||||
Distinct from `_ticket_card_url`: that is our own rendered card and is
|
||||
always attached, this is a template the organiser uploads and enables
|
||||
on a specific wave. They shared a name before the v1.6.8 merge, which
|
||||
made them look like one feature.
|
||||
"""
|
||||
waves = ensure_ticket_waves(event)
|
||||
wave = next(
|
||||
(wave for wave in waves if wave.id == ticket.extra.ticket_wave_id),
|
||||
waves[0],
|
||||
)
|
||||
if not wave.use_ticket_image:
|
||||
return None
|
||||
|
||||
base_url = (ticket.extra.ticket_base_url or settings.lnbits_baseurl).rstrip("/")
|
||||
return f"{base_url}/events/api/v1/qr/{ticket.id}"
|
||||
|
||||
|
||||
async def refund_tickets(event_id: str):
|
||||
"""
|
||||
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: '',
|
||||
refund: '',
|
||||
nostr_identifier: '',
|
||||
ticket_wave_id: null,
|
||||
payment_method: 'lightning'
|
||||
}
|
||||
},
|
||||
|
|
@ -46,26 +47,68 @@ window.PageEventsDisplay = {
|
|||
paymentMethods() {
|
||||
// Mirrors `effective_payment_methods` on the backend: an explicit
|
||||
// extra.payment_methods list wins, else Lightning + fiat-if-allow_fiat.
|
||||
// Since v1.6.8 `allow_fiat` is a per-wave flag and `event.allow_fiat`
|
||||
// is only the PRIMARY wave's, so prefer the active wave when there is
|
||||
// one — otherwise a later fiat-enabled wave would not offer fiat.
|
||||
const explicit = this.event?.extra?.payment_methods || []
|
||||
if (explicit.length) return explicit
|
||||
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() {
|
||||
return this.paymentMethods.includes('fiat')
|
||||
},
|
||||
paymentMethodOptions() {
|
||||
// Options come from `paymentMethods` — the same list the backend
|
||||
// validates against in `effective_payment_methods` — rather than being
|
||||
// re-derived from per-method booleans. v1.6.8 added a second copy of
|
||||
// this computed that built the list from `allow_fiat`/`onchain_enabled`
|
||||
// instead; because it was defined later in the object it silently won
|
||||
// the merge, and it offers a "Bitcoin" option for any event with
|
||||
// `extra.onchain_enabled` even though the backend rejects `onchain`
|
||||
// unless it is in `extra.payment_methods` (views_api: "not in
|
||||
// effective_payment_methods"). Labels below are upstream's; the source
|
||||
// of the list is ours.
|
||||
const labels = {
|
||||
lightning: 'Lightning',
|
||||
fiat: this.fiatCheckoutLabel,
|
||||
onchain: 'Bitcoin'
|
||||
}
|
||||
return this.paymentMethods.map(method => ({
|
||||
value: method,
|
||||
label: method === 'fiat' ? this.fiatCheckoutLabel : 'Lightning'
|
||||
label: labels[method] || method
|
||||
}))
|
||||
},
|
||||
fiatCheckoutLabel() {
|
||||
if (!this.allowFiatCheckout) return 'Fiat'
|
||||
const unit = ['sat', 'sats'].includes(
|
||||
(this.event?.currency || '').toLowerCase()
|
||||
(this.selectedTicketWave?.currency || '').toLowerCase()
|
||||
)
|
||||
? this.event?.fiat_currency
|
||||
: this.event?.currency
|
||||
? this.selectedTicketWave?.fiat_currency
|
||||
: this.selectedTicketWave?.currency
|
||||
return `Fiat (${(unit || 'GBP').toUpperCase()})`
|
||||
},
|
||||
allowEmailNotifications() {
|
||||
|
|
@ -82,6 +125,16 @@ window.PageEventsDisplay = {
|
|||
'GET',
|
||||
`/events/api/v1/events/${this.eventId}`
|
||||
)
|
||||
const activeWaves = (data.extra?.ticket_waves || []).filter(wave => {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
return (
|
||||
wave.amount_tickets > 0 &&
|
||||
wave.opening_date <= today &&
|
||||
wave.closing_date >= today
|
||||
)
|
||||
})
|
||||
this.formDialog.data.ticket_wave_id =
|
||||
activeWaves.length === 1 ? activeWaves[0].id : null
|
||||
return data
|
||||
} catch (error) {
|
||||
this.eventErrorLabel = 'Event unavailable.'
|
||||
|
|
@ -94,6 +147,10 @@ window.PageEventsDisplay = {
|
|||
this.formDialog.data.email = ''
|
||||
this.formDialog.data.refund = ''
|
||||
this.formDialog.data.nostr_identifier = ''
|
||||
this.formDialog.data.ticket_wave_id =
|
||||
this.activeTicketWaves.length === 1
|
||||
? this.activeTicketWaves[0].id
|
||||
: null
|
||||
this.formDialog.data.promo_code = ''
|
||||
this.formDialog.data.payment_method =
|
||||
this.paymentMethods[0] || 'lightning'
|
||||
|
|
@ -108,6 +165,13 @@ window.PageEventsDisplay = {
|
|||
this.paymentWebsocket.close()
|
||||
this.paymentWebsocket = null
|
||||
}
|
||||
this.paymentReq = null
|
||||
this.receive = {
|
||||
show: false,
|
||||
status: 'pending',
|
||||
paymentReq: null,
|
||||
isFiat: false
|
||||
}
|
||||
},
|
||||
nameValidation(val) {
|
||||
const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g
|
||||
|
|
@ -130,6 +194,10 @@ window.PageEventsDisplay = {
|
|||
this.formDialog.data.email = ''
|
||||
this.formDialog.data.refund = ''
|
||||
this.formDialog.data.nostr_identifier = ''
|
||||
this.formDialog.data.ticket_wave_id =
|
||||
this.activeTicketWaves.length === 1
|
||||
? this.activeTicketWaves[0].id
|
||||
: null
|
||||
this.formDialog.data.payment_method =
|
||||
this.paymentMethods[0] || 'lightning'
|
||||
Quasar.Notify.create({
|
||||
|
|
@ -160,12 +228,26 @@ window.PageEventsDisplay = {
|
|||
{
|
||||
name: this.formDialog.data.name,
|
||||
email: this.formDialog.data.email,
|
||||
ticket_wave_id: this.formDialog.data.ticket_wave_id || null,
|
||||
promo_code: this.formDialog.data.promo_code || null,
|
||||
refund_address: this.formDialog.data.refund || null,
|
||||
nostr_identifier: this.formDialog.data.nostr_identifier || null,
|
||||
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'
|
||||
}
|
||||
)
|
||||
if (data.satspay_charge_url) {
|
||||
window.location.href = data.satspay_charge_url
|
||||
return
|
||||
}
|
||||
|
||||
const isFiat = Boolean(data.is_fiat)
|
||||
this.paymentReq = isFiat
|
||||
? data.fiat_payment_request || null
|
||||
|
|
@ -197,7 +279,7 @@ window.PageEventsDisplay = {
|
|||
|
||||
const url = new URL(window.location)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
url.pathname = `/api/v1/ws/${paymentHash}`
|
||||
url.pathname = `/events/api/v1/tickets/ws/${paymentHash}`
|
||||
url.search = ''
|
||||
url.hash = ''
|
||||
|
||||
|
|
@ -206,7 +288,7 @@ window.PageEventsDisplay = {
|
|||
|
||||
ws.onmessage = event => {
|
||||
const data = JSON.parse(event.data)
|
||||
if (data.pending === false) {
|
||||
if (data.paid === true) {
|
||||
this.paymentSuccess(paymentHash)
|
||||
ws.close()
|
||||
}
|
||||
|
|
@ -215,8 +297,12 @@ window.PageEventsDisplay = {
|
|||
console.error('WebSocket error:', error)
|
||||
}
|
||||
ws.onclose = () => {
|
||||
if (this.paymentWebsocket === ws) {
|
||||
this.paymentWebsocket = null
|
||||
if (this.paymentWebsocket !== ws) return
|
||||
this.paymentWebsocket = null
|
||||
if (this.receive.show) {
|
||||
setTimeout(() => {
|
||||
if (this.receive.show) this.paymentWatcher(paymentHash)
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@
|
|||
filled
|
||||
dense
|
||||
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."
|
||||
></q-input>
|
||||
</div>
|
||||
|
|
@ -89,6 +89,21 @@
|
|||
lazy-rules
|
||||
:hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`"
|
||||
></q-input>
|
||||
<q-select
|
||||
v-if="showTicketWaveSelector"
|
||||
filled
|
||||
dense
|
||||
v-model="formDialog.data.ticket_wave_id"
|
||||
emit-value
|
||||
map-options
|
||||
label="Ticket wave"
|
||||
:options="
|
||||
activeTicketWaves.map(wave => ({
|
||||
label: `${wave.title} - ${wave.price_per_ticket} ${wave.currency}`,
|
||||
value: wave.id
|
||||
}))
|
||||
"
|
||||
></q-select>
|
||||
<div class="row q-col-gutter-md q-pt-lg items-center">
|
||||
<div v-if="paymentMethods.length > 1" class="col-auto">
|
||||
<q-option-group
|
||||
|
|
@ -117,7 +132,8 @@
|
|||
:disable="
|
||||
formDialog.data.name == '' ||
|
||||
formDialog.data.email == '' ||
|
||||
Boolean(paymentReq)
|
||||
(showTicketWaveSelector && !formDialog.data.ticket_wave_id) ||
|
||||
(receive.show && Boolean(paymentReq))
|
||||
"
|
||||
type="submit"
|
||||
>Submit</q-btn
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ window.PageEvents = {
|
|||
return {
|
||||
events: [],
|
||||
tickets: [],
|
||||
allPaidTickets: [],
|
||||
resendingTicketEmails: [],
|
||||
isUploadingTicketTemplate: false,
|
||||
ticketImageUploadTarget: null,
|
||||
currencies: [],
|
||||
pendingEvents: [],
|
||||
allUserEvents: [],
|
||||
|
|
@ -25,7 +28,12 @@ window.PageEvents = {
|
|||
label: 'Owner',
|
||||
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: 'event_start_date',
|
||||
|
|
@ -39,12 +47,6 @@ window.PageEvents = {
|
|||
label: 'End date',
|
||||
field: 'event_end_date'
|
||||
},
|
||||
{
|
||||
name: 'closing_date',
|
||||
align: 'left',
|
||||
label: 'Ticket close',
|
||||
field: 'closing_date'
|
||||
},
|
||||
{
|
||||
name: 'canceled',
|
||||
align: 'left',
|
||||
|
|
@ -129,8 +131,14 @@ window.PageEvents = {
|
|||
}
|
||||
},
|
||||
ticketsTable: {
|
||||
loading: false,
|
||||
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: 'email', align: 'left', label: 'Email', field: 'email'},
|
||||
{
|
||||
|
|
@ -152,6 +160,12 @@ window.PageEvents = {
|
|||
label: 'Registered',
|
||||
field: 'registered'
|
||||
},
|
||||
{
|
||||
name: 'nostr',
|
||||
align: 'left',
|
||||
label: 'Nostr',
|
||||
field: row => row.extra?.nostr_identifier || ''
|
||||
},
|
||||
{
|
||||
name: 'promo_code',
|
||||
align: 'left',
|
||||
|
|
@ -161,7 +175,11 @@ window.PageEvents = {
|
|||
{name: 'id', align: 'left', label: 'ID', field: 'id'}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
sortBy: 'time',
|
||||
descending: true,
|
||||
page: 1,
|
||||
rowsPerPage: 10,
|
||||
rowsNumber: 10
|
||||
}
|
||||
},
|
||||
// Rails an organizer can enable per event. Mirrors the webapp's
|
||||
|
|
@ -218,12 +236,44 @@ window.PageEvents = {
|
|||
categories: [],
|
||||
extra: {
|
||||
payment_methods: ['lightning'],
|
||||
ticket_waves: [],
|
||||
promo_codes: [],
|
||||
notification_subject: '',
|
||||
notification_body: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
ticketWaveDialog: {
|
||||
show: false,
|
||||
eventId: null,
|
||||
wallet: null,
|
||||
editingWaveId: null,
|
||||
data: {
|
||||
id: null,
|
||||
title: '',
|
||||
opening_date: '',
|
||||
closing_date: '',
|
||||
currency: 'sats',
|
||||
use_ticket_image: false,
|
||||
ticket_image_id: null,
|
||||
allow_fiat: false,
|
||||
fiat_currency: 'GBP',
|
||||
amount_tickets: 0,
|
||||
price_per_ticket: 0
|
||||
}
|
||||
},
|
||||
promoCodesDialog: {
|
||||
show: false,
|
||||
data: {
|
||||
id: null,
|
||||
wallet: null,
|
||||
name: '',
|
||||
extra: {
|
||||
promo_codes: []
|
||||
}
|
||||
}
|
||||
},
|
||||
onchainWallets: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -248,19 +298,196 @@ window.PageEvents = {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
shortenId(value) {
|
||||
if (!value) return ''
|
||||
return value.length > 4 ? `${value.slice(0, 4)}...` : value
|
||||
},
|
||||
async loadOnchainWallets() {
|
||||
const wallet = _.findWhere(this.g.user.wallets, {
|
||||
id: this.formDialog.data.wallet
|
||||
})
|
||||
if (!wallet) return
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/events/api/v1/events/onchain/status',
|
||||
wallet.adminkey
|
||||
)
|
||||
this.onchainWallets = data.available ? data.wallets || [] : []
|
||||
} catch {
|
||||
this.onchainWallets = []
|
||||
}
|
||||
},
|
||||
async confirmOnchainTicket(ticket) {
|
||||
const wallet = _.findWhere(this.g.user.wallets, {id: ticket.wallet})
|
||||
if (!wallet) return
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
`/events/api/v1/tickets/${ticket.id}/onchain-confirm`,
|
||||
wallet.adminkey
|
||||
)
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Onchain payment confirmed.',
|
||||
icon: null
|
||||
})
|
||||
await this.getTickets()
|
||||
await this.getAllTickets()
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
primaryTicketWave(data = this.formDialog.data) {
|
||||
if (!data.extra) data.extra = {}
|
||||
if (!data.extra.ticket_waves || data.extra.ticket_waves.length === 0) {
|
||||
data.extra.ticket_waves = [
|
||||
{
|
||||
id: 'primary',
|
||||
title: 'Primary wave',
|
||||
opening_date: data.closing_date || '',
|
||||
closing_date: data.closing_date || '',
|
||||
currency: data.currency || 'sats',
|
||||
use_ticket_image: false,
|
||||
ticket_image_id: null,
|
||||
allow_fiat: Boolean(data.allow_fiat),
|
||||
fiat_currency: data.fiat_currency || 'GBP',
|
||||
amount_tickets: data.amount_tickets || 0,
|
||||
price_per_ticket: data.price_per_ticket || 0
|
||||
}
|
||||
]
|
||||
}
|
||||
return data.extra.ticket_waves[0]
|
||||
},
|
||||
syncPrimaryWaveFromForm(data = this.formDialog.data) {
|
||||
const primaryWave = this.primaryTicketWave(data)
|
||||
primaryWave.title = primaryWave.title || 'Primary wave'
|
||||
primaryWave.opening_date = primaryWave.opening_date || ''
|
||||
primaryWave.closing_date = data.closing_date || ''
|
||||
primaryWave.currency = data.currency || 'sats'
|
||||
primaryWave.use_ticket_image = Boolean(primaryWave.use_ticket_image)
|
||||
primaryWave.ticket_image_id = primaryWave.ticket_image_id || null
|
||||
primaryWave.allow_fiat = Boolean(data.allow_fiat)
|
||||
primaryWave.fiat_currency = data.fiat_currency || 'GBP'
|
||||
primaryWave.amount_tickets = Number(data.amount_tickets || 0)
|
||||
primaryWave.price_per_ticket = Number(data.price_per_ticket || 0)
|
||||
return primaryWave
|
||||
},
|
||||
hydrateEventForm(data) {
|
||||
const formData = {
|
||||
...data,
|
||||
extra: {
|
||||
...(data.extra || {}),
|
||||
ticket_waves: [...((data.extra && data.extra.ticket_waves) || [])]
|
||||
}
|
||||
}
|
||||
const primaryWave = this.primaryTicketWave(formData)
|
||||
formData.currency = primaryWave.currency || formData.currency || 'sats'
|
||||
formData.allow_fiat = Boolean(primaryWave.allow_fiat)
|
||||
formData.fiat_currency = primaryWave.fiat_currency || 'GBP'
|
||||
formData.amount_tickets = primaryWave.amount_tickets
|
||||
formData.price_per_ticket = primaryWave.price_per_ticket
|
||||
formData.closing_date =
|
||||
primaryWave.closing_date || formData.closing_date || ''
|
||||
return formData
|
||||
},
|
||||
isFiatCurrency(currency) {
|
||||
return !['sat', 'sats'].includes((currency || '').toLowerCase())
|
||||
},
|
||||
getTickets() {
|
||||
LNbits.api
|
||||
.request(
|
||||
normalizePromoCodes(promoCodes = []) {
|
||||
return promoCodes
|
||||
.filter(code => code.code?.trim() !== '')
|
||||
.map(code => ({
|
||||
...code,
|
||||
code: code.code.trim().toUpperCase(),
|
||||
// fork-only: blank / 0 = unlimited; used_count is derived server-side
|
||||
max_uses: code.max_uses ? Number(code.max_uses) : null
|
||||
}))
|
||||
},
|
||||
templateDownloadUrl() {
|
||||
return '/events/static/image/ticket.jpg'
|
||||
},
|
||||
async uploadAssetFile(file) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
form.append('public_asset', 'true')
|
||||
const {data} = await LNbits.api.request(
|
||||
'POST',
|
||||
'/api/v1/assets?public_asset=true',
|
||||
null,
|
||||
form
|
||||
)
|
||||
return data.id
|
||||
},
|
||||
triggerTicketImageUpload(target) {
|
||||
this.ticketImageUploadTarget = target
|
||||
this.$refs.ticketImageUpload.value = null
|
||||
this.$refs.ticketImageUpload.click()
|
||||
},
|
||||
async handleTicketImageSelected(event) {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file || !this.ticketImageUploadTarget) return
|
||||
|
||||
this.isUploadingTicketTemplate = true
|
||||
try {
|
||||
const assetId = await this.uploadAssetFile(file)
|
||||
if (this.ticketImageUploadTarget === 'primary') {
|
||||
const wave = this.primaryTicketWave()
|
||||
wave.use_ticket_image = true
|
||||
wave.ticket_image_id = assetId
|
||||
} else if (this.ticketImageUploadTarget === 'dialog') {
|
||||
this.ticketWaveDialog.data.use_ticket_image = true
|
||||
this.ticketWaveDialog.data.ticket_image_id = assetId
|
||||
}
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Ticket template uploaded.',
|
||||
icon: null
|
||||
})
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
} finally {
|
||||
this.isUploadingTicketTemplate = false
|
||||
this.ticketImageUploadTarget = null
|
||||
}
|
||||
},
|
||||
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',
|
||||
'/events/api/v1/tickets?all_wallets=true',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(response => {
|
||||
this.tickets = response.data.filter(e => e.paid)
|
||||
})
|
||||
this.allPaidTickets = data.filter(ticket => ticket.paid)
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
async getTickets(props) {
|
||||
try {
|
||||
this.ticketsTable.loading = true
|
||||
const params = LNbits.utils.prepareFilterQuery(this.ticketsTable, props)
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/events/api/v1/tickets/paginated?all_wallets=true&${params}`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
this.tickets = data.data
|
||||
this.ticketsTable.pagination.rowsNumber = data.total
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
} finally {
|
||||
this.ticketsTable.loading = false
|
||||
}
|
||||
},
|
||||
deleteTicket(ticketId) {
|
||||
const tickets = _.findWhere(this.tickets, {id: ticketId})
|
||||
|
|
@ -275,10 +502,9 @@ window.PageEvents = {
|
|||
'/events/api/v1/tickets/' + ticketId,
|
||||
wallet.adminkey
|
||||
)
|
||||
.then(response => {
|
||||
this.tickets = _.reject(this.tickets, function (obj) {
|
||||
return obj.id == ticketId
|
||||
})
|
||||
.then(async () => {
|
||||
await this.getTickets()
|
||||
await this.getAllTickets()
|
||||
})
|
||||
.catch(LNbits.utils.notifyApiError)
|
||||
})
|
||||
|
|
@ -296,14 +522,30 @@ window.PageEvents = {
|
|||
wallet.adminkey
|
||||
)
|
||||
.then(response => {
|
||||
const result = response.data
|
||||
this.tickets = this.tickets.map(obj =>
|
||||
obj.id === ticket.id ? response.data : obj
|
||||
obj.id === ticket.id ? result.ticket : obj
|
||||
)
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Ticket email resent.',
|
||||
icon: null
|
||||
})
|
||||
|
||||
if (result.email?.attempted) {
|
||||
Quasar.Notify.create({
|
||||
type: result.email.sent ? 'positive' : 'negative',
|
||||
message: result.email.sent
|
||||
? 'Ticket email resent.'
|
||||
: `Ticket email failed: ${result.email.error || 'Unknown error.'}`,
|
||||
icon: null
|
||||
})
|
||||
}
|
||||
|
||||
if (result.nostr?.attempted) {
|
||||
Quasar.Notify.create({
|
||||
type: result.nostr.sent ? 'positive' : 'negative',
|
||||
message: result.nostr.sent
|
||||
? 'Ticket Nostr DM resent.'
|
||||
: `Ticket Nostr DM failed: ${result.nostr.error || 'Unknown error.'}`,
|
||||
icon: null
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(LNbits.utils.notifyApiError)
|
||||
.finally(() => {
|
||||
|
|
@ -313,7 +555,7 @@ window.PageEvents = {
|
|||
})
|
||||
},
|
||||
exportticketsCSV() {
|
||||
LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets)
|
||||
LNbits.utils.exportCSV(this.ticketsTable.columns, this.allPaidTickets)
|
||||
},
|
||||
getEvents() {
|
||||
LNbits.api
|
||||
|
|
@ -520,16 +762,14 @@ window.PageEvents = {
|
|||
data.location = (data.location || '').trim() || null
|
||||
data.categories = data.categories || []
|
||||
data.closing_date = data.closing_date || null
|
||||
|
||||
// Fold the form's day+time pairs first, then let upstream's helper
|
||||
// mirror the form fields onto the primary wave — order matters, the
|
||||
// sync reads closing_date/currency/amount_tickets off `data`.
|
||||
this.syncPrimaryWaveFromForm(data)
|
||||
if (data.extra?.promo_codes) {
|
||||
data.extra.promo_codes = data.extra.promo_codes
|
||||
.filter(code => code.code?.trim() !== '')
|
||||
.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
|
||||
}))
|
||||
data.extra.promo_codes = this.normalizePromoCodes(
|
||||
data.extra.promo_codes
|
||||
)
|
||||
}
|
||||
const methods = data.extra?.payment_methods || []
|
||||
if (methods.length === 0) {
|
||||
|
|
@ -549,6 +789,7 @@ window.PageEvents = {
|
|||
} else if (!data.allow_fiat) {
|
||||
data.fiat_currency = 'GBP'
|
||||
}
|
||||
this.syncPrimaryWaveFromForm(data)
|
||||
|
||||
if (data.id) {
|
||||
this.updateEvent(wallet, data)
|
||||
|
|
@ -561,10 +802,14 @@ window.PageEvents = {
|
|||
if (data && data.id) {
|
||||
const start = this.splitDateTime(data.event_start_date)
|
||||
const end = this.splitDateTime(data.event_end_date)
|
||||
// Seed from upstream's hydrator (it materialises ticket_waves and
|
||||
// mirrors the primary wave onto the flat form fields), then layer
|
||||
// our fork-only fields on top.
|
||||
const hydrated = this.hydrateEventForm(data)
|
||||
this.formDialog.data = {
|
||||
...data,
|
||||
...hydrated,
|
||||
extra: {
|
||||
...(data.extra || {}),
|
||||
...(hydrated.extra || {}),
|
||||
// Events created before extra.payment_methods existed carry an
|
||||
// empty list; show the rails the backend actually accepts for
|
||||
// them (Lightning always, fiat when allow_fiat).
|
||||
|
|
@ -596,6 +841,25 @@ window.PageEvents = {
|
|||
min_tickets: 1,
|
||||
email_notifications: false,
|
||||
nostr_notifications: false,
|
||||
onchain_enabled: false,
|
||||
onchain_wallet_id: null,
|
||||
onchain_zeroconf: false,
|
||||
onchain_fasttrack: false,
|
||||
ticket_waves: [
|
||||
{
|
||||
id: 'primary',
|
||||
title: 'Primary wave',
|
||||
opening_date: '',
|
||||
closing_date: '',
|
||||
currency: 'sats',
|
||||
use_ticket_image: false,
|
||||
ticket_image_id: null,
|
||||
allow_fiat: false,
|
||||
fiat_currency: 'GBP',
|
||||
amount_tickets: 0,
|
||||
price_per_ticket: 0
|
||||
}
|
||||
],
|
||||
promo_codes: [],
|
||||
notification_subject: '',
|
||||
notification_body: ''
|
||||
|
|
@ -603,6 +867,12 @@ window.PageEvents = {
|
|||
}
|
||||
}
|
||||
this.formDialog.show = true
|
||||
if (
|
||||
this.formDialog.data.wallet &&
|
||||
this.formDialog.data.extra?.onchain_enabled
|
||||
) {
|
||||
this.loadOnchainWallets()
|
||||
}
|
||||
},
|
||||
resetEventDialog() {
|
||||
this.formDialog.show = false
|
||||
|
|
@ -614,8 +884,27 @@ window.PageEvents = {
|
|||
categories: [],
|
||||
extra: {
|
||||
payment_methods: ['lightning'],
|
||||
conditional: false,
|
||||
min_tickets: 1,
|
||||
email_notifications: false,
|
||||
nostr_notifications: false,
|
||||
onchain_enabled: false,
|
||||
onchain_wallet_id: null,
|
||||
ticket_waves: [
|
||||
{
|
||||
id: 'primary',
|
||||
title: 'Primary wave',
|
||||
opening_date: '',
|
||||
closing_date: '',
|
||||
currency: 'sats',
|
||||
use_ticket_image: false,
|
||||
ticket_image_id: null,
|
||||
allow_fiat: false,
|
||||
fiat_currency: 'GBP',
|
||||
amount_tickets: 0,
|
||||
price_per_ticket: 0
|
||||
}
|
||||
],
|
||||
promo_codes: [],
|
||||
notification_subject: '',
|
||||
notification_body: ''
|
||||
|
|
@ -636,6 +925,179 @@ window.PageEvents = {
|
|||
const link = _.findWhere(this.events, {id: formId})
|
||||
this.openEventDialog(link)
|
||||
},
|
||||
openTicketWaveDialog(event, wave = null) {
|
||||
const primaryWave = (event.extra?.ticket_waves || [])[0] || {}
|
||||
const isEditing = Boolean(wave)
|
||||
this.ticketWaveDialog = {
|
||||
show: true,
|
||||
eventId: event.id,
|
||||
wallet: event.wallet,
|
||||
editingWaveId: wave?.id || null,
|
||||
data: {
|
||||
id: wave?.id || null,
|
||||
title: wave?.title || '',
|
||||
opening_date: wave?.opening_date || '',
|
||||
closing_date: wave?.closing_date || '',
|
||||
currency:
|
||||
wave?.currency || primaryWave.currency || event.currency || 'sats',
|
||||
use_ticket_image: Boolean(wave?.use_ticket_image),
|
||||
ticket_image_id: wave?.ticket_image_id || null,
|
||||
allow_fiat: isEditing
|
||||
? Boolean(wave?.allow_fiat)
|
||||
: Boolean(primaryWave.allow_fiat ?? event.allow_fiat),
|
||||
fiat_currency:
|
||||
wave?.fiat_currency ||
|
||||
primaryWave.fiat_currency ||
|
||||
event.fiat_currency ||
|
||||
'GBP',
|
||||
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) {
|
||||
LNbits.api
|
||||
.request(
|
||||
|
|
@ -702,6 +1164,7 @@ window.PageEvents = {
|
|||
async created() {
|
||||
if (this.g.user.wallets.length) {
|
||||
this.getTickets()
|
||||
this.getAllTickets()
|
||||
this.getEvents()
|
||||
this.getSettings()
|
||||
this.getPendingEvents()
|
||||
|
|
|
|||
|
|
@ -243,45 +243,102 @@
|
|||
<q-tr v-show="props.expand" :props="props">
|
||||
<q-td colspan="100%">
|
||||
<div class="q-pa-md">
|
||||
<div class="text-subtitle1 q-mb-md">Promo codes</div>
|
||||
<div class="row items-center q-mb-md">
|
||||
<div class="text-subtitle1">Ticket waves</div>
|
||||
<q-btn
|
||||
round
|
||||
dense
|
||||
unelevated
|
||||
color="primary"
|
||||
icon="add"
|
||||
class="q-ml-sm"
|
||||
@click="openTicketWaveDialog(props.row)"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="column q-mb-lg">
|
||||
<div class="column">
|
||||
<div
|
||||
v-for="(wave, index) in props.row.extra.ticket_waves"
|
||||
:key="wave.id || index"
|
||||
class="q-mb-sm"
|
||||
>
|
||||
<q-chip
|
||||
square
|
||||
clickable
|
||||
class="q-py-xs"
|
||||
style="height: auto"
|
||||
@click="openTicketWaveDialog(props.row, wave)"
|
||||
>
|
||||
<span
|
||||
style="white-space: normal; line-height: 1.3"
|
||||
v-text="
|
||||
`${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.length == 0"
|
||||
v-if="
|
||||
props.row.extra.promo_codes.filter(
|
||||
code => code.active
|
||||
).length == 0
|
||||
"
|
||||
class="text-caption"
|
||||
>
|
||||
No promo codes for this event.
|
||||
No active 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">
|
||||
<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"
|
||||
>
|
||||
<q-chip
|
||||
square
|
||||
size="md"
|
||||
clickable
|
||||
@click="utils.copyText(code.code.toUpperCase())"
|
||||
>
|
||||
<q-avatar
|
||||
icon="bookmark"
|
||||
:color="code.active ? 'green' : 'grey'"
|
||||
text-color="white"
|
||||
></q-avatar>
|
||||
<span v-text="code.code.toUpperCase()"></span>
|
||||
<span
|
||||
v-text="
|
||||
`${code.code.toUpperCase()} - ${code.discount_percent}%`
|
||||
"
|
||||
></span>
|
||||
</q-chip>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
Discount:
|
||||
<span v-text="code.discount_percent"></span>%
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
Status:
|
||||
<span
|
||||
:class="code.active ? 'text-green' : 'text-grey'"
|
||||
v-text="code.active ? 'Active' : 'Inactive'"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -308,9 +365,11 @@
|
|||
dense
|
||||
flat
|
||||
:rows="tickets"
|
||||
:loading="ticketsTable.loading"
|
||||
row-key="id"
|
||||
:columns="ticketsTable.columns"
|
||||
v-model:pagination="ticketsTable.pagination"
|
||||
@request="getTickets"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
|
|
@ -349,6 +408,18 @@
|
|||
>
|
||||
<q-tooltip>Resend ticket email</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="props.row.extra?.onchain && !props.row.paid"
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="confirmOnchainTicket(props.row)"
|
||||
icon="currency_bitcoin"
|
||||
color="orange"
|
||||
class="q-ml-xs"
|
||||
>
|
||||
<q-tooltip>Confirm onchain payment</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
|
|
@ -597,6 +668,39 @@
|
|||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator class="q-my-md"></q-separator>
|
||||
<div class="text-subtitle1 q-mt-lg q-mb-md">
|
||||
Primary ticket wave (you can add other waves later)
|
||||
</div>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col-12 col-md-4">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="primaryTicketWave().title"
|
||||
type="text"
|
||||
label="Wave title"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="primaryTicketWave().opening_date"
|
||||
type="date"
|
||||
label="Ticket opening date"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.closing_date"
|
||||
type="date"
|
||||
label="Ticket closing date"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col">
|
||||
<q-select
|
||||
|
|
@ -674,6 +778,49 @@
|
|||
</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
|
||||
v-if="acceptsFiat && isSatPrice"
|
||||
filled
|
||||
|
|
@ -718,135 +865,98 @@
|
|||
</div>
|
||||
</div>
|
||||
<q-separator class="q-my-md"></q-separator>
|
||||
<div class="text-subtitle1 q-mb-md">Promo Codes</div>
|
||||
<div class="text-caption">
|
||||
Allow users to enter a promo code for discounts.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(code, index) in formDialog.data.extra.promo_codes"
|
||||
:key="index"
|
||||
class="row q-col-gutter-sm q-mt-md"
|
||||
>
|
||||
<q-input
|
||||
class="col-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-caption">
|
||||
Send the paid ticket link automatically by email or Nostr DM.
|
||||
</div>
|
||||
<q-toggle
|
||||
v-model="formDialog.data.extra.email_notifications"
|
||||
label="Email notifications"
|
||||
left-label
|
||||
></q-toggle>
|
||||
<q-toggle
|
||||
v-model="formDialog.data.extra.nostr_notifications"
|
||||
label="Nostr notifications"
|
||||
left-label
|
||||
></q-toggle>
|
||||
<div class="row items-center q-col-gutter-md">
|
||||
<div class="col-auto">
|
||||
<q-toggle
|
||||
v-model="formDialog.data.extra.email_notifications"
|
||||
label="Email notifications"
|
||||
left-label
|
||||
></q-toggle>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-toggle
|
||||
v-model="formDialog.data.extra.nostr_notifications"
|
||||
label="Nostr notifications"
|
||||
left-label
|
||||
></q-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="formDialog.data.extra.email_notifications"
|
||||
class="q-mt-md"
|
||||
>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.extra.notification_subject"
|
||||
type="text"
|
||||
label="Ticket notification subject"
|
||||
hint="Used as the email subject when sending paid ticket links."
|
||||
></q-input>
|
||||
<q-input
|
||||
class="q-mt-md"
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.extra.notification_body"
|
||||
type="textarea"
|
||||
label="Ticket notification body"
|
||||
hint="Shown before the ticket link in the paid ticket notification."
|
||||
></q-input>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-separator class="q-my-md"></q-separator>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.extra.notification_subject"
|
||||
type="text"
|
||||
label="Ticket notification subject"
|
||||
hint="Used as the email subject when sending paid ticket links."
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.extra.notification_body"
|
||||
type="textarea"
|
||||
label="Ticket notification body"
|
||||
hint="Shown before the ticket link in the paid ticket notification."
|
||||
></q-input>
|
||||
<q-expansion-item
|
||||
group="advanced"
|
||||
icon="view_in_ar"
|
||||
label="Onchain payments"
|
||||
>
|
||||
<div class="q-mt-lg">
|
||||
<div class="text-caption q-mb-md">
|
||||
Accept Bitcoin onchain payments. Requires the
|
||||
<strong>SatsPay</strong> and <strong>Watchonly</strong>
|
||||
extensions to be installed and enabled.
|
||||
</div>
|
||||
<q-toggle
|
||||
v-model="formDialog.data.extra.onchain_enabled"
|
||||
label="Accept onchain payments"
|
||||
left-label
|
||||
@update:model-value="val => val && loadOnchainWallets()"
|
||||
></q-toggle>
|
||||
<q-select
|
||||
v-if="formDialog.data.extra.onchain_enabled"
|
||||
filled
|
||||
dense
|
||||
class="q-mt-md"
|
||||
v-model="formDialog.data.extra.onchain_wallet_id"
|
||||
label="Watchonly wallet"
|
||||
:options="
|
||||
onchainWallets.map(w => ({
|
||||
label: w.title || w.id,
|
||||
value: w.id
|
||||
}))
|
||||
"
|
||||
emit-value
|
||||
map-options
|
||||
hint="Bitcoin watchonly wallet for receiving onchain payments"
|
||||
></q-select>
|
||||
<div v-if="formDialog.data.extra.onchain_enabled" class="q-mt-md">
|
||||
<q-toggle
|
||||
v-model="formDialog.data.extra.onchain_zeroconf"
|
||||
label="Zero-conf (accept unconfirmed transactions)"
|
||||
left-label
|
||||
></q-toggle>
|
||||
<q-toggle
|
||||
v-model="formDialog.data.extra.onchain_fasttrack"
|
||||
label="Fasttrack (treat pending as paid)"
|
||||
left-label
|
||||
></q-toggle>
|
||||
</div>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
|
|
@ -863,7 +973,9 @@
|
|||
:disable="
|
||||
!formDialog.data.wallet ||
|
||||
!formDialog.data.name ||
|
||||
!formDialog.data.event_start_day
|
||||
!formDialog.data.event_start_day ||
|
||||
primaryTicketWave().title == null ||
|
||||
primaryTicketWave().opening_date == null
|
||||
"
|
||||
type="submit"
|
||||
>Create Event</q-btn
|
||||
|
|
@ -875,5 +987,275 @@
|
|||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="promoCodesDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="savePromoCodes" class="q-gutter-md">
|
||||
<div class="text-subtitle1">Promo Codes</div>
|
||||
<div class="text-caption">
|
||||
Allow users to enter a promo code for discounts.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(code, index) in promoCodesDialog.data.extra.promo_codes"
|
||||
:key="index"
|
||||
class="row q-col-gutter-sm q-mt-md"
|
||||
>
|
||||
<q-input
|
||||
class="col-8"
|
||||
filled
|
||||
dense
|
||||
v-model.trim="promoCodesDialog.data.extra.promo_codes[index].code"
|
||||
type="text"
|
||||
label="Promo Code"
|
||||
>
|
||||
<template v-slot:before>
|
||||
<q-checkbox
|
||||
left-label
|
||||
v-model="
|
||||
promoCodesDialog.data.extra.promo_codes[index].active
|
||||
"
|
||||
checked-icon="radio_button_checked"
|
||||
unchecked-icon="radio_button_unchecked"
|
||||
></q-checkbox>
|
||||
<q-tooltip>
|
||||
<span
|
||||
v-text="
|
||||
promoCodesDialog.data.extra.promo_codes[index].active
|
||||
? 'Active'
|
||||
: 'Inactive'
|
||||
"
|
||||
></span>
|
||||
</q-tooltip>
|
||||
</template>
|
||||
</q-input>
|
||||
<q-input
|
||||
class="col-2"
|
||||
filled
|
||||
dense
|
||||
v-model.number="
|
||||
promoCodesDialog.data.extra.promo_codes[index].discount_percent
|
||||
"
|
||||
type="number"
|
||||
label="Discount (%)"
|
||||
:hint="
|
||||
promoCodesDialog.data.extra.promo_codes[index].used_count
|
||||
? 'Used ' +
|
||||
promoCodesDialog.data.extra.promo_codes[index].used_count +
|
||||
(promoCodesDialog.data.extra.promo_codes[index].max_uses
|
||||
? ' / ' +
|
||||
promoCodesDialog.data.extra.promo_codes[index].max_uses
|
||||
: '')
|
||||
: ''
|
||||
"
|
||||
min="0"
|
||||
max="100"
|
||||
>
|
||||
<template v-slot:after>
|
||||
<q-btn
|
||||
round
|
||||
dense
|
||||
flat
|
||||
icon="delete"
|
||||
@click="
|
||||
promoCodesDialog.data.extra.promo_codes.splice(index, 1)
|
||||
"
|
||||
></q-btn>
|
||||
</template>
|
||||
</q-input>
|
||||
<q-input
|
||||
class="col-2"
|
||||
filled
|
||||
dense
|
||||
v-model.number="
|
||||
promoCodesDialog.data.extra.promo_codes[index].max_uses
|
||||
"
|
||||
type="number"
|
||||
label="Max uses"
|
||||
placeholder="∞"
|
||||
min="1"
|
||||
hint="Blank = unlimited"
|
||||
></q-input>
|
||||
</div>
|
||||
|
||||
<div class="col-12 q-mt-md">
|
||||
<q-btn @click="addPromoCodeToDialog">Add Promo Code</q-btn>
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn unelevated color="primary" type="submit"
|
||||
>Save Promo Codes</q-btn
|
||||
>
|
||||
<q-btn
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
@click="resetPromoCodesDialog"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="ticketWaveDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="saveTicketWave" class="q-gutter-md">
|
||||
<div
|
||||
class="text-subtitle1"
|
||||
v-text="
|
||||
ticketWaveDialog.editingWaveId
|
||||
? 'Edit Ticket Wave'
|
||||
: 'Add Ticket Wave'
|
||||
"
|
||||
></div>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col-12 col-md-4">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="ticketWaveDialog.data.title"
|
||||
type="text"
|
||||
label="Wave title"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="ticketWaveDialog.data.opening_date"
|
||||
type="date"
|
||||
label="Ticket opening date"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="ticketWaveDialog.data.closing_date"
|
||||
type="date"
|
||||
label="Ticket closing date"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="ticketWaveDialog.data.currency"
|
||||
type="text"
|
||||
label="Unit"
|
||||
:options="currencies"
|
||||
></q-select>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="ticketWaveDialog.data.amount_tickets"
|
||||
type="number"
|
||||
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>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -3,16 +3,36 @@ window.PageEventsTicket = {
|
|||
data() {
|
||||
return {
|
||||
ticketId: null,
|
||||
ticket: null
|
||||
ticket: null,
|
||||
printMode: false,
|
||||
qrSrc: ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
printWindow() {
|
||||
window.print()
|
||||
async printWindow() {
|
||||
this.printMode = true
|
||||
await this.$nextTick()
|
||||
await this.waitForPrintAssets()
|
||||
setTimeout(() => window.print(), 50)
|
||||
},
|
||||
async waitForPrintAssets() {
|
||||
await this.$nextTick()
|
||||
const img = document.querySelector('.ticket-print-qr')
|
||||
if (!img) return
|
||||
if (img.complete && img.naturalWidth > 0) return
|
||||
await new Promise(resolve => {
|
||||
const done = () => resolve()
|
||||
img.addEventListener('load', done, {once: true})
|
||||
img.addEventListener('error', done, {once: true})
|
||||
setTimeout(done, 500)
|
||||
})
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
this.ticketId = this.$route.params.id
|
||||
this.qrSrc = `/api/v1/qrcode?data=${encodeURIComponent(
|
||||
`ticket://${this.ticketId}`
|
||||
)}`
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
|
|
@ -22,5 +42,8 @@ window.PageEventsTicket = {
|
|||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
window.addEventListener('afterprint', () => {
|
||||
this.printMode = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,4 +36,53 @@
|
|||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div class="ticket-print-sheet" v-if="printMode">
|
||||
<img class="ticket-print-qr" :src="qrSrc" alt="Ticket QR" v-if="qrSrc" />
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@media print {
|
||||
@page {
|
||||
size: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
* {
|
||||
color: black !important;
|
||||
background: white !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
body > * {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ticket-print-sheet {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100vw;
|
||||
min-height: 100vh;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
background: white !important;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
.ticket-print-qr {
|
||||
display: block;
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,15 @@ from datetime import datetime, timezone
|
|||
import pytest
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -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:
|
||||
now = datetime.now(timezone.utc)
|
||||
return Ticket(
|
||||
|
|
@ -103,7 +120,7 @@ def test_remaining_uses():
|
|||
|
||||
def test_sat_totals_round_to_whole_sats():
|
||||
event = _event(price=333, codes=[PromoCode(code="OFF15", discount_percent=15)])
|
||||
totals = basket_totals(event, ["off15"], 1, {})
|
||||
totals = basket_totals(event, ["off15"], 1, {}, _wave(event))
|
||||
assert (totals.subtotal, totals.total, totals.discount) == (333, 283, 50)
|
||||
assert totals.currency == "sat"
|
||||
assert totals.discounts_applied[0].dict() == {
|
||||
|
|
@ -120,7 +137,7 @@ def test_fiat_totals_round_to_cents_and_scale_by_quantity():
|
|||
price=19.99,
|
||||
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.total == 40.18
|
||||
assert totals.discount == 19.79
|
||||
|
|
@ -135,7 +152,10 @@ def test_first_applicable_code_wins():
|
|||
]
|
||||
)
|
||||
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": 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.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),
|
||||
]
|
||||
)
|
||||
assert basket_totals(event, ["TWO"], 1, {"TWO": 1}).total == 500
|
||||
assert basket_totals(event, ["INF"], 10, {"INF": 999}).total == 9000
|
||||
assert basket_totals(event, ["TWO"], 1, {"TWO": 1}, _wave(event)).total == 500
|
||||
assert basket_totals(event, ["INF"], 10, {"INF": 999}, _wave(event)).total == 9000
|
||||
|
||||
|
||||
def test_full_discount_prices_to_zero():
|
||||
event = _event(codes=[PromoCode(code="FREE", discount_percent=100)])
|
||||
assert basket_totals(event, ["FREE"], 2, {}).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
|
||||
|
|
|
|||
440
views_api.py
440
views_api.py
|
|
@ -2,6 +2,7 @@ import asyncio
|
|||
from datetime import datetime, timezone
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
|
@ -16,17 +17,20 @@ from fastapi import (
|
|||
)
|
||||
from fastapi.responses import StreamingResponse
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.core.crud.assets import get_public_asset
|
||||
from lnbits.core.crud.wallets import get_wallet
|
||||
from lnbits.core.models import Account, User, WalletTypeInfo
|
||||
from lnbits.core.models.payments import CreateInvoice
|
||||
from lnbits.core.services import create_payment_request
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import (
|
||||
check_admin,
|
||||
check_user_exists,
|
||||
parse_filters,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
from lnbits.helpers import generate_filter_params_openapi, urlsafe_short_hash
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.exchange_rates import (
|
||||
fiat_amount_as_satoshis,
|
||||
|
|
@ -34,6 +38,8 @@ from lnbits.utils.exchange_rates import (
|
|||
satoshis_amount_as_fiat,
|
||||
)
|
||||
from lnbits.utils.nostr import normalize_public_key
|
||||
from loguru import logger
|
||||
from PIL import Image
|
||||
|
||||
from .crud import (
|
||||
create_event,
|
||||
|
|
@ -53,6 +59,7 @@ from .crud import (
|
|||
get_tickets_by_event,
|
||||
get_tickets_by_payment_hash,
|
||||
get_tickets_by_user_id,
|
||||
get_tickets_paginated,
|
||||
purge_unpaid_tickets,
|
||||
update_event,
|
||||
update_settings,
|
||||
|
|
@ -68,9 +75,13 @@ from .models import (
|
|||
PublicEvent,
|
||||
PublicTicket,
|
||||
Ticket,
|
||||
TicketFilters,
|
||||
TicketPaymentRequest,
|
||||
TicketResendResult,
|
||||
TicketWave,
|
||||
effective_payment_methods,
|
||||
ensure_ticket_waves,
|
||||
get_active_ticket_waves,
|
||||
)
|
||||
from .nostr_hooks import publish_or_delete_nostr_event
|
||||
from .promo import (
|
||||
|
|
@ -88,19 +99,57 @@ from .qr import (
|
|||
ticket_card_filename,
|
||||
)
|
||||
from .services import (
|
||||
create_satspay_charge,
|
||||
event_promo_usage,
|
||||
fetch_watchonly_config,
|
||||
fetch_watchonly_wallets,
|
||||
get_satspay_charge,
|
||||
hydrate_promo_usage,
|
||||
refund_tickets,
|
||||
resend_ticket_email_notification,
|
||||
send_ticket_notification_in_background,
|
||||
set_ticket_paid,
|
||||
)
|
||||
from .tasks import deregister_payment_listener, register_payment_listener
|
||||
from .tasks import (
|
||||
deregister_payment_listener,
|
||||
payment_listeners,
|
||||
register_payment_listener,
|
||||
)
|
||||
|
||||
events_api_router = APIRouter(prefix="/api/v1/events")
|
||||
tickets_api_router = APIRouter(prefix="/api/v1/tickets")
|
||||
qr_api_router = APIRouter(prefix="/api/v1")
|
||||
promo_api_router = APIRouter(prefix="/api/v1/promo")
|
||||
tickets_filters = parse_filters(TicketFilters)
|
||||
|
||||
|
||||
async def _get_watchonly_status(wallet) -> dict[str, Any]:
|
||||
try:
|
||||
config = await fetch_watchonly_config(wallet.inkey)
|
||||
network = config.get("network")
|
||||
if not network:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Watchonly extension returned an invalid network.",
|
||||
)
|
||||
wallets = await fetch_watchonly_wallets(wallet.inkey, network)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
return {
|
||||
"available": False,
|
||||
"message": f"Watchonly extension is not reachable: {exc!s}",
|
||||
"network": None,
|
||||
"wallets": [],
|
||||
"mempool_endpoint": None,
|
||||
}
|
||||
return {
|
||||
"available": True,
|
||||
"message": None,
|
||||
"network": network,
|
||||
"wallets": wallets,
|
||||
"mempool_endpoint": config.get("mempool_endpoint"),
|
||||
}
|
||||
|
||||
|
||||
def _is_fiat_currency(currency: str | None) -> bool:
|
||||
|
|
@ -109,8 +158,6 @@ def _is_fiat_currency(currency: str | None) -> bool:
|
|||
|
||||
# Literal-prefix routes (/public, /all, /pending, /settings) MUST be declared
|
||||
# before any "/{event_id}" route or FastAPI matches them as a path parameter.
|
||||
|
||||
|
||||
@events_api_router.get("")
|
||||
async def api_events(
|
||||
all_wallets: bool = Query(False),
|
||||
|
|
@ -247,6 +294,13 @@ async def api_get_settings_public(
|
|||
return {"auto_approve": settings.auto_approve}
|
||||
|
||||
|
||||
@events_api_router.get("/onchain/status")
|
||||
async def api_onchain_status(
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> dict[str, Any]:
|
||||
return await _get_watchonly_status(wallet.wallet)
|
||||
|
||||
|
||||
@events_api_router.get("/{event_id}", response_model=PublicEvent)
|
||||
async def api_get_event(event_id: str) -> Event:
|
||||
"""Public event detail used by display.vue.
|
||||
|
|
@ -273,28 +327,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
|
||||
# Accept either YYYY-MM-DD or full ISO 8601 datetime (event_end_date
|
||||
# may carry a time component since v1.3.0-aio.3 / our start-end-time
|
||||
# feature).
|
||||
# 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:
|
||||
closing_dt = datetime.fromisoformat(closing_date)
|
||||
except ValueError:
|
||||
closing_dt = datetime.strptime(closing_date[:10], "%Y-%m-%d")
|
||||
if closing_dt.tzinfo is None:
|
||||
closing_dt = closing_dt.replace(tzinfo=timezone.utc)
|
||||
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 = (
|
||||
event.sold >= event.extra.min_tickets if event.extra.conditional else True
|
||||
)
|
||||
if event.amount_tickets < 1:
|
||||
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
|
||||
if event.extra.conditional and not is_min_tickets_met and not is_window_open:
|
||||
if event.extra.conditional and not is_min_tickets_met and is_sales_closed:
|
||||
event.canceled = True
|
||||
await update_event(event)
|
||||
await refund_tickets(event_id)
|
||||
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.")
|
||||
|
||||
if not is_window_open:
|
||||
if not active_waves:
|
||||
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
|
||||
|
|
@ -536,6 +606,31 @@ async def api_tickets_by_user(
|
|||
return await get_tickets_by_user_id(user_id)
|
||||
|
||||
|
||||
@tickets_api_router.get(
|
||||
"/paginated",
|
||||
summary="Get paginated list of tickets",
|
||||
openapi_extra=generate_filter_params_openapi(TicketFilters),
|
||||
response_model=Page[Ticket],
|
||||
)
|
||||
async def api_tickets_paginated(
|
||||
all_wallets: bool = Query(False),
|
||||
filters: Filters = Depends(tickets_filters),
|
||||
key_info: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Page[Ticket]:
|
||||
wallet_ids = [key_info.wallet.id]
|
||||
|
||||
if all_wallets:
|
||||
user = await get_user(key_info.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
|
||||
if not filters.sortby:
|
||||
filters.sortby = "time"
|
||||
if not filters.direction:
|
||||
filters.direction = "desc"
|
||||
|
||||
return await get_tickets_paginated(wallet_ids, filters)
|
||||
|
||||
|
||||
@tickets_api_router.get("/{ticket_id}", response_model=PublicTicket)
|
||||
async def api_get_ticket(ticket_id: str) -> Ticket:
|
||||
ticket = await get_ticket(ticket_id)
|
||||
|
|
@ -597,6 +692,36 @@ def _resolve_frontend_root(data: CreateTicket, request: Request) -> str:
|
|||
return data.frontend_url.rstrip("/")
|
||||
|
||||
|
||||
def _resolve_ticket_wave(event: Event, ticket_wave_id: str | None) -> TicketWave:
|
||||
"""The wave a purchase or a price preview is priced against.
|
||||
|
||||
Shared by the purchase and promo-validate endpoints so the two cannot
|
||||
drift: whatever wave the preview quoted is the wave the invoice charges.
|
||||
Selection is upstream v1.6.8's — an explicit id must be an OPEN wave, a
|
||||
single open wave is implied, and an ambiguous choice is an error rather
|
||||
than a silent pick.
|
||||
"""
|
||||
active_waves = get_active_ticket_waves(event)
|
||||
if not active_waves:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.GONE, detail="No ticket wave is currently open."
|
||||
)
|
||||
if ticket_wave_id:
|
||||
wave = next((wave for wave in active_waves if wave.id == ticket_wave_id), None)
|
||||
if not wave:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Invalid ticket wave selected.",
|
||||
)
|
||||
return wave
|
||||
if len(active_waves) == 1:
|
||||
return active_waves[0]
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Please select a ticket wave.",
|
||||
)
|
||||
|
||||
|
||||
async def _issue_free_tickets(
|
||||
*,
|
||||
event: Event,
|
||||
|
|
@ -607,6 +732,7 @@ async def _issue_free_tickets(
|
|||
promo_code: str | None,
|
||||
nostr_identifier: str | None,
|
||||
frontend_root: str,
|
||||
selected_wave: TicketWave,
|
||||
) -> TicketPaymentRequest:
|
||||
"""Issue `quantity` free tickets without minting an invoice.
|
||||
|
||||
|
|
@ -635,6 +761,11 @@ async def _issue_free_tickets(
|
|||
ticket_id=row_id,
|
||||
extra={
|
||||
"applied_promo_code": promo_code,
|
||||
# Free tickets consume wave stock exactly like paid ones —
|
||||
# `set_ticket_paid` runs on this path too — so they must name
|
||||
# their wave or the decrement lands on the primary wave.
|
||||
"ticket_wave_id": selected_wave.id,
|
||||
"ticket_wave_title": selected_wave.title,
|
||||
"nostr_identifier": nostr_identifier,
|
||||
"ticket_base_url": frontend_root,
|
||||
"sats_paid": 0,
|
||||
|
|
@ -690,11 +821,16 @@ async def api_ticket_create(
|
|||
refund_address = data.refund_address
|
||||
nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None
|
||||
payment_method = (data.payment_method or "lightning").lower()
|
||||
if payment_method not in {"lightning", "fiat"}:
|
||||
if payment_method not in {"lightning", "fiat", "onchain"}:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Unsupported payment method.",
|
||||
)
|
||||
# npub or NIP-05, both normalised to a hex pubkey. v1.6.8 replaced this
|
||||
# with a hard "Only NIP-05 Nostr identifiers are supported." rejection —
|
||||
# true for upstream, false here: `_send_nostr_ticket_notification` sends
|
||||
# bare pubkeys via `send_nostr_dm`. That rejection merged in outside any
|
||||
# conflict marker, so it would have silently dropped npub checkout.
|
||||
if nostr_identifier and "@" not in nostr_identifier:
|
||||
try:
|
||||
nostr_identifier = normalize_public_key(nostr_identifier)
|
||||
|
|
@ -703,6 +839,8 @@ async def api_ticket_create(
|
|||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Invalid Nostr identifier.",
|
||||
) from exc
|
||||
|
||||
selected_wave = _resolve_ticket_wave(event, data.ticket_wave_id)
|
||||
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
|
||||
frontend_root = _resolve_frontend_root(data, request)
|
||||
|
||||
|
|
@ -733,9 +871,18 @@ async def api_ticket_create(
|
|||
),
|
||||
)
|
||||
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:
|
||||
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).
|
||||
# Short-circuit before any invoice / fiat-provider logic: no Lightning
|
||||
|
|
@ -751,6 +898,16 @@ async def api_ticket_create(
|
|||
promo_code=promo_code,
|
||||
nostr_identifier=nostr_identifier,
|
||||
frontend_root=frontend_root,
|
||||
selected_wave=selected_wave,
|
||||
)
|
||||
|
||||
# Fiat is a per-wave opt-in since v1.6.8. `effective_payment_methods`
|
||||
# below reads `event.allow_fiat`, which is only the PRIMARY wave's, so
|
||||
# this check is what actually protects a non-fiat wave.
|
||||
if payment_method == "fiat" and not selected_wave.allow_fiat:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Fiat payments are not enabled for this ticket wave.",
|
||||
)
|
||||
|
||||
# Organizer-controlled rails (extra.payment_methods; legacy events fall
|
||||
|
|
@ -762,23 +919,25 @@ async def api_ticket_create(
|
|||
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["currency"] = event.currency
|
||||
extra["currency"] = selected_wave.currency
|
||||
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":
|
||||
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_provider = None
|
||||
onchain_amount_sat = None
|
||||
|
||||
if payment_method == "fiat":
|
||||
if _is_fiat_currency(event.currency):
|
||||
invoice_unit = event.currency
|
||||
if _is_fiat_currency(selected_wave.currency):
|
||||
invoice_unit = selected_wave.currency
|
||||
else:
|
||||
invoice_unit = event.fiat_currency
|
||||
invoice_unit = selected_wave.fiat_currency
|
||||
fiat_amount = await satoshis_amount_as_fiat(price, invoice_unit)
|
||||
extra["fiat"] = True
|
||||
extra["currency"] = invoice_unit
|
||||
|
|
@ -797,6 +956,80 @@ async def api_ticket_create(
|
|||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="No fiat payment provider configured for this event.",
|
||||
)
|
||||
elif payment_method == "onchain":
|
||||
onchain_amount_sat = int(price)
|
||||
wallet_record = await get_wallet(event.wallet)
|
||||
if not wallet_record:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Event wallet does not exist.",
|
||||
)
|
||||
if not event.extra.onchain_enabled:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Onchain payments are not enabled for this event.",
|
||||
)
|
||||
if not event.extra.onchain_wallet_id:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="No onchain wallet configured for this event.",
|
||||
)
|
||||
|
||||
ticket_id = urlsafe_short_hash()
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
# Use internal address for webhook — SatsPay calls it server-side and
|
||||
# cannot reach the public domain from within the container.
|
||||
internal_base = f"http://{settings.host}:{settings.port}"
|
||||
webhook_url = (
|
||||
f"{internal_base}/events/api/v1/tickets/{ticket_id}/satspay-webhook"
|
||||
)
|
||||
complete_url = f"{base_url}/events/ticket/{ticket_id}"
|
||||
try:
|
||||
charge = await create_satspay_charge(
|
||||
api_key=wallet_record.inkey,
|
||||
data={
|
||||
"amount": onchain_amount_sat,
|
||||
"description": f"Ticket for {event.name}",
|
||||
"name": name,
|
||||
"onchainwallet": event.extra.onchain_wallet_id,
|
||||
"zeroconf": event.extra.onchain_zeroconf,
|
||||
"fasttrack": event.extra.onchain_fasttrack,
|
||||
"webhook": webhook_url,
|
||||
"completelink": complete_url,
|
||||
"completelinktext": "View your ticket",
|
||||
"time": 1440,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Failed to create SatsPay charge: {exc}",
|
||||
) from exc
|
||||
|
||||
await create_ticket(
|
||||
payment_hash=ticket_id,
|
||||
wallet=event.wallet,
|
||||
event=event.id,
|
||||
name=name,
|
||||
email=email,
|
||||
extra={
|
||||
"applied_promo_code": promo_code,
|
||||
"ticket_wave_id": selected_wave.id,
|
||||
"ticket_wave_title": selected_wave.title,
|
||||
"refund_address": refund_address,
|
||||
"nostr_identifier": nostr_identifier,
|
||||
"ticket_base_url": base_url,
|
||||
"sats_paid": onchain_amount_sat,
|
||||
"onchain": True,
|
||||
"satspay_charge_id": charge["id"],
|
||||
},
|
||||
)
|
||||
|
||||
return TicketPaymentRequest(
|
||||
payment_hash=ticket_id,
|
||||
onchain_amount_sat=onchain_amount_sat,
|
||||
satspay_charge_url=f"/satspay/{charge['id']}",
|
||||
)
|
||||
else:
|
||||
invoice_unit = "sat"
|
||||
|
||||
|
|
@ -854,6 +1087,12 @@ async def api_ticket_create(
|
|||
ticket_id=row_id,
|
||||
extra={
|
||||
"applied_promo_code": promo_code,
|
||||
# Which wave this ticket came from. `set_ticket_paid` debits
|
||||
# the wave named here and falls back to waves[0] when it is
|
||||
# absent, so omitting it would take every sale off the
|
||||
# primary wave no matter what the buyer bought.
|
||||
"ticket_wave_id": selected_wave.id,
|
||||
"ticket_wave_title": selected_wave.title,
|
||||
"refund_address": refund_address,
|
||||
"nostr_identifier": nostr_identifier,
|
||||
"ticket_base_url": frontend_root,
|
||||
|
|
@ -955,9 +1194,78 @@ async def api_ticket_delete(
|
|||
await delete_ticket(ticket_id)
|
||||
|
||||
|
||||
@tickets_api_router.post("/{ticket_id}/satspay-webhook")
|
||||
async def api_ticket_satspay_webhook(ticket_id: str) -> None:
|
||||
ticket = await get_ticket(ticket_id)
|
||||
if not ticket:
|
||||
logger.warning(f"SatsPay webhook: ticket {ticket_id} does not exist.")
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
|
||||
)
|
||||
if ticket.paid:
|
||||
logger.warning(f"SatsPay webhook: ticket {ticket_id} already paid.")
|
||||
return
|
||||
if not ticket.extra.satspay_charge_id:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Not a SatsPay ticket."
|
||||
)
|
||||
wallet = await get_wallet(ticket.wallet)
|
||||
if not wallet:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Ticket wallet does not exist."
|
||||
)
|
||||
try:
|
||||
charge = await get_satspay_charge(wallet.inkey, ticket.extra.satspay_charge_id)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail=f"Could not verify charge: {exc}"
|
||||
) from exc
|
||||
if not charge.get("paid"):
|
||||
logger.warning(
|
||||
f"SatsPay webhook for ticket {ticket_id}: charge"
|
||||
f" {ticket.extra.satspay_charge_id} not paid."
|
||||
)
|
||||
return
|
||||
|
||||
ticket = await set_ticket_paid(ticket)
|
||||
send_ticket_notification_in_background(ticket)
|
||||
for queue in payment_listeners.get(ticket_id, []):
|
||||
queue.put_nowait(ticket)
|
||||
|
||||
|
||||
@tickets_api_router.put("/{payment_hash}/onchain-confirm")
|
||||
async def api_ticket_onchain_confirm(
|
||||
payment_hash: str,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Ticket:
|
||||
ticket = await get_ticket(payment_hash)
|
||||
if not ticket:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
|
||||
)
|
||||
if ticket.wallet != wallet.wallet.id:
|
||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your ticket.")
|
||||
if ticket.paid:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Ticket already paid."
|
||||
)
|
||||
if not ticket.extra.onchain:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Ticket is not an onchain payment.",
|
||||
)
|
||||
ticket = await set_ticket_paid(ticket)
|
||||
send_ticket_notification_in_background(ticket)
|
||||
for queue in payment_listeners.get(payment_hash, []):
|
||||
queue.put_nowait(ticket)
|
||||
return ticket
|
||||
|
||||
|
||||
@tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult)
|
||||
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:
|
||||
ticket = await get_ticket(ticket_id)
|
||||
if not ticket:
|
||||
|
|
@ -975,16 +1283,13 @@ async def api_ticket_resend_email(
|
|||
)
|
||||
|
||||
try:
|
||||
return await resend_ticket_email_notification(ticket)
|
||||
return await resend_ticket_email_notification(
|
||||
ticket, str(request.base_url).rstrip("/")
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to resend ticket email.",
|
||||
) from exc
|
||||
|
||||
|
||||
@tickets_api_router.put("/register/{ticket_id}")
|
||||
|
|
@ -1095,28 +1400,76 @@ async def api_event_ticket_stats(
|
|||
|
||||
@qr_api_router.get("/qr/{ticket_id}", response_class=StreamingResponse)
|
||||
async def api_ticket_qr(ticket_id: str):
|
||||
"""PNG of the ticket's scan payload (`ticket://<id>`), branded with the
|
||||
instance QR logo. Anonymous by design — it is what the ticket email
|
||||
embeds — and the id is the same bearer token the ticket page exposes.
|
||||
Port of upstream v1.6.8 without ticket-image compositing."""
|
||||
"""PNG of the ticket's scan payload (`ticket://<id>`).
|
||||
|
||||
Two shapes behind one route. Before the v1.6.8 merge this endpoint
|
||||
existed on both sides — ours was "upstream's, without ticket-image
|
||||
compositing", theirs added the compositing but dropped the logo. They
|
||||
are the same endpoint, so they are merged rather than registered twice
|
||||
(FastAPI serves whichever route is declared first, so the second copy
|
||||
would have been silently unreachable):
|
||||
|
||||
- wave opted into `use_ticket_image`: upstream's composite — the QR
|
||||
pasted onto the organiser's uploaded template, or the bundled
|
||||
default. QR size and paste coordinates are upstream's and are tied
|
||||
to each other; no logo, because the template carries the branding.
|
||||
- otherwise: our standalone QR at 300px with the instance logo.
|
||||
|
||||
Anonymous by design — it is what the ticket email links to — and the id
|
||||
is the same bearer token the ticket page exposes.
|
||||
"""
|
||||
ticket = await get_ticket(ticket_id)
|
||||
if not ticket:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
|
||||
)
|
||||
event = await get_event(ticket.event)
|
||||
if not event:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
||||
)
|
||||
|
||||
logo = await load_qr_logo()
|
||||
image = make_qr_png(f"ticket://{ticket_id}", size=300, logo=logo)
|
||||
return StreamingResponse(
|
||||
BytesIO(image_png_bytes(image)),
|
||||
media_type="image/png",
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0",
|
||||
},
|
||||
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()
|
||||
image = make_qr_png(f"ticket://{ticket_id}", size=300, logo=logo)
|
||||
return StreamingResponse(
|
||||
BytesIO(image_png_bytes(image)),
|
||||
media_type="image/png",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
background_bytes = None
|
||||
if wave.ticket_image_id:
|
||||
asset = await get_public_asset(wave.ticket_image_id)
|
||||
if asset:
|
||||
background_bytes = asset.data
|
||||
|
||||
if background_bytes:
|
||||
ticket_image = Image.open(BytesIO(background_bytes)).convert("RGBA")
|
||||
else:
|
||||
default_template = (
|
||||
Path(__file__).resolve().parent / "static" / "image" / "ticket.jpg"
|
||||
)
|
||||
ticket_image = Image.open(default_template).convert("RGBA")
|
||||
|
||||
ticket_image.paste(make_qr_png(f"ticket://{ticket_id}", size=157), (122, 505))
|
||||
output = BytesIO()
|
||||
ticket_image.save(output, format="PNG")
|
||||
output.seek(0)
|
||||
return StreamingResponse(output, media_type="image/png", headers=headers)
|
||||
|
||||
|
||||
@qr_api_router.get("/ticket-card/{ticket_id}", response_class=StreamingResponse)
|
||||
async def api_ticket_card(ticket_id: str):
|
||||
|
|
@ -1162,4 +1515,7 @@ async def api_validate_promo_codes(
|
|||
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
||||
)
|
||||
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