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
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
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue