diff --git a/crud.py b/crud.py index 551a3bc..bd5d3c1 100644 --- a/crud.py +++ b/crud.py @@ -55,14 +55,12 @@ async def create_ticket( now = datetime.now(timezone.utc) row_id = ticket_id or payment_hash - # name/email columns are NOT NULL in the schema, so we store "" when only - # user_id is supplied. _parse_ticket_row reverses this on read. - if user_id: - db_name = "" - db_email = "" - else: - db_name = name or "" - db_email = email or "" + # name/email columns are NOT NULL in the schema, so we store "" when a + # value is absent. _parse_ticket_row reverses this on read. A user_id + # ticket may carry an email too — that is how logged-in webapp buyers get + # their ticket emailed. + db_name = name or "" + db_email = email or "" db_ticket = Ticket( id=row_id, diff --git a/migrations_fork.py b/migrations_fork.py index 864cbb8..ebe65a2 100644 --- a/migrations_fork.py +++ b/migrations_fork.py @@ -127,4 +127,3 @@ async def m002_ticket_payment_hash(db): "UPDATE events.ticket SET payment_hash = id " "WHERE payment_hash IS NULL OR payment_hash = ''" ) - diff --git a/models.py b/models.py index 7f1feac..e36c60a 100644 --- a/models.py +++ b/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 diff --git a/tests/test_crud_ticket_email.py b/tests/test_crud_ticket_email.py new file mode 100644 index 0000000..de1d6ef --- /dev/null +++ b/tests/test_crud_ticket_email.py @@ -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 == "" diff --git a/tests/test_ticket_models.py b/tests/test_ticket_models.py new file mode 100644 index 0000000..7d1ab3f --- /dev/null +++ b/tests/test_ticket_models.py @@ -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"])