Some checks failed
lint.yml / fix: require a capacity on every ticket wave (pull_request) Failing after 0s
"Capacity is always required, there is no unlimited" was settled on #34 and enforced in #62 — but as a single number on the event. Since v1.6.8 capacity is per-wave and `event.amount_tickets` is a derived roll-up (`sync_event_ticket_waves`), so the rule had nothing holding it up at the level organisers actually set: the wave form's capacity input had no minimum, and nothing on the backend checked one at all. A zero-capacity wave can never be active — `get_active_ticket_waves` requires `amount_tickets > 0` — so it is the wave-level form of exactly what #62 removed: an event that looks on sale but refuses every purchase, on the card and at checkout both. `_validate_wave_capacity` now runs on create and update. Two details that matter: - it checks `ensure_ticket_waves(data)` rather than the raw list, so an event submitted with no waves is checked through the primary wave it is about to be given, not vacuously passed - on an edit it only checks waves NEW to the event. Selling out is the legitimate route to zero, and rejecting it would make a sold-out event uneditable — including the legacy zero-capacity rows this rule exists to let organisers fix Frontend: the wave dialog's capacity input gains the `min="1"` and hint the event-level field already had, and a new wave opens at 1 rather than 0. That default uses `??`, not `||` — a sold-out wave holds 0 and must keep showing it instead of silently regaining stock when saved. Also drops the stale `0 = unlimited / not ticketed` contract still documented on `CreateEvent.amount_tickets`, which has not been true since #62. 6 new tests; 120 pass. ruff, black, prettier clean; mypy error set unchanged from baseline.
505 lines
18 KiB
Python
505 lines
18 KiB
Python
import json
|
|
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")
|
|
|
|
|
|
class PromoCode(BaseModel):
|
|
code: str
|
|
discount_percent: float = 0.0
|
|
active: bool = True
|
|
# Redemption cap; None / 0 = unlimited. Field names follow upstream v2.
|
|
max_uses: int | None = None
|
|
# Derived on read from PAID tickets whose extra.applied_promo_code matches
|
|
# (see promo.promo_usage / services.hydrate_promo_usage). Whatever a
|
|
# client sends back here is ignored — it is never the source of truth.
|
|
used_count: int = 0
|
|
|
|
# stored form: stripped + upper-case, never empty
|
|
@validator("code")
|
|
def uppercase_code(cls, v):
|
|
v = (v or "").strip().upper()
|
|
if not v:
|
|
raise ValueError("Promo code cannot be empty.")
|
|
return v
|
|
|
|
@validator("discount_percent")
|
|
def validate_discount_percent(cls, v):
|
|
assert 0 <= v <= 100, "Discount must be between 0 and 100."
|
|
return v
|
|
|
|
@validator("max_uses", pre=True)
|
|
def normalize_max_uses(cls, v):
|
|
if v in (None, "", 0, "0"):
|
|
return None
|
|
v = int(v)
|
|
if v < 1:
|
|
raise ValueError("max_uses must be at least 1.")
|
|
return v
|
|
|
|
|
|
class TicketWave(BaseModel):
|
|
id: str = Field(default_factory=lambda: uuid4().hex[:8])
|
|
title: str = "Primary wave"
|
|
opening_date: str
|
|
closing_date: str
|
|
currency: str = "sat"
|
|
use_ticket_image: bool = False
|
|
ticket_image_id: str | None = None
|
|
allow_fiat: bool = False
|
|
fiat_currency: str = "GBP"
|
|
amount_tickets: int = Field(default=0, ge=0)
|
|
price_per_ticket: float = Field(default=0, ge=0)
|
|
|
|
|
|
class EventExtraBase(BaseModel):
|
|
"""Everything in `extra` that is safe to show anyone. `EventExtra` adds
|
|
the organizer-only promo codes on top; `PublicEventExtra` is this base,
|
|
so anonymous responses can never carry them."""
|
|
|
|
conditional: bool = False
|
|
min_tickets: int = 1
|
|
email_notifications: bool = False
|
|
nostr_notifications: bool = False
|
|
notification_subject: str = ""
|
|
notification_body: str = ""
|
|
# Rails the organizer accepts for this event. Empty = legacy rule
|
|
# ("lightning" always, "fiat" when allow_fiat) — see
|
|
# `effective_payment_methods`. Same field name/shape as upstream v2 so the
|
|
# eventual rebase (#33) merges cleanly.
|
|
payment_methods: list[str] = Field(default_factory=list)
|
|
|
|
@validator("payment_methods", pre=True)
|
|
def normalize_payment_methods(cls, v):
|
|
if not v:
|
|
return []
|
|
if isinstance(v, str):
|
|
v = v.split(",")
|
|
seen: list[str] = []
|
|
for method in v:
|
|
method = str(method).strip().lower()
|
|
if method not in PAYMENT_METHODS:
|
|
raise ValueError(f"Unsupported payment method: {method}")
|
|
if method not in seen:
|
|
seen.append(method)
|
|
return seen
|
|
|
|
|
|
class EventExtra(EventExtraBase):
|
|
promo_codes: list[PromoCode] = Field(default_factory=list)
|
|
# Upstream v1.6.8 ticket waves — time-boxed pricing tiers. The
|
|
# event-level `currency` / `allow_fiat` / `amount_tickets` /
|
|
# `price_per_ticket` fields become derived values (see
|
|
# `sync_event_ticket_waves`), which is why fork code that reads them
|
|
# needs auditing — aiolabs/events#61.
|
|
ticket_waves: list[TicketWave] = Field(default_factory=list)
|
|
|
|
|
|
PublicEventExtra = EventExtraBase
|
|
|
|
|
|
class CreateEvent(BaseModel):
|
|
wallet: str | None = None # filled from caller's wallet if absent
|
|
name: str # title (required)
|
|
info: str = "" # description (optional)
|
|
closing_date: str | None = None # date-only YYYY-MM-DD; defaults to event_end_date
|
|
# ISO 8601: date-only ("2026-05-19") or datetime ("2026-05-19T18:30").
|
|
# Presence of a "T" toggles NIP-52 kind (31922 date / 31923 time).
|
|
event_start_date: str
|
|
event_end_date: str | None = None # same format as event_start_date
|
|
currency: str = "sat"
|
|
allow_fiat: bool = False
|
|
fiat_currency: str = "GBP"
|
|
# Capacity is always required and there is no unlimited (#34): a zero
|
|
# here means sold out / not sellable, which `api_get_event` and
|
|
# `api_ticket_create` both enforce with a 410. Under v1.6.8 waves this
|
|
# is only the seed for the primary wave — `sync_event_ticket_waves`
|
|
# recomputes it as the sum of every wave's remaining stock.
|
|
amount_tickets: int = 0
|
|
price_per_ticket: float = 0 # 0 = free
|
|
banner: str | None = None
|
|
location: str | None = None # venue/address (NIP-52 'location' tag)
|
|
categories: list[str] = Field(default_factory=list) # NIP-52 't' tags
|
|
extra: EventExtra = Field(default_factory=EventExtra)
|
|
status: str = "approved" # proposed, approved, rejected
|
|
|
|
|
|
class Event(BaseModel):
|
|
id: str
|
|
wallet: str
|
|
name: str
|
|
info: str = ""
|
|
closing_date: str | None = None
|
|
canceled: bool = False
|
|
event_start_date: str
|
|
event_end_date: str | None = None
|
|
currency: str = "sat"
|
|
allow_fiat: bool = False
|
|
fiat_currency: str = "GBP"
|
|
amount_tickets: int = 0
|
|
price_per_ticket: float = 0
|
|
time: datetime
|
|
sold: int = 0
|
|
banner: str | None = None
|
|
location: str | None = None
|
|
categories: list[str] = Field(default_factory=list)
|
|
extra: EventExtra = Field(default_factory=EventExtra)
|
|
status: str = "approved"
|
|
nostr_event_id: str | None = None
|
|
nostr_event_created_at: int | None = None
|
|
# Set before every publish attempt, cleared on confirmed success.
|
|
# True means the relay's copy may be behind this row — see
|
|
# migrations_fork.m003 and the sweep in __init__.events_start.
|
|
nostr_publish_pending: bool = False
|
|
# Which wave the last successful publish advertised (see
|
|
# `advertised_wave_key`). NULL = never published; "" = published while
|
|
# nothing was on sale. The sweep compares this against the current key
|
|
# to catch wave boundaries, which are time-driven and so fire no
|
|
# sale-triggered republish (aiolabs/events#61).
|
|
nostr_published_wave_id: str | None = None
|
|
|
|
@validator("categories", pre=True)
|
|
def parse_categories(cls, v):
|
|
if isinstance(v, str):
|
|
return json.loads(v) if v else []
|
|
return v or []
|
|
|
|
|
|
class PublicEvent(BaseModel):
|
|
id: str
|
|
name: str
|
|
info: str
|
|
closing_date: str | None = None
|
|
canceled: bool
|
|
event_start_date: str
|
|
event_end_date: str | None = None
|
|
currency: str
|
|
allow_fiat: bool = False
|
|
fiat_currency: str = "GBP"
|
|
price_per_ticket: float
|
|
banner: str | None
|
|
location: str | None = None
|
|
categories: list[str] = Field(default_factory=list)
|
|
# PublicEventExtra: promo codes are organizer-only (a buyer who can read
|
|
# every code can mint every discount).
|
|
extra: PublicEventExtra = Field(default_factory=PublicEventExtra)
|
|
status: str = "approved" # surfaces "proposed"/"rejected" so SFC can render banner
|
|
|
|
@validator("categories", pre=True)
|
|
def parse_categories(cls, v):
|
|
if isinstance(v, str):
|
|
return json.loads(v) if v else []
|
|
return v or []
|
|
|
|
|
|
def effective_payment_methods(
|
|
event: "Event | PublicEvent | CreateEvent",
|
|
wave: "TicketWave | None" = None,
|
|
) -> list[str]:
|
|
"""Rails a buyer may pick for `event`.
|
|
|
|
Explicit `extra.payment_methods` wins; an empty list falls back to the
|
|
pre-#payment-methods rule so events created before the field existed
|
|
keep behaving the same (Lightning always, fiat iff `allow_fiat`).
|
|
|
|
Pass `wave` when the answer is about one specific ticket wave. Fiat is a
|
|
per-wave opt-in since v1.6.8 and `event.allow_fiat` is only the PRIMARY
|
|
wave's, so without it a publisher can advertise a fiat rail for an
|
|
advertised wave that does not accept fiat — which the purchase endpoint
|
|
then rejects (aiolabs/events#61). Callers asking the event-level
|
|
question ("which rails did the organiser enable at all") leave it unset.
|
|
"""
|
|
explicit = list(getattr(event.extra, "payment_methods", []) or [])
|
|
if explicit:
|
|
return explicit
|
|
methods = ["lightning"]
|
|
if wave.allow_fiat if wave is not None else event.allow_fiat:
|
|
methods.append("fiat")
|
|
return methods
|
|
|
|
|
|
class PromoValidateRequest(BaseModel):
|
|
"""Upstream v2 shape. v2 sends `items` (ticket types); this fork prices a
|
|
plain `quantity` against one ticket wave.
|
|
|
|
`ticket_wave_id` may be omitted when exactly one wave is open, matching
|
|
how the purchase endpoint resolves it — the preview has to price the same
|
|
wave the invoice will, and since v1.6.8 price and currency are per-wave.
|
|
"""
|
|
|
|
codes: list[str] = Field(default_factory=list)
|
|
quantity: int = Field(default=1, ge=1, le=10)
|
|
ticket_wave_id: str | None = None
|
|
|
|
|
|
class BasketDiscount(BaseModel):
|
|
code: str
|
|
discount_percent: float | None = None
|
|
discount_fixed: int | None = None # always None here (percent-only); v2 shape
|
|
amount_saved: float = 0
|
|
|
|
|
|
class BasketTotals(BaseModel):
|
|
subtotal: float = 0
|
|
discount: float = 0
|
|
total: float = 0
|
|
discounts_applied: list[BasketDiscount] = Field(default_factory=list)
|
|
currency: str = "sat" # fork addition so a client can format the numbers
|
|
|
|
|
|
class EventsSettings(BaseModel):
|
|
"""Extension-level settings for the events extension."""
|
|
|
|
auto_approve: bool = False # Skip approval workflow for non-admin users
|
|
|
|
|
|
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
|
|
ticket_base_url: str | None = None
|
|
email_notification_sent: bool = False
|
|
nostr_notification_sent: bool = False
|
|
refunded: bool = False
|
|
# On-chain vocabulary, populated once native lnbits on-chain lands
|
|
# (aiolabs/events#41). Upstream's field names, minus the SatsPay charge
|
|
# id — SatsPay is the implementation we are not adopting.
|
|
onchain: bool = False
|
|
onchain_address: str | None = None
|
|
|
|
|
|
class CreateTicket(BaseModel):
|
|
name: str | None = None
|
|
email: EmailStr | None = None
|
|
user_id: str | None = None # LNbits user id (alternative to name+email)
|
|
ticket_wave_id: str | None = None
|
|
promo_code: str | None = None
|
|
refund_address: str | None = None
|
|
nostr_identifier: str | None = None
|
|
payment_method: str | None = None
|
|
fiat_provider: str | None = None
|
|
# Number of tickets to buy on this single invoice. Bounded so a
|
|
# bad client can't run away with the organizer's capacity.
|
|
quantity: int = Field(default=1, ge=1, le=10)
|
|
# App root of the client that is buying (e.g. https://app.example/events).
|
|
# The extension builds the Stripe success/cancel URLs and the emailed
|
|
# ticket link under it, so the buyer lands back in the app they came
|
|
# from. Origin is allow-listed server-side (see `_resolve_frontend_root`);
|
|
# absent = today's behaviour (the LNbits host).
|
|
frontend_url: str | None = Field(default=None, max_length=512)
|
|
|
|
@validator("frontend_url")
|
|
def validate_frontend_url(cls, v):
|
|
if v is None:
|
|
return None
|
|
v = v.strip()
|
|
if not v:
|
|
return None
|
|
parts = urlsplit(v)
|
|
if parts.scheme not in ("http", "https") or not parts.netloc:
|
|
raise ValueError("frontend_url must be an absolute http(s) URL")
|
|
if parts.query or parts.fragment or ".." in parts.path:
|
|
raise ValueError("frontend_url must not contain a query, fragment or '..'")
|
|
return v.rstrip("/")
|
|
|
|
@root_validator
|
|
def validate_identifiers(cls, values):
|
|
"""A ticket needs an identity: an LNbits `user_id`, or `name` +
|
|
`email` for guests. A logged-in buyer may add `email` (and `name`)
|
|
on top of `user_id` so the ticket can be emailed to them."""
|
|
name = values.get("name")
|
|
email = values.get("email")
|
|
user_id = values.get("user_id")
|
|
if not user_id and not (name and email):
|
|
raise ValueError("Either user_id or both name and email must be provided")
|
|
return values
|
|
|
|
|
|
class Ticket(BaseModel):
|
|
id: str
|
|
wallet: str
|
|
event: str
|
|
name: str | None = None
|
|
email: str | None = None
|
|
user_id: str | None = None
|
|
registered: bool
|
|
paid: bool
|
|
time: datetime
|
|
reg_timestamp: datetime
|
|
extra: TicketExtra = Field(default_factory=TicketExtra)
|
|
# Shared LNbits invoice payment_hash. Equals `id` for single-ticket
|
|
# purchases (legacy + post-migration default). Multi-ticket
|
|
# purchases create N rows sharing one payment_hash so each attendee
|
|
# gets a distinct scannable id while the buyer pays once.
|
|
payment_hash: str | None = None
|
|
|
|
|
|
class NotificationDeliveryResult(BaseModel):
|
|
attempted: bool = False
|
|
sent: bool = False
|
|
error: str | None = None
|
|
|
|
|
|
class TicketResendResult(BaseModel):
|
|
ticket: Ticket
|
|
email: NotificationDeliveryResult = Field(
|
|
default_factory=NotificationDeliveryResult
|
|
)
|
|
nostr: NotificationDeliveryResult = Field(
|
|
default_factory=NotificationDeliveryResult
|
|
)
|
|
|
|
|
|
class PublicTicket(BaseModel):
|
|
event: str
|
|
name: str | None = None
|
|
registered: bool
|
|
paid: bool
|
|
time: datetime
|
|
reg_timestamp: datetime
|
|
|
|
|
|
class TicketPaymentRequest(BaseModel):
|
|
payment_hash: str
|
|
payment_request: str | None = None
|
|
fiat_payment_request: str | None = None
|
|
fiat_provider: str | None = None
|
|
is_fiat: bool = False
|
|
# True when the tickets are already issued + paid with no invoice to
|
|
# settle — free events (price 0) or a 100%-off promo. The client skips
|
|
# the QR / payment-poll step and goes straight to the ticket QRs.
|
|
paid: bool = False
|
|
# Row ids created on this invoice — one for single-ticket
|
|
# purchases, N for multi-ticket (each independently scannable at
|
|
# the door). Buyers fetch these after payment to render N QRs in
|
|
# My Tickets.
|
|
ticket_ids: list[str] = Field(default_factory=list)
|
|
onchain_amount_sat: int | None = None
|
|
|
|
|
|
class TicketFilters(FilterModel):
|
|
__search_fields__ = ["event", "name", "email", "id"] # noqa: RUF012
|
|
__sort_fields__ = [ # noqa: RUF012
|
|
"time",
|
|
"event",
|
|
"name",
|
|
"email",
|
|
"registered",
|
|
"id",
|
|
]
|
|
|
|
event: str | None = None
|
|
name: str | None = None
|
|
email: str | None = None
|
|
registered: bool | None = None
|
|
paid: bool | None = None
|
|
id: str | None = None
|
|
|
|
|
|
def _parse_date(value: str) -> date:
|
|
"""Date component of `value`.
|
|
|
|
Upstream only ever produces bare `YYYY-MM-DD` here, so its version is a
|
|
plain `strptime(value, "%Y-%m-%d")`. In this fork `event_end_date` may
|
|
carry a time (start/end times, v1.3.0-aio.3) and `create_event` defaults
|
|
`closing_date` to it, so a wave derived from an event inherits the full
|
|
ISO datetime and upstream's parser raises
|
|
`ValueError: unconverted data remains: T18:00:00` — on the purchase path,
|
|
the public event gate, and the promo preview.
|
|
"""
|
|
return date.fromisoformat(value[:10])
|
|
|
|
|
|
def ensure_ticket_waves(event: Event | PublicEvent | CreateEvent) -> list[TicketWave]:
|
|
ticket_waves = list(getattr(event.extra, "ticket_waves", []) or [])
|
|
if ticket_waves:
|
|
return ticket_waves
|
|
|
|
# `TicketWave` requires both dates; `Event.closing_date` is Optional in
|
|
# this fork (it defaults from event_end_date at create time), so fall
|
|
# back the same way `create_event` does rather than handing None to a
|
|
# required field.
|
|
closing_date = event.closing_date or event.event_end_date or event.event_start_date
|
|
|
|
fallback_opening_date = None
|
|
event_time = getattr(event, "time", None)
|
|
if event_time:
|
|
fallback_opening_date = event_time.date().isoformat()
|
|
if not fallback_opening_date:
|
|
fallback_opening_date = closing_date
|
|
|
|
return [
|
|
TicketWave(
|
|
id="primary",
|
|
title="Primary wave",
|
|
opening_date=fallback_opening_date,
|
|
closing_date=closing_date,
|
|
currency=event.currency,
|
|
allow_fiat=event.allow_fiat,
|
|
fiat_currency=event.fiat_currency,
|
|
amount_tickets=getattr(event, "amount_tickets", 0),
|
|
price_per_ticket=event.price_per_ticket,
|
|
)
|
|
]
|
|
|
|
|
|
def advertised_ticket_wave(event: "Event | PublicEvent") -> "TicketWave | None":
|
|
"""The wave a public listing should describe, or None when nothing is
|
|
on sale (sold out, between waves, or not yet open).
|
|
|
|
Several waves can be open at once. The purchase endpoint refuses to
|
|
guess; a publisher has no one to ask, so it advertises the CHEAPEST
|
|
open wave — the price a buyer is actually able to obtain.
|
|
|
|
Shared by the NIP-52 publisher and the wave-transition detector so the
|
|
two cannot disagree about which wave is currently being advertised.
|
|
"""
|
|
active = get_active_ticket_waves(event)
|
|
if not active:
|
|
return None
|
|
return min(active, key=lambda wave: wave.price_per_ticket)
|
|
|
|
|
|
def advertised_wave_key(event: "Event | PublicEvent") -> str:
|
|
"""Stable key for what a publish advertised. Empty string means "nothing
|
|
on sale", which is a real published state and distinct from NULL in
|
|
`nostr_published_wave_id` (never published)."""
|
|
wave = advertised_ticket_wave(event)
|
|
return wave.id if wave else ""
|
|
|
|
|
|
def sync_event_ticket_waves(event: Event | CreateEvent) -> Event | CreateEvent:
|
|
ticket_waves = ensure_ticket_waves(event)
|
|
event.extra.ticket_waves = ticket_waves
|
|
|
|
primary_wave = ticket_waves[0]
|
|
event.closing_date = max(wave.closing_date for wave in ticket_waves)
|
|
event.currency = primary_wave.currency
|
|
event.allow_fiat = primary_wave.allow_fiat
|
|
event.fiat_currency = primary_wave.fiat_currency
|
|
event.amount_tickets = sum(wave.amount_tickets for wave in ticket_waves)
|
|
event.price_per_ticket = primary_wave.price_per_ticket
|
|
|
|
return event
|
|
|
|
|
|
def get_active_ticket_waves(
|
|
event: Event | PublicEvent, today: date | None = None
|
|
) -> list[TicketWave]:
|
|
current_day = today or datetime.utcnow().date()
|
|
return [
|
|
wave
|
|
for wave in ensure_ticket_waves(event)
|
|
if _parse_date(wave.opening_date)
|
|
<= current_day
|
|
<= _parse_date(wave.closing_date)
|
|
and wave.amount_tickets > 0
|
|
]
|