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
10
crud.py
10
crud.py
|
|
@ -55,12 +55,10 @@ async def create_ticket(
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
row_id = ticket_id or payment_hash
|
row_id = ticket_id or payment_hash
|
||||||
|
|
||||||
# name/email columns are NOT NULL in the schema, so we store "" when only
|
# name/email columns are NOT NULL in the schema, so we store "" when a
|
||||||
# user_id is supplied. _parse_ticket_row reverses this on read.
|
# value is absent. _parse_ticket_row reverses this on read. A user_id
|
||||||
if user_id:
|
# ticket may carry an email too — that is how logged-in webapp buyers get
|
||||||
db_name = ""
|
# their ticket emailed.
|
||||||
db_email = ""
|
|
||||||
else:
|
|
||||||
db_name = name or ""
|
db_name = name or ""
|
||||||
db_email = email or ""
|
db_email = email or ""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -127,4 +127,3 @@ async def m002_ticket_payment_hash(db):
|
||||||
"UPDATE events.ticket SET payment_hash = id "
|
"UPDATE events.ticket SET payment_hash = id "
|
||||||
"WHERE payment_hash IS NULL OR payment_hash = ''"
|
"WHERE payment_hash IS NULL OR payment_hash = ''"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
80
models.py
80
models.py
|
|
@ -1,8 +1,11 @@
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from pydantic import BaseModel, EmailStr, Field, root_validator, validator
|
from pydantic import BaseModel, EmailStr, Field, root_validator, validator
|
||||||
|
|
||||||
|
PAYMENT_METHODS = ("lightning", "fiat")
|
||||||
|
|
||||||
|
|
||||||
class PromoCode(BaseModel):
|
class PromoCode(BaseModel):
|
||||||
code: str
|
code: str
|
||||||
|
|
@ -28,6 +31,26 @@ class EventExtra(BaseModel):
|
||||||
nostr_notifications: bool = False
|
nostr_notifications: bool = False
|
||||||
notification_subject: str = ""
|
notification_subject: str = ""
|
||||||
notification_body: 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):
|
class CreateEvent(BaseModel):
|
||||||
|
|
@ -107,6 +130,22 @@ class PublicEvent(BaseModel):
|
||||||
return v or []
|
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):
|
class EventsSettings(BaseModel):
|
||||||
"""Extension-level settings for the events extension."""
|
"""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
|
# Number of tickets to buy on this single invoice. Bounded so a
|
||||||
# bad client can't run away with the organizer's capacity.
|
# bad client can't run away with the organizer's capacity.
|
||||||
quantity: int = Field(default=1, ge=1, le=10)
|
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
|
@root_validator
|
||||||
def validate_identifiers(cls, values):
|
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")
|
name = values.get("name")
|
||||||
email = values.get("email")
|
email = values.get("email")
|
||||||
user_id = values.get("user_id")
|
user_id = values.get("user_id")
|
||||||
if not user_id and not (name and email):
|
if not user_id and not (name and email):
|
||||||
raise ValueError("Either user_id or both name and email must be provided")
|
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
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -168,6 +228,22 @@ class Ticket(BaseModel):
|
||||||
payment_hash: str | None = None
|
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):
|
class PublicTicket(BaseModel):
|
||||||
event: str
|
event: str
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
|
|
|
||||||
48
tests/test_crud_ticket_email.py
Normal file
48
tests/test_crud_ticket_email.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from .. import crud
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_ticket_keeps_email_alongside_user_id(monkeypatch):
|
||||||
|
inserted = {}
|
||||||
|
|
||||||
|
async def fake_insert(table, model):
|
||||||
|
inserted["table"] = table
|
||||||
|
inserted["model"] = model
|
||||||
|
|
||||||
|
monkeypatch.setattr(crud.db, "insert", AsyncMock(side_effect=fake_insert))
|
||||||
|
|
||||||
|
ticket = await crud.create_ticket(
|
||||||
|
payment_hash="hash",
|
||||||
|
wallet="w",
|
||||||
|
event="e",
|
||||||
|
name="Ada",
|
||||||
|
email="ada@example.com",
|
||||||
|
user_id="u1",
|
||||||
|
ticket_id="t1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert inserted["table"] == "events.ticket"
|
||||||
|
assert inserted["model"].user_id == "u1"
|
||||||
|
assert inserted["model"].email == "ada@example.com"
|
||||||
|
assert inserted["model"].name == "Ada"
|
||||||
|
assert ticket.email == "ada@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_ticket_stores_empty_string_sentinels(monkeypatch):
|
||||||
|
inserted = {}
|
||||||
|
|
||||||
|
async def fake_insert(table, model):
|
||||||
|
inserted["model"] = model
|
||||||
|
|
||||||
|
monkeypatch.setattr(crud.db, "insert", AsyncMock(side_effect=fake_insert))
|
||||||
|
|
||||||
|
await crud.create_ticket(
|
||||||
|
payment_hash="hash", wallet="w", event="e", user_id="u1", ticket_id="t2"
|
||||||
|
)
|
||||||
|
assert inserted["model"].email == ""
|
||||||
|
assert inserted["model"].name == ""
|
||||||
110
tests/test_ticket_models.py
Normal file
110
tests/test_ticket_models.py
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from ..models import (
|
||||||
|
CreateEvent,
|
||||||
|
CreateTicket,
|
||||||
|
EventExtra,
|
||||||
|
effective_payment_methods,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ticket(**kwargs) -> CreateTicket:
|
||||||
|
return CreateTicket(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_id_only_is_a_valid_identity():
|
||||||
|
assert _ticket(user_id="u1").user_id == "u1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_name_and_email_is_a_valid_guest_identity():
|
||||||
|
ticket = _ticket(name="Guest", email="guest@example.com")
|
||||||
|
assert ticket.user_id is None
|
||||||
|
assert ticket.email == "guest@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_id_may_carry_an_email_for_delivery():
|
||||||
|
ticket = _ticket(user_id="u1", email="me@example.com")
|
||||||
|
assert ticket.user_id == "u1"
|
||||||
|
assert ticket.email == "me@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"kwargs",
|
||||||
|
[
|
||||||
|
{},
|
||||||
|
{"name": "Guest"},
|
||||||
|
{"email": "guest@example.com"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_missing_identity_is_rejected(kwargs):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
_ticket(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"url,expected",
|
||||||
|
[
|
||||||
|
("https://app.example/events", "https://app.example/events"),
|
||||||
|
("https://app.example/events/", "https://app.example/events"),
|
||||||
|
("http://localhost:5173/", "http://localhost:5173"),
|
||||||
|
(" ", None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_frontend_url_is_normalised(url, expected):
|
||||||
|
assert _ticket(user_id="u1", frontend_url=url).frontend_url == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"url",
|
||||||
|
[
|
||||||
|
"/events", # relative
|
||||||
|
"ftp://app.example/events",
|
||||||
|
"https://app.example/events?x=1",
|
||||||
|
"https://app.example/events#top",
|
||||||
|
"https://app.example/../events",
|
||||||
|
"https://" + "a" * 520,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_frontend_url_rejects_unsafe_values(url):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
_ticket(user_id="u1", frontend_url=url)
|
||||||
|
|
||||||
|
|
||||||
|
def _event(**overrides) -> CreateEvent:
|
||||||
|
data = {
|
||||||
|
"wallet": "w",
|
||||||
|
"name": "Test",
|
||||||
|
"info": "",
|
||||||
|
"closing_date": "2030-01-01",
|
||||||
|
"event_start_date": "2030-01-01",
|
||||||
|
"event_end_date": "2030-01-02",
|
||||||
|
"amount_tickets": 10,
|
||||||
|
"price_per_ticket": 5,
|
||||||
|
}
|
||||||
|
data.update(overrides)
|
||||||
|
return CreateEvent(**data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_effective_payment_methods_legacy_rule():
|
||||||
|
assert effective_payment_methods(_event()) == ["lightning"]
|
||||||
|
assert effective_payment_methods(_event(allow_fiat=True)) == ["lightning", "fiat"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_effective_payment_methods_explicit_list_wins():
|
||||||
|
event = _event(allow_fiat=True, extra=EventExtra(payment_methods=["fiat"]))
|
||||||
|
assert effective_payment_methods(event) == ["fiat"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_payment_methods_are_normalised_and_deduplicated():
|
||||||
|
extra = EventExtra(payment_methods=["Fiat", " lightning ", "fiat"])
|
||||||
|
assert extra.payment_methods == ["fiat", "lightning"]
|
||||||
|
assert EventExtra(payment_methods="lightning,fiat").payment_methods == [
|
||||||
|
"lightning",
|
||||||
|
"fiat",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_payment_method_is_rejected():
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
EventExtra(payment_methods=["cash"])
|
||||||
Loading…
Add table
Add a link
Reference in a new issue