events/models.py
Padreug ecd05b4168
Some checks failed
lint.yml / fix: require a capacity on every ticket wave (pull_request) Failing after 0s
fix: require a capacity on every ticket wave
"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.
2026-09-28 23:11:30 +02:00

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
]