feat: let a user_id ticket carry an email, accept frontend_url
`CreateTicket` no longer rejects `user_id` together with `name`/`email`
(the exclusion was a fork-only dispatch convenience from dfabcb8; nothing
needed it). `crud.create_ticket` stops blanking name/email when a user_id
is present, so logged-in webapp buyers can have their ticket emailed —
until now `_send_ticket_notification` short-circuited on the empty
address for every app purchase.
New optional `frontend_url` (absolute http(s) root, no query/fragment/..,
trailing slash stripped) lets a buyer-side client name the app the buyer
should be returned to and linked into from the ticket email; the origin
allow-list lives in views_api.
Also folds in the pending black reflow of migrations_fork.py.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
This commit is contained in:
parent
fe9f005b53
commit
db708cf0da
5 changed files with 242 additions and 11 deletions
80
models.py
80
models.py
|
|
@ -1,8 +1,11 @@
|
|||
import json
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, root_validator, validator
|
||||
|
||||
PAYMENT_METHODS = ("lightning", "fiat")
|
||||
|
||||
|
||||
class PromoCode(BaseModel):
|
||||
code: str
|
||||
|
|
@ -28,6 +31,26 @@ class EventExtra(BaseModel):
|
|||
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 CreateEvent(BaseModel):
|
||||
|
|
@ -107,6 +130,22 @@ class PublicEvent(BaseModel):
|
|||
return v or []
|
||||
|
||||
|
||||
def effective_payment_methods(event: "Event | PublicEvent | CreateEvent") -> 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`).
|
||||
"""
|
||||
explicit = list(getattr(event.extra, "payment_methods", []) or [])
|
||||
if explicit:
|
||||
return explicit
|
||||
methods = ["lightning"]
|
||||
if event.allow_fiat:
|
||||
methods.append("fiat")
|
||||
return methods
|
||||
|
||||
|
||||
class EventsSettings(BaseModel):
|
||||
"""Extension-level settings for the events extension."""
|
||||
|
||||
|
|
@ -136,16 +175,37 @@ class CreateTicket(BaseModel):
|
|||
# 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")
|
||||
if user_id and (name or email):
|
||||
raise ValueError("Cannot provide both user_id and name/email")
|
||||
return values
|
||||
|
||||
|
||||
|
|
@ -168,6 +228,22 @@ class Ticket(BaseModel):
|
|||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue