Merge pull request 'feat: guest checkout, Stripe return flow, emailed QR tickets, per-event payment methods (v1.6.1-aio.8)' (#36) from feat/guest-checkout-email into main
Reviewed-on: #36
This commit is contained in:
commit
b8fb3ee441
20 changed files with 844 additions and 114 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -2,3 +2,6 @@ __pycache__
|
||||||
node_modules
|
node_modules
|
||||||
.mypy_cache
|
.mypy_cache
|
||||||
.venv
|
.venv
|
||||||
|
|
||||||
|
# lnbits data dir created by `make test` (settings default lnbits_data_folder)
|
||||||
|
data/
|
||||||
|
|
|
||||||
7
Makefile
7
Makefile
|
|
@ -30,10 +30,15 @@ checkblack:
|
||||||
checkeditorconfig:
|
checkeditorconfig:
|
||||||
editorconfig-checker
|
editorconfig-checker
|
||||||
|
|
||||||
|
# The uv env resolves *upstream* lnbits from PyPI, which lacks the aio fork's
|
||||||
|
# modules (lnbits.core.signers, …). Point PYTHONPATH at a fork checkout so
|
||||||
|
# `import lnbits` picks it up; override with LNBITS_SRC=/path/to/lnbits.
|
||||||
|
LNBITS_SRC ?= $(HOME)/dev/lnbits/dev
|
||||||
test:
|
test:
|
||||||
|
PYTHONPATH=$(LNBITS_SRC) \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
DEBUG=true \
|
DEBUG=true \
|
||||||
uv run pytest
|
uv run --frozen pytest
|
||||||
install-pre-commit-hook:
|
install-pre-commit-hook:
|
||||||
@echo "Installing pre-commit hook to git"
|
@echo "Installing pre-commit hook to git"
|
||||||
@echo "Uninstall the hook with uv run pre-commit uninstall"
|
@echo "Uninstall the hook with uv run pre-commit uninstall"
|
||||||
|
|
|
||||||
27
README.md
27
README.md
|
|
@ -23,7 +23,6 @@ Events includes a shareable ticket scanner, which can be used to register attend
|
||||||
1. Create an event\
|
1. Create an event\
|
||||||

|

|
||||||
2. Fill out the event information:
|
2. Fill out the event information:
|
||||||
|
|
||||||
- event name
|
- event name
|
||||||
- wallet (normally there's only one)
|
- wallet (normally there's only one)
|
||||||
- event information
|
- event information
|
||||||
|
|
@ -34,7 +33,6 @@ Events includes a shareable ticket scanner, which can be used to register attend
|
||||||
|
|
||||||
3. Share the event registration link\
|
3. Share the event registration link\
|
||||||

|

|
||||||
|
|
||||||
- ticket example\
|
- ticket example\
|
||||||

|

|
||||||
|
|
||||||
|
|
@ -44,6 +42,31 @@ Events includes a shareable ticket scanner, which can be used to register attend
|
||||||
4. Use the built-in ticket scanner to validate registered, and paid, attendees\
|
4. Use the built-in ticket scanner to validate registered, and paid, attendees\
|
||||||

|

|
||||||
|
|
||||||
|
## Guest checkout, card payments and email delivery (aio fork)
|
||||||
|
|
||||||
|
- **Identity.** `POST /events/api/v1/tickets/{event_id}` accepts either an
|
||||||
|
LNbits `user_id` or a guest `name` + `email`; a `user_id` ticket may also
|
||||||
|
carry an `email` so logged-in buyers get their ticket mailed.
|
||||||
|
- **Payment methods.** `extra.payment_methods` (`lightning`, `fiat`) lists the
|
||||||
|
rails an event accepts; an empty list keeps the legacy rule (Lightning always,
|
||||||
|
fiat when `allow_fiat`). The effective list is published on the NIP-52 event
|
||||||
|
as `tickets_payment_methods` and enforced at purchase.
|
||||||
|
- **Return to the calling app.** A client may send `frontend_url` (its app
|
||||||
|
root, e.g. `https://app.example/events`). Its origin must be one of
|
||||||
|
`LNBITS_CORS_ALLOWED_ORIGINS`, the LNbits base URL or
|
||||||
|
`LNBITS_CUSTOM_FRONTEND_URL`, otherwise the request is refused. Under that
|
||||||
|
root the extension builds the Stripe `success_url`
|
||||||
|
(`/events/{event_id}?checkout=success&tickets=<id,id>`), `cancel_url`
|
||||||
|
(`/events/{event_id}?checkout=cancelled`) and the emailed ticket link
|
||||||
|
(`/events/ticket/{ticket_id}`). Absent, the LNbits host is used as before.
|
||||||
|
- **Stripe session.** The buyer's email is passed as `customer_email`
|
||||||
|
(prefilled and locked on the hosted page); the line item is named after the
|
||||||
|
event; `event_id`, `quantity` and `ticket_ids` ride along as metadata.
|
||||||
|
- **Email.** Multipart text + HTML with the ticket QR embedded from
|
||||||
|
`GET /events/api/v1/qr/{ticket_id}` (PNG, branded with the instance QR logo).
|
||||||
|
`POST /events/api/v1/tickets/{ticket_id}/resend-email` returns a
|
||||||
|
`TicketResendResult` with per-channel outcome.
|
||||||
|
|
||||||
## Powered by LNbits
|
## Powered by LNbits
|
||||||
|
|
||||||
[LNbits](https://lnbits.com) is a free and open-source lightning accounts system.
|
[LNbits](https://lnbits.com) is a free and open-source lightning accounts system.
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,13 @@ from loguru import logger
|
||||||
from .crud import db
|
from .crud import db
|
||||||
from .tasks import wait_for_paid_invoices
|
from .tasks import wait_for_paid_invoices
|
||||||
from .views import events_generic_router
|
from .views import events_generic_router
|
||||||
from .views_api import events_api_router, tickets_api_router
|
from .views_api import events_api_router, qr_api_router, tickets_api_router
|
||||||
|
|
||||||
events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"])
|
events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"])
|
||||||
events_ext.include_router(events_generic_router)
|
events_ext.include_router(events_generic_router)
|
||||||
events_ext.include_router(events_api_router)
|
events_ext.include_router(events_api_router)
|
||||||
events_ext.include_router(tickets_api_router)
|
events_ext.include_router(tickets_api_router)
|
||||||
|
events_ext.include_router(qr_api_router)
|
||||||
|
|
||||||
events_static_files = [
|
events_static_files = [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"id": "events",
|
"id": "events",
|
||||||
"version": "1.6.1-aio.7",
|
"version": "1.6.1-aio.8",
|
||||||
"name": "Events",
|
"name": "Events",
|
||||||
"repo": "https://git.atitlan.io/aiolabs/events",
|
"repo": "https://git.atitlan.io/aiolabs/events",
|
||||||
"short_description": "Sell and register event tickets",
|
"short_description": "Sell and register event tickets",
|
||||||
|
|
|
||||||
14
crud.py
14
crud.py
|
|
@ -55,14 +55,12 @@ 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 = ""
|
db_name = name or ""
|
||||||
else:
|
db_email = email or ""
|
||||||
db_name = name or ""
|
|
||||||
db_email = email or ""
|
|
||||||
|
|
||||||
db_ticket = Ticket(
|
db_ticket = Ticket(
|
||||||
id=row_id,
|
id=row_id,
|
||||||
|
|
|
||||||
17
docs/upstream-candidates.md
Normal file
17
docs/upstream-candidates.md
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Upstream PR candidates
|
||||||
|
|
||||||
|
Running log of fork features that are shaped so they could be offered to
|
||||||
|
`lnbits/events` (or `lnbits/lnbits`). Add a row whenever a change lands here
|
||||||
|
in an upstream-compatible form; strike it when the PR merges upstream.
|
||||||
|
|
||||||
|
| Feature | Where | Upstream target | Readiness |
|
||||||
|
| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
|
||||||
|
| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR |
|
||||||
|
| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above |
|
||||||
|
| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch |
|
||||||
|
| `extra.payment_methods` per event + `tickets_payment_methods` NIP-52 tag | `models.py`, `nostr_publisher.py` | lnbits/events (v2 PR #64 introduces the same field) | offer as review input on #64 |
|
||||||
|
| `asyncio.to_thread` around the smtplib send | `services.py` `_send_ticket_email_notification` | lnbits/events | trivial, standalone |
|
||||||
|
| QR logo overlay in `make_qr_png` (instance `lnbits_qr_logo`) | `views_api.py` | lnbits/events | standalone |
|
||||||
|
| Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 |
|
||||||
|
| Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone |
|
||||||
|
| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 |
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ from datetime import datetime, timezone
|
||||||
from lnbits.core.signers import NostrSigner
|
from lnbits.core.signers import NostrSigner
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from .models import Event
|
from .models import Event, effective_payment_methods
|
||||||
from .nostr.event import NostrEvent
|
from .nostr.event import NostrEvent
|
||||||
from .nostr_timestamp import monotonic_created_at
|
from .nostr_timestamp import monotonic_created_at
|
||||||
|
|
||||||
|
|
@ -110,6 +110,11 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
|
||||||
tags.append(["tickets_allow_fiat", "true"])
|
tags.append(["tickets_allow_fiat", "true"])
|
||||||
if event.fiat_currency:
|
if event.fiat_currency:
|
||||||
tags.append(["tickets_fiat_currency", event.fiat_currency])
|
tags.append(["tickets_fiat_currency", event.fiat_currency])
|
||||||
|
# Rails the organizer accepts, resolved through the same helper the
|
||||||
|
# ticket endpoint enforces with, so a client can render exactly the
|
||||||
|
# buttons that will be accepted (e.g. a card-only event) without a REST
|
||||||
|
# round-trip. Comma-separated, lowercase.
|
||||||
|
tags.append(["tickets_payment_methods", ",".join(effective_payment_methods(event))])
|
||||||
|
|
||||||
# NIP-52 calendar events are replaceable: this d-tag is republished
|
# NIP-52 calendar events are replaceable: this d-tag is republished
|
||||||
# whenever inventory changes (a ticket sells). Use a strictly-monotonic
|
# whenever inventory changes (a ticket sells). Use a strictly-monotonic
|
||||||
|
|
|
||||||
205
services.py
205
services.py
|
|
@ -1,14 +1,16 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import smtplib
|
||||||
from asyncio.tasks import create_task
|
from asyncio.tasks import create_task
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from html import escape
|
||||||
|
|
||||||
from lnbits.core.models.users import UserNotifications
|
from lnbits.core.models.users import UserNotifications
|
||||||
from lnbits.core.services.nostr import send_nostr_dm
|
from lnbits.core.services.nostr import send_nostr_dm
|
||||||
from lnbits.core.services.notifications import (
|
from lnbits.core.services.notifications import send_user_notification
|
||||||
send_email_notification,
|
from lnbits.helpers import is_valid_email_address
|
||||||
send_user_notification,
|
|
||||||
)
|
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.utils.nostr import normalize_private_key, normalize_public_key
|
from lnbits.utils.nostr import normalize_private_key, normalize_public_key
|
||||||
from lnurl import execute
|
from lnurl import execute
|
||||||
|
|
@ -21,7 +23,7 @@ from .crud import (
|
||||||
update_event,
|
update_event,
|
||||||
update_ticket,
|
update_ticket,
|
||||||
)
|
)
|
||||||
from .models import Event, Ticket
|
from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult
|
||||||
from .nostr_hooks import publish_or_delete_nostr_event
|
from .nostr_hooks import publish_or_delete_nostr_event
|
||||||
|
|
||||||
DEFAULT_NOSTR_RELAYS = [
|
DEFAULT_NOSTR_RELAYS = [
|
||||||
|
|
@ -80,40 +82,13 @@ async def _send_ticket_notification(ticket: Ticket) -> None:
|
||||||
logger.warning(f"Event {ticket.event} not found for ticket notification.")
|
logger.warning(f"Event {ticket.event} not found for ticket notification.")
|
||||||
return
|
return
|
||||||
|
|
||||||
subject, message = _ticket_notification_message(ticket, event)
|
await _deliver_ticket_notifications(ticket, event)
|
||||||
updated = False
|
|
||||||
|
|
||||||
if (
|
|
||||||
event.extra.email_notifications
|
|
||||||
and settings.lnbits_email_notifications_enabled
|
|
||||||
and ticket.email
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
await send_email_notification([ticket.email], message, subject)
|
|
||||||
ticket.extra.email_notification_sent = True
|
|
||||||
updated = True
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(f"Failed to email ticket {ticket.id}: {exc}")
|
|
||||||
|
|
||||||
if (
|
|
||||||
event.extra.nostr_notifications
|
|
||||||
and settings.is_nostr_notifications_configured()
|
|
||||||
and ticket.extra.nostr_identifier
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
await _send_nostr_ticket_notification(
|
|
||||||
ticket.extra.nostr_identifier, message
|
|
||||||
)
|
|
||||||
ticket.extra.nostr_notification_sent = True
|
|
||||||
updated = True
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(f"Failed to send nostr DM for ticket {ticket.id}: {exc}")
|
|
||||||
|
|
||||||
if updated:
|
|
||||||
await update_ticket(ticket)
|
|
||||||
|
|
||||||
|
|
||||||
async def resend_ticket_email_notification(ticket: Ticket) -> Ticket:
|
async def resend_ticket_email_notification(ticket: Ticket) -> TicketResendResult:
|
||||||
|
"""Organizer-triggered re-delivery of the ticket email. Bypasses the
|
||||||
|
per-event `email_notifications` opt-in (the organizer asked explicitly)
|
||||||
|
but still needs the instance mailer and an address on the ticket."""
|
||||||
event = await get_event(ticket.event)
|
event = await get_event(ticket.event)
|
||||||
if not event:
|
if not event:
|
||||||
raise ValueError("Event does not exist.")
|
raise ValueError("Event does not exist.")
|
||||||
|
|
@ -122,10 +97,7 @@ async def resend_ticket_email_notification(ticket: Ticket) -> Ticket:
|
||||||
if not ticket.email:
|
if not ticket.email:
|
||||||
raise ValueError("Ticket does not have an email address.")
|
raise ValueError("Ticket does not have an email address.")
|
||||||
|
|
||||||
subject, message = _ticket_notification_message(ticket, event)
|
return await _deliver_ticket_notifications(ticket, event, email=True, nostr=False)
|
||||||
await send_email_notification([ticket.email], message, subject)
|
|
||||||
ticket.extra.email_notification_sent = True
|
|
||||||
return await update_ticket(ticket)
|
|
||||||
|
|
||||||
|
|
||||||
def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str]:
|
def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str]:
|
||||||
|
|
@ -142,6 +114,149 @@ def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str
|
||||||
return subject, f"{body}\n\nOpen it here: {ticket_url}"
|
return subject, f"{body}\n\nOpen it here: {ticket_url}"
|
||||||
|
|
||||||
|
|
||||||
|
def _ticket_delivery_message(ticket: Ticket, base_message: str) -> str:
|
||||||
|
return f"{base_message}\n\nTicket image: {_ticket_image_url(ticket)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _ticket_email_html_message(ticket: Ticket, base_message: str) -> str:
|
||||||
|
text_message = _ticket_delivery_message(ticket, base_message)
|
||||||
|
html_message = f"<p>{escape(text_message).replace(chr(10), '<br />')}</p>"
|
||||||
|
image_url = escape(_ticket_image_url(ticket), quote=True)
|
||||||
|
return (
|
||||||
|
f"{html_message}"
|
||||||
|
f'<p><img src="{image_url}" alt="Ticket QR code" '
|
||||||
|
'style="max-width: 300px; height: auto;" /></p>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]:
|
||||||
|
subject, base_message = _ticket_notification_message(ticket, event)
|
||||||
|
text_message = _ticket_delivery_message(ticket, base_message)
|
||||||
|
html_message = _ticket_email_html_message(ticket, base_message)
|
||||||
|
return subject, text_message, html_message
|
||||||
|
|
||||||
|
|
||||||
|
async def _deliver_ticket_notifications(
|
||||||
|
ticket: Ticket,
|
||||||
|
event: Event,
|
||||||
|
*,
|
||||||
|
email: bool | None = None,
|
||||||
|
nostr: bool | None = None,
|
||||||
|
) -> TicketResendResult:
|
||||||
|
"""Send the ticket by every configured channel and report per-channel
|
||||||
|
outcome (upstream v1.6.8 shape). `email` / `nostr` override the event's
|
||||||
|
opt-ins when not None; the instance-level prerequisites always apply."""
|
||||||
|
subject, text_message, html_message = _ticket_notification_payload(ticket, event)
|
||||||
|
updated = False
|
||||||
|
|
||||||
|
email_wanted = event.extra.email_notifications if email is None else email
|
||||||
|
nostr_wanted = event.extra.nostr_notifications if nostr is None else nostr
|
||||||
|
result = TicketResendResult(
|
||||||
|
ticket=ticket,
|
||||||
|
email=NotificationDeliveryResult(
|
||||||
|
attempted=bool(
|
||||||
|
email_wanted
|
||||||
|
and settings.lnbits_email_notifications_enabled
|
||||||
|
and ticket.email
|
||||||
|
)
|
||||||
|
),
|
||||||
|
nostr=NotificationDeliveryResult(
|
||||||
|
attempted=bool(
|
||||||
|
nostr_wanted
|
||||||
|
and settings.is_nostr_notifications_configured()
|
||||||
|
and ticket.extra.nostr_identifier
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.email.attempted:
|
||||||
|
try:
|
||||||
|
assert ticket.email
|
||||||
|
await _send_ticket_email_notification(
|
||||||
|
[ticket.email], text_message, subject, html_message
|
||||||
|
)
|
||||||
|
ticket.extra.email_notification_sent = True
|
||||||
|
result.email.sent = True
|
||||||
|
updated = True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"Failed to email ticket {ticket.id}: {exc}")
|
||||||
|
result.email.error = str(exc)
|
||||||
|
|
||||||
|
if result.nostr.attempted:
|
||||||
|
try:
|
||||||
|
identifier = ticket.extra.nostr_identifier
|
||||||
|
assert identifier
|
||||||
|
await _send_nostr_ticket_notification(identifier, text_message)
|
||||||
|
ticket.extra.nostr_notification_sent = True
|
||||||
|
result.nostr.sent = True
|
||||||
|
updated = True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"Failed to send nostr DM for ticket {ticket.id}: {exc}")
|
||||||
|
result.nostr.error = str(exc)
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
result.ticket = await update_ticket(ticket)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_ticket_email_notification(
|
||||||
|
to_emails: list[str],
|
||||||
|
message: str,
|
||||||
|
subject: str,
|
||||||
|
html_message: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Multipart (text + HTML) ticket email through the instance SMTP
|
||||||
|
settings. Core's `send_email_notification` is plain-text only, which is
|
||||||
|
why this lives here (ported from upstream v1.6.8). The blocking smtplib
|
||||||
|
session runs in a worker thread so a slow relay cannot stall the event
|
||||||
|
loop while a batch of tickets settles."""
|
||||||
|
if not settings.lnbits_email_notifications_enabled:
|
||||||
|
raise ValueError("Email notifications are disabled")
|
||||||
|
from_email = settings.lnbits_email_notifications_email
|
||||||
|
if not is_valid_email_address(from_email):
|
||||||
|
raise ValueError(f"Invalid from email address: {from_email}")
|
||||||
|
if not to_emails:
|
||||||
|
raise ValueError("No email addresses provided")
|
||||||
|
for address in to_emails:
|
||||||
|
if not is_valid_email_address(address):
|
||||||
|
raise ValueError(f"Invalid email address: {address}")
|
||||||
|
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["From"] = from_email
|
||||||
|
msg["To"] = ", ".join(to_emails)
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg.attach(MIMEText(message, "plain"))
|
||||||
|
if html_message:
|
||||||
|
msg.attach(MIMEText(html_message, "html"))
|
||||||
|
|
||||||
|
username = settings.lnbits_email_notifications_username or from_email
|
||||||
|
await asyncio.to_thread(
|
||||||
|
_smtp_send,
|
||||||
|
settings.lnbits_email_notifications_server,
|
||||||
|
settings.lnbits_email_notifications_port,
|
||||||
|
username,
|
||||||
|
settings.lnbits_email_notifications_password,
|
||||||
|
from_email,
|
||||||
|
to_emails,
|
||||||
|
msg.as_string(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _smtp_send(
|
||||||
|
server: str,
|
||||||
|
port: int,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
from_email: str,
|
||||||
|
to_emails: list[str],
|
||||||
|
payload: str,
|
||||||
|
) -> None:
|
||||||
|
with smtplib.SMTP(server, port, timeout=30) as smtp_server:
|
||||||
|
smtp_server.starttls()
|
||||||
|
smtp_server.login(username, password)
|
||||||
|
smtp_server.sendmail(from_email, to_emails, payload)
|
||||||
|
|
||||||
|
|
||||||
async def _send_nostr_ticket_notification(identifier: str, message: str) -> None:
|
async def _send_nostr_ticket_notification(identifier: str, message: str) -> None:
|
||||||
if "@" in identifier:
|
if "@" in identifier:
|
||||||
await send_user_notification(
|
await send_user_notification(
|
||||||
|
|
@ -161,6 +276,14 @@ def _ticket_url(ticket: Ticket) -> str:
|
||||||
return f"{base_url}/events/ticket/{ticket.id}"
|
return f"{base_url}/events/ticket/{ticket.id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _ticket_image_url(ticket: Ticket) -> str:
|
||||||
|
"""The QR PNG is served by THIS extension on the LNbits host, so it is
|
||||||
|
built from `lnbits_baseurl` even when `ticket_base_url` points at a
|
||||||
|
separate web app (deviation from upstream, which assumes both are the
|
||||||
|
same host)."""
|
||||||
|
return f"{settings.lnbits_baseurl.rstrip('/')}/events/api/v1/qr/{ticket.id}"
|
||||||
|
|
||||||
|
|
||||||
async def refund_tickets(event_id: str):
|
async def refund_tickets(event_id: str):
|
||||||
"""
|
"""
|
||||||
Refund tickets for an event that has not met the minimum ticket requirement.
|
Refund tickets for an event that has not met the minimum ticket requirement.
|
||||||
|
|
|
||||||
|
|
@ -35,13 +35,29 @@ window.PageEventsDisplay = {
|
||||||
async created() {
|
async created() {
|
||||||
this.eventId = this.$route.params.id
|
this.eventId = this.$route.params.id
|
||||||
this.event = await this.getEvent()
|
this.event = await this.getEvent()
|
||||||
|
// Default to the first rail the organizer accepts (a card-only event
|
||||||
|
// must not submit "lightning").
|
||||||
|
this.formDialog.data.payment_method = this.paymentMethods[0] || 'lightning'
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
formatDescription() {
|
formatDescription() {
|
||||||
return LNbits.utils.convertMarkdown(this.event?.info || '')
|
return LNbits.utils.convertMarkdown(this.event?.info || '')
|
||||||
},
|
},
|
||||||
|
paymentMethods() {
|
||||||
|
// Mirrors `effective_payment_methods` on the backend: an explicit
|
||||||
|
// extra.payment_methods list wins, else Lightning + fiat-if-allow_fiat.
|
||||||
|
const explicit = this.event?.extra?.payment_methods || []
|
||||||
|
if (explicit.length) return explicit
|
||||||
|
return ['lightning', ...(this.event?.allow_fiat ? ['fiat'] : [])]
|
||||||
|
},
|
||||||
allowFiatCheckout() {
|
allowFiatCheckout() {
|
||||||
return Boolean(this.event?.allow_fiat)
|
return this.paymentMethods.includes('fiat')
|
||||||
|
},
|
||||||
|
paymentMethodOptions() {
|
||||||
|
return this.paymentMethods.map(method => ({
|
||||||
|
value: method,
|
||||||
|
label: method === 'fiat' ? this.fiatCheckoutLabel : 'Lightning'
|
||||||
|
}))
|
||||||
},
|
},
|
||||||
fiatCheckoutLabel() {
|
fiatCheckoutLabel() {
|
||||||
if (!this.allowFiatCheckout) return 'Fiat'
|
if (!this.allowFiatCheckout) return 'Fiat'
|
||||||
|
|
@ -78,7 +94,8 @@ window.PageEventsDisplay = {
|
||||||
this.formDialog.data.email = ''
|
this.formDialog.data.email = ''
|
||||||
this.formDialog.data.refund = ''
|
this.formDialog.data.refund = ''
|
||||||
this.formDialog.data.nostr_identifier = ''
|
this.formDialog.data.nostr_identifier = ''
|
||||||
this.formDialog.data.payment_method = 'lightning'
|
this.formDialog.data.payment_method =
|
||||||
|
this.paymentMethods[0] || 'lightning'
|
||||||
},
|
},
|
||||||
|
|
||||||
closeReceiveDialog() {
|
closeReceiveDialog() {
|
||||||
|
|
@ -112,7 +129,8 @@ window.PageEventsDisplay = {
|
||||||
this.formDialog.data.email = ''
|
this.formDialog.data.email = ''
|
||||||
this.formDialog.data.refund = ''
|
this.formDialog.data.refund = ''
|
||||||
this.formDialog.data.nostr_identifier = ''
|
this.formDialog.data.nostr_identifier = ''
|
||||||
this.formDialog.data.payment_method = 'lightning'
|
this.formDialog.data.payment_method =
|
||||||
|
this.paymentMethods[0] || 'lightning'
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Sent, thank you!',
|
message: 'Sent, thank you!',
|
||||||
|
|
|
||||||
|
|
@ -90,20 +90,18 @@
|
||||||
:hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`"
|
:hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`"
|
||||||
></q-input>
|
></q-input>
|
||||||
<div class="row q-col-gutter-md q-pt-lg items-center">
|
<div class="row q-col-gutter-md q-pt-lg items-center">
|
||||||
<div v-if="allowFiatCheckout" class="col-auto">
|
<div v-if="paymentMethods.length > 1" class="col-auto">
|
||||||
<q-option-group
|
<q-option-group
|
||||||
v-model="formDialog.data.payment_method"
|
v-model="formDialog.data.payment_method"
|
||||||
inline
|
inline
|
||||||
:options="[
|
:options="paymentMethodOptions"
|
||||||
{label: 'Lightning', value: 'lightning'},
|
|
||||||
{
|
|
||||||
label: fiatCheckoutLabel,
|
|
||||||
value: 'fiat'
|
|
||||||
}
|
|
||||||
]"
|
|
||||||
></q-option-group>
|
></q-option-group>
|
||||||
</div>
|
</div>
|
||||||
<div :class="allowFiatCheckout ? 'col-12 col-md-3' : 'col-12'">
|
<div
|
||||||
|
:class="
|
||||||
|
paymentMethods.length > 1 ? 'col-12 col-md-3' : 'col-12'
|
||||||
|
"
|
||||||
|
>
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,7 @@ window.PageEvents = {
|
||||||
allow_fiat: false,
|
allow_fiat: false,
|
||||||
fiat_currency: 'GBP',
|
fiat_currency: 'GBP',
|
||||||
extra: {
|
extra: {
|
||||||
|
payment_methods: ['lightning'],
|
||||||
promo_codes: [],
|
promo_codes: [],
|
||||||
notification_subject: '',
|
notification_subject: '',
|
||||||
notification_body: ''
|
notification_body: ''
|
||||||
|
|
@ -271,12 +272,7 @@ window.PageEvents = {
|
||||||
},
|
},
|
||||||
saveSettings() {
|
saveSettings() {
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request(
|
.request('PUT', '/events/api/v1/events/settings', null, this.settings)
|
||||||
'PUT',
|
|
||||||
'/events/api/v1/events/settings',
|
|
||||||
null,
|
|
||||||
this.settings
|
|
||||||
)
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
Quasar.Notify.create({type: 'positive', message: 'Settings saved'})
|
Quasar.Notify.create({type: 'positive', message: 'Settings saved'})
|
||||||
})
|
})
|
||||||
|
|
@ -326,7 +322,7 @@ window.PageEvents = {
|
||||||
LNbits.utils
|
LNbits.utils
|
||||||
.confirmDialog(
|
.confirmDialog(
|
||||||
'Re-emit every approved event to Nostr relays? This is safe ' +
|
'Re-emit every approved event to Nostr relays? This is safe ' +
|
||||||
'to run multiple times but generates one event per approved row.'
|
'to run multiple times but generates one event per approved row.'
|
||||||
)
|
)
|
||||||
.onOk(() => {
|
.onOk(() => {
|
||||||
this.republishing = true
|
this.republishing = true
|
||||||
|
|
@ -351,9 +347,7 @@ window.PageEvents = {
|
||||||
},
|
},
|
||||||
republishMyEvents() {
|
republishMyEvents() {
|
||||||
LNbits.utils
|
LNbits.utils
|
||||||
.confirmDialog(
|
.confirmDialog('Re-emit your approved events to Nostr relays?')
|
||||||
'Re-emit your approved events to Nostr relays?'
|
|
||||||
)
|
|
||||||
.onOk(() => {
|
.onOk(() => {
|
||||||
this.republishingMine = true
|
this.republishingMine = true
|
||||||
LNbits.api
|
LNbits.api
|
||||||
|
|
@ -420,6 +414,17 @@ window.PageEvents = {
|
||||||
code: code.code.trim().toUpperCase()
|
code: code.code.trim().toUpperCase()
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
const methods = data.extra?.payment_methods || []
|
||||||
|
if (methods.length === 0) {
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'warning',
|
||||||
|
message: 'Select at least one payment method.'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// allow_fiat stays the fiat-currency carrier the backend and the
|
||||||
|
// NIP-52 tags read; keep it in lockstep with the checkbox list.
|
||||||
|
data.allow_fiat = methods.includes('fiat')
|
||||||
if (!this.isFiatCurrency(data.currency)) {
|
if (!this.isFiatCurrency(data.currency)) {
|
||||||
if (!data.allow_fiat) {
|
if (!data.allow_fiat) {
|
||||||
data.fiat_currency = 'GBP'
|
data.fiat_currency = 'GBP'
|
||||||
|
|
@ -439,6 +444,15 @@ window.PageEvents = {
|
||||||
const end = this.splitDateTime(data.event_end_date)
|
const end = this.splitDateTime(data.event_end_date)
|
||||||
this.formDialog.data = {
|
this.formDialog.data = {
|
||||||
...data,
|
...data,
|
||||||
|
extra: {
|
||||||
|
...(data.extra || {}),
|
||||||
|
// Events created before extra.payment_methods existed carry an
|
||||||
|
// empty list; show the rails the backend actually accepts for
|
||||||
|
// them (Lightning always, fiat when allow_fiat).
|
||||||
|
payment_methods: data.extra?.payment_methods?.length
|
||||||
|
? data.extra.payment_methods
|
||||||
|
: ['lightning', ...(data.allow_fiat ? ['fiat'] : [])]
|
||||||
|
},
|
||||||
event_start_day: start.day,
|
event_start_day: start.day,
|
||||||
event_start_time: start.time,
|
event_start_time: start.time,
|
||||||
event_end_day: end.day,
|
event_end_day: end.day,
|
||||||
|
|
@ -454,6 +468,7 @@ window.PageEvents = {
|
||||||
event_end_day: '',
|
event_end_day: '',
|
||||||
event_end_time: '',
|
event_end_time: '',
|
||||||
extra: {
|
extra: {
|
||||||
|
payment_methods: ['lightning'],
|
||||||
conditional: false,
|
conditional: false,
|
||||||
min_tickets: 1,
|
min_tickets: 1,
|
||||||
email_notifications: false,
|
email_notifications: false,
|
||||||
|
|
@ -473,6 +488,7 @@ window.PageEvents = {
|
||||||
allow_fiat: false,
|
allow_fiat: false,
|
||||||
fiat_currency: 'GBP',
|
fiat_currency: 'GBP',
|
||||||
extra: {
|
extra: {
|
||||||
|
payment_methods: ['lightning'],
|
||||||
email_notifications: false,
|
email_notifications: false,
|
||||||
nostr_notifications: false,
|
nostr_notifications: false,
|
||||||
promo_codes: [],
|
promo_codes: [],
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,10 @@
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<span class="text-subtitle2">Republish to Nostr</span>
|
<span class="text-subtitle2">Republish to Nostr</span>
|
||||||
<div class="text-caption text-grey-7" style="color: #aaa">
|
<div class="text-caption text-grey-7" style="color: #aaa">
|
||||||
Re-emit every approved event so connected clients pick
|
Re-emit every approved event so connected clients pick up the
|
||||||
up the latest tag set. Useful after the extension
|
latest tag set. Useful after the extension publisher changes
|
||||||
publisher changes (e.g. new tickets_* tags) so existing
|
(e.g. new tickets_* tags) so existing events don't need a
|
||||||
events don't need a per-event edit.
|
per-event edit.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
|
|
@ -56,8 +56,8 @@
|
||||||
></q-btn>
|
></q-btn>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-caption q-mt-sm" style="color: #aaa">
|
<div class="text-caption q-mt-sm" style="color: #aaa">
|
||||||
Re-emit your approved events to Nostr relays. Useful after
|
Re-emit your approved events to Nostr relays. Useful after a
|
||||||
a publisher upgrade or if a relay dropped your events.
|
publisher upgrade or if a relay dropped your events.
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
@ -228,7 +228,13 @@
|
||||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
<q-badge
|
<q-badge
|
||||||
v-if="col.name === 'status'"
|
v-if="col.name === 'status'"
|
||||||
:color="col.value === 'approved' ? 'green' : col.value === 'proposed' ? 'orange' : 'red'"
|
:color="
|
||||||
|
col.value === 'approved'
|
||||||
|
? 'green'
|
||||||
|
: col.value === 'proposed'
|
||||||
|
? 'orange'
|
||||||
|
: 'red'
|
||||||
|
"
|
||||||
:label="col.value"
|
:label="col.value"
|
||||||
></q-badge>
|
></q-badge>
|
||||||
<span v-else v-text="col.value"></span>
|
<span v-else v-text="col.value"></span>
|
||||||
|
|
@ -399,7 +405,13 @@
|
||||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
<q-badge
|
<q-badge
|
||||||
v-if="col.name === 'status'"
|
v-if="col.name === 'status'"
|
||||||
:color="col.value === 'approved' ? 'green' : col.value === 'proposed' ? 'orange' : 'red'"
|
:color="
|
||||||
|
col.value === 'approved'
|
||||||
|
? 'green'
|
||||||
|
: col.value === 'proposed'
|
||||||
|
? 'orange'
|
||||||
|
: 'red'
|
||||||
|
"
|
||||||
:label="col.value"
|
:label="col.value"
|
||||||
></q-badge>
|
></q-badge>
|
||||||
<span v-else v-text="col.value"></span>
|
<span v-else v-text="col.value"></span>
|
||||||
|
|
@ -587,15 +599,22 @@
|
||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<q-toggle
|
<div class="q-mt-sm">
|
||||||
v-model="formDialog.data.allow_fiat"
|
<div class="text-caption text-grey-7">Payment methods *</div>
|
||||||
label="Allow fiat checkout"
|
<q-option-group
|
||||||
left-label
|
v-model="formDialog.data.extra.payment_methods"
|
||||||
hint="Lets attendees pay through a configured fiat provider using the event currency."
|
type="checkbox"
|
||||||
></q-toggle>
|
inline
|
||||||
|
:options="paymentMethodOptions"
|
||||||
|
></q-option-group>
|
||||||
|
<div class="text-caption text-grey-7">
|
||||||
|
Card / fiat checkout goes through the fiat provider configured on
|
||||||
|
this LNbits instance. Untick Lightning for a card-only event.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<q-select
|
<q-select
|
||||||
v-if="
|
v-if="
|
||||||
formDialog.data.allow_fiat &&
|
acceptsFiat &&
|
||||||
['sat', 'sats'].includes(
|
['sat', 'sats'].includes(
|
||||||
(formDialog.data.currency || '').toLowerCase()
|
(formDialog.data.currency || '').toLowerCase()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
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 == ""
|
||||||
52
tests/test_frontend_root.py
Normal file
52
tests/test_frontend_root.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
from ..models import CreateTicket
|
||||||
|
from ..views_api import _allowed_frontend_origins, _resolve_frontend_root
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def lnbits_settings(monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
settings, "lnbits_cors_allowed_origins", ["https://app.example"], raising=False
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
settings,
|
||||||
|
"lnbits_custom_frontend_url",
|
||||||
|
"https://Front.Example/login",
|
||||||
|
raising=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _request(base_url: str = "https://lnbits.example/"):
|
||||||
|
return SimpleNamespace(base_url=base_url)
|
||||||
|
|
||||||
|
|
||||||
|
def test_allowlist_collects_every_configured_origin(lnbits_settings):
|
||||||
|
assert _allowed_frontend_origins() == {
|
||||||
|
"https://lnbits.example",
|
||||||
|
"https://app.example",
|
||||||
|
"https://front.example",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_absent_frontend_url_falls_back_to_the_request_host(lnbits_settings):
|
||||||
|
data = CreateTicket(user_id="u1")
|
||||||
|
assert _resolve_frontend_root(data, _request()) == "https://lnbits.example"
|
||||||
|
|
||||||
|
|
||||||
|
def test_allowed_origin_is_returned_without_trailing_slash(lnbits_settings):
|
||||||
|
data = CreateTicket(user_id="u1", frontend_url="https://app.example/events/")
|
||||||
|
assert _resolve_frontend_root(data, _request()) == "https://app.example/events"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unlisted_origin_is_rejected_loudly(lnbits_settings):
|
||||||
|
data = CreateTicket(user_id="u1", frontend_url="https://evil.example/events")
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
_resolve_frontend_root(data, _request())
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
assert "frontend_url" in exc.value.detail
|
||||||
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"])
|
||||||
22
tests/test_ticket_qr.py
Normal file
22
tests/test_ticket_qr.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from ..views_api import make_qr_png
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_qr_png_renders_requested_size():
|
||||||
|
img = make_qr_png("ticket://abc123", size=200)
|
||||||
|
assert img.size == (200, 200)
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_qr_png_pastes_a_centred_logo():
|
||||||
|
logo = Image.new("RGBA", (64, 64), (255, 0, 0, 255))
|
||||||
|
img = make_qr_png("ticket://abc123", size=300, logo=logo)
|
||||||
|
assert img.size == (300, 300)
|
||||||
|
# The centre pixel is inside the pasted logo, so it is red — a plain
|
||||||
|
# QR would only ever have black or white there.
|
||||||
|
assert img.getpixel((150, 150))[:3] == (255, 0, 0)
|
||||||
|
out = BytesIO()
|
||||||
|
img.save(out, format="PNG")
|
||||||
|
assert out.getvalue().startswith(b"\x89PNG")
|
||||||
235
views_api.py
235
views_api.py
|
|
@ -1,8 +1,13 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pyqrcode # type: ignore[import-untyped]
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
APIRouter,
|
APIRouter,
|
||||||
Depends,
|
Depends,
|
||||||
|
|
@ -12,18 +17,19 @@ from fastapi import (
|
||||||
WebSocket,
|
WebSocket,
|
||||||
WebSocketDisconnect,
|
WebSocketDisconnect,
|
||||||
)
|
)
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from lnbits.core.crud import get_user
|
from lnbits.core.crud import get_user
|
||||||
from lnbits.core.crud.wallets import get_wallet
|
from lnbits.core.crud.wallets import get_wallet
|
||||||
from lnbits.core.models import Account, User, WalletTypeInfo
|
from lnbits.core.models import Account, User, WalletTypeInfo
|
||||||
from lnbits.core.models.payments import CreateInvoice
|
from lnbits.core.models.payments import CreateInvoice
|
||||||
from lnbits.core.services import create_payment_request
|
from lnbits.core.services import create_payment_request
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
check_admin,
|
check_admin,
|
||||||
check_user_exists,
|
check_user_exists,
|
||||||
require_admin_key,
|
require_admin_key,
|
||||||
require_invoice_key,
|
require_invoice_key,
|
||||||
)
|
)
|
||||||
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.utils.exchange_rates import (
|
from lnbits.utils.exchange_rates import (
|
||||||
fiat_amount_as_satoshis,
|
fiat_amount_as_satoshis,
|
||||||
|
|
@ -31,6 +37,8 @@ from lnbits.utils.exchange_rates import (
|
||||||
satoshis_amount_as_fiat,
|
satoshis_amount_as_fiat,
|
||||||
)
|
)
|
||||||
from lnbits.utils.nostr import normalize_public_key
|
from lnbits.utils.nostr import normalize_public_key
|
||||||
|
from loguru import logger
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
from .crud import (
|
from .crud import (
|
||||||
create_event,
|
create_event,
|
||||||
|
|
@ -64,6 +72,8 @@ from .models import (
|
||||||
PublicTicket,
|
PublicTicket,
|
||||||
Ticket,
|
Ticket,
|
||||||
TicketPaymentRequest,
|
TicketPaymentRequest,
|
||||||
|
TicketResendResult,
|
||||||
|
effective_payment_methods,
|
||||||
)
|
)
|
||||||
from .nostr_hooks import publish_or_delete_nostr_event
|
from .nostr_hooks import publish_or_delete_nostr_event
|
||||||
from .services import (
|
from .services import (
|
||||||
|
|
@ -76,6 +86,7 @@ from .tasks import deregister_payment_listener, register_payment_listener
|
||||||
|
|
||||||
events_api_router = APIRouter(prefix="/api/v1/events")
|
events_api_router = APIRouter(prefix="/api/v1/events")
|
||||||
tickets_api_router = APIRouter(prefix="/api/v1/tickets")
|
tickets_api_router = APIRouter(prefix="/api/v1/tickets")
|
||||||
|
qr_api_router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
def _is_fiat_currency(currency: str | None) -> bool:
|
def _is_fiat_currency(currency: str | None) -> bool:
|
||||||
|
|
@ -513,6 +524,136 @@ async def api_get_ticket(ticket_id: str) -> Ticket:
|
||||||
return ticket
|
return ticket
|
||||||
|
|
||||||
|
|
||||||
|
def _origin(url: str | None) -> str | None:
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
parts = urlsplit(url.strip())
|
||||||
|
if not parts.scheme or not parts.netloc:
|
||||||
|
return None
|
||||||
|
return f"{parts.scheme.lower()}://{parts.netloc.lower()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed_frontend_origins() -> set[str]:
|
||||||
|
"""Origins a buyer-side client may name in `CreateTicket.frontend_url`.
|
||||||
|
|
||||||
|
The CORS allow-list is literally "which web apps may talk to this
|
||||||
|
LNbits", so it is the natural allow-list for "which web apps may be
|
||||||
|
linked from a ticket email". The LNbits host itself and the configured
|
||||||
|
custom frontend are always fine.
|
||||||
|
"""
|
||||||
|
origins: set[str] = set()
|
||||||
|
for candidate in [
|
||||||
|
*getattr(settings, "lnbits_cors_allowed_origins", []),
|
||||||
|
settings.lnbits_baseurl,
|
||||||
|
getattr(settings, "lnbits_custom_frontend_url", None),
|
||||||
|
]:
|
||||||
|
origin = _origin(candidate)
|
||||||
|
if origin:
|
||||||
|
origins.add(origin)
|
||||||
|
return origins
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_frontend_root(data: CreateTicket, request: Request) -> str:
|
||||||
|
"""Root under which `/events/{event_id}` and `/events/ticket/{ticket_id}`
|
||||||
|
resolve for the buyer — the calling app when it says so, else the LNbits
|
||||||
|
host (the extension's own Quasar pages)."""
|
||||||
|
if not data.frontend_url:
|
||||||
|
return str(request.base_url).rstrip("/")
|
||||||
|
origin = _origin(data.frontend_url)
|
||||||
|
if not origin or origin not in _allowed_frontend_origins():
|
||||||
|
# Fail loud rather than silently falling back: a wrong root means
|
||||||
|
# the buyer is returned to (and emailed a link into) the wrong app.
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail="frontend_url origin is not allowed.",
|
||||||
|
)
|
||||||
|
return data.frontend_url.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
_qr_logo_cache: dict[str, Image.Image | None] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_qr_logo() -> Image.Image | None:
|
||||||
|
"""LNbits' "QR Code/Favicon Logo" setting, as a Pillow image (cached).
|
||||||
|
|
||||||
|
Local `/static/...` values resolve inside the LNbits package; absolute
|
||||||
|
URLs are fetched once. Any failure just yields a plain QR.
|
||||||
|
"""
|
||||||
|
source = (settings.lnbits_qr_logo or "").strip()
|
||||||
|
if not source:
|
||||||
|
return None
|
||||||
|
if source in _qr_logo_cache:
|
||||||
|
return _qr_logo_cache[source]
|
||||||
|
logo: Image.Image | None = None
|
||||||
|
try:
|
||||||
|
if source.startswith(("http://", "https://")):
|
||||||
|
async with httpx.AsyncClient(timeout=5) as client:
|
||||||
|
resp = await client.get(source)
|
||||||
|
resp.raise_for_status()
|
||||||
|
logo = Image.open(BytesIO(resp.content)).convert("RGBA")
|
||||||
|
else:
|
||||||
|
local = Path(settings.lnbits_path) / source.lstrip("/")
|
||||||
|
if local.is_file():
|
||||||
|
logo = Image.open(local).convert("RGBA")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"QR logo '{source}' unavailable: {exc}")
|
||||||
|
logo = None
|
||||||
|
_qr_logo_cache[source] = logo
|
||||||
|
return logo
|
||||||
|
|
||||||
|
|
||||||
|
def make_qr_png(
|
||||||
|
data: str,
|
||||||
|
size: int = 235,
|
||||||
|
border: int = 4,
|
||||||
|
logo: Image.Image | None = None,
|
||||||
|
) -> Image.Image:
|
||||||
|
"""Render `data` as a QR image (upstream v1.6.8 shape). With `logo`, the
|
||||||
|
code is built at error-correction level H and the logo is pasted in the
|
||||||
|
centre on a white pad at ≤ 20 % of the width — the same look LNbits'
|
||||||
|
client-side `lnbits-qrcode` component produces."""
|
||||||
|
qr = pyqrcode.create(data, error="H" if logo is not None else "M")
|
||||||
|
matrix = qr.code
|
||||||
|
modules = len(matrix)
|
||||||
|
|
||||||
|
total_modules = modules + border * 2
|
||||||
|
box_size = max(1, size // total_modules)
|
||||||
|
img_size = total_modules * box_size
|
||||||
|
|
||||||
|
img = Image.new("RGBA", (img_size, img_size), "white")
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
for y, row in enumerate(matrix):
|
||||||
|
for x, cell in enumerate(row):
|
||||||
|
if cell:
|
||||||
|
x0 = (x + border) * box_size
|
||||||
|
y0 = (y + border) * box_size
|
||||||
|
draw.rectangle(
|
||||||
|
[x0, y0, x0 + box_size - 1, y0 + box_size - 1],
|
||||||
|
fill="black",
|
||||||
|
)
|
||||||
|
|
||||||
|
if img_size != size:
|
||||||
|
img = img.resize((size, size), Image.Resampling.NEAREST)
|
||||||
|
|
||||||
|
if logo is not None:
|
||||||
|
logo_size = max(8, int(size * 0.2))
|
||||||
|
pad = max(2, logo_size // 8)
|
||||||
|
scaled = logo.copy()
|
||||||
|
scaled.thumbnail((logo_size, logo_size), Image.Resampling.LANCZOS)
|
||||||
|
plate = Image.new(
|
||||||
|
"RGBA", (scaled.width + 2 * pad, scaled.height + 2 * pad), "white"
|
||||||
|
)
|
||||||
|
plate.paste(scaled, (pad, pad), scaled)
|
||||||
|
img.paste(
|
||||||
|
plate,
|
||||||
|
((size - plate.width) // 2, (size - plate.height) // 2),
|
||||||
|
plate,
|
||||||
|
)
|
||||||
|
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
async def _issue_free_tickets(
|
async def _issue_free_tickets(
|
||||||
*,
|
*,
|
||||||
event: Event,
|
event: Event,
|
||||||
|
|
@ -522,7 +663,7 @@ async def _issue_free_tickets(
|
||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
promo_code: str | None,
|
promo_code: str | None,
|
||||||
nostr_identifier: str | None,
|
nostr_identifier: str | None,
|
||||||
request: Request,
|
frontend_root: str,
|
||||||
) -> TicketPaymentRequest:
|
) -> TicketPaymentRequest:
|
||||||
"""Issue `quantity` free tickets without minting an invoice.
|
"""Issue `quantity` free tickets without minting an invoice.
|
||||||
|
|
||||||
|
|
@ -552,7 +693,7 @@ async def _issue_free_tickets(
|
||||||
extra={
|
extra={
|
||||||
"applied_promo_code": promo_code,
|
"applied_promo_code": promo_code,
|
||||||
"nostr_identifier": nostr_identifier,
|
"nostr_identifier": nostr_identifier,
|
||||||
"ticket_base_url": str(request.base_url).rstrip("/"),
|
"ticket_base_url": frontend_root,
|
||||||
"sats_paid": 0,
|
"sats_paid": 0,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -588,7 +729,9 @@ async def api_ticket_create(
|
||||||
quantity = data.quantity
|
quantity = data.quantity
|
||||||
if event.amount_tickets > 0:
|
if event.amount_tickets > 0:
|
||||||
if event.sold >= event.amount_tickets:
|
if event.sold >= event.amount_tickets:
|
||||||
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.GONE, detail="Event is sold out."
|
||||||
|
)
|
||||||
remaining = event.amount_tickets - event.sold
|
remaining = event.amount_tickets - event.sold
|
||||||
if quantity > remaining:
|
if quantity > remaining:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -618,6 +761,7 @@ async def api_ticket_create(
|
||||||
) from exc
|
) from exc
|
||||||
unit_price = event.price_per_ticket
|
unit_price = event.price_per_ticket
|
||||||
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
|
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
|
||||||
|
frontend_root = _resolve_frontend_root(data, request)
|
||||||
|
|
||||||
if promo_code:
|
if promo_code:
|
||||||
# check if promo_code exists in event.extra.promo_codes
|
# check if promo_code exists in event.extra.promo_codes
|
||||||
|
|
@ -645,13 +789,16 @@ async def api_ticket_create(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
promo_code=promo_code,
|
promo_code=promo_code,
|
||||||
nostr_identifier=nostr_identifier,
|
nostr_identifier=nostr_identifier,
|
||||||
request=request,
|
frontend_root=frontend_root,
|
||||||
)
|
)
|
||||||
|
|
||||||
if payment_method == "fiat" and not event.allow_fiat:
|
# Organizer-controlled rails (extra.payment_methods; legacy events fall
|
||||||
|
# back to Lightning + fiat-if-allow_fiat). Checked after the free path
|
||||||
|
# because a free claim charges nothing on any rail.
|
||||||
|
if payment_method not in effective_payment_methods(event):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail="Fiat payments are not enabled for this event.",
|
detail="Payment method not enabled for this event.",
|
||||||
)
|
)
|
||||||
|
|
||||||
if _is_fiat_currency(event.currency):
|
if _is_fiat_currency(event.currency):
|
||||||
|
|
@ -692,6 +839,36 @@ async def api_ticket_create(
|
||||||
else:
|
else:
|
||||||
invoice_unit = "sat"
|
invoice_unit = "sat"
|
||||||
|
|
||||||
|
# Each row gets a fresh urlsafe_short_hash id so single- and
|
||||||
|
# multi-ticket purchases stay shape-consistent — every scannable
|
||||||
|
# ticket id is a short hash, never the long bolt11 payment_hash.
|
||||||
|
# The shared `payment_hash` column is the join key for invoice
|
||||||
|
# lookup (poll endpoint, ws notifier, set_ticket_paid loop). Ids are
|
||||||
|
# minted BEFORE the invoice so the fiat success URL can carry them
|
||||||
|
# (the payment_hash only exists after `create_payment_request`).
|
||||||
|
ticket_ids: list[str] = [urlsafe_short_hash() for _ in range(quantity)]
|
||||||
|
|
||||||
|
if payment_method == "fiat":
|
||||||
|
# Parameterise the provider's hosted checkout (consumed by
|
||||||
|
# lnbits/fiat/stripe.py `StripeCheckoutOptions`): bring the buyer
|
||||||
|
# back to the app they came from, lock the email they gave us, and
|
||||||
|
# label the line item with the event rather than the raw memo.
|
||||||
|
ticket_label = "ticket" if quantity == 1 else "tickets"
|
||||||
|
extra["checkout"] = {
|
||||||
|
"success_url": (
|
||||||
|
f"{frontend_root}/events/{event.id}"
|
||||||
|
f"?checkout=success&tickets={','.join(ticket_ids)}"
|
||||||
|
),
|
||||||
|
"cancel_url": f"{frontend_root}/events/{event.id}?checkout=cancelled",
|
||||||
|
"customer_email": email,
|
||||||
|
"line_item_name": f"{event.name} — {quantity} {ticket_label}",
|
||||||
|
"metadata": {
|
||||||
|
"event_id": event.id,
|
||||||
|
"quantity": str(quantity),
|
||||||
|
"ticket_ids": ",".join(ticket_ids),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
payment = await create_payment_request(
|
payment = await create_payment_request(
|
||||||
wallet_id=event.wallet,
|
wallet_id=event.wallet,
|
||||||
invoice_data=CreateInvoice(
|
invoice_data=CreateInvoice(
|
||||||
|
|
@ -703,15 +880,8 @@ async def api_ticket_create(
|
||||||
extra=extra,
|
extra=extra,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
# Each row gets a fresh urlsafe_short_hash id so single- and
|
|
||||||
# multi-ticket purchases stay shape-consistent — every scannable
|
|
||||||
# ticket id is a short hash, never the long bolt11 payment_hash.
|
|
||||||
# The shared `payment_hash` column is the join key for invoice
|
|
||||||
# lookup (poll endpoint, ws notifier, set_ticket_paid loop).
|
|
||||||
ticket_ids: list[str] = []
|
|
||||||
sats_per_ticket = payment.sat // quantity if quantity else payment.sat
|
sats_per_ticket = payment.sat // quantity if quantity else payment.sat
|
||||||
for _ in range(quantity):
|
for row_id in ticket_ids:
|
||||||
row_id = urlsafe_short_hash()
|
|
||||||
await create_ticket(
|
await create_ticket(
|
||||||
payment_hash=payment.payment_hash,
|
payment_hash=payment.payment_hash,
|
||||||
wallet=event.wallet,
|
wallet=event.wallet,
|
||||||
|
|
@ -724,11 +894,10 @@ async def api_ticket_create(
|
||||||
"applied_promo_code": promo_code,
|
"applied_promo_code": promo_code,
|
||||||
"refund_address": refund_address,
|
"refund_address": refund_address,
|
||||||
"nostr_identifier": nostr_identifier,
|
"nostr_identifier": nostr_identifier,
|
||||||
"ticket_base_url": str(request.base_url).rstrip("/"),
|
"ticket_base_url": frontend_root,
|
||||||
"sats_paid": sats_per_ticket,
|
"sats_paid": sats_per_ticket,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
ticket_ids.append(row_id)
|
|
||||||
|
|
||||||
return TicketPaymentRequest(
|
return TicketPaymentRequest(
|
||||||
payment_hash=payment.payment_hash,
|
payment_hash=payment.payment_hash,
|
||||||
|
|
@ -824,10 +993,10 @@ async def api_ticket_delete(
|
||||||
await delete_ticket(ticket_id)
|
await delete_ticket(ticket_id)
|
||||||
|
|
||||||
|
|
||||||
@tickets_api_router.post("/{ticket_id}/resend-email")
|
@tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult)
|
||||||
async def api_ticket_resend_email(
|
async def api_ticket_resend_email(
|
||||||
ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
) -> Ticket:
|
) -> TicketResendResult:
|
||||||
ticket = await get_ticket(ticket_id)
|
ticket = await get_ticket(ticket_id)
|
||||||
if not ticket:
|
if not ticket:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -959,3 +1128,31 @@ async def api_event_ticket_stats(
|
||||||
for t in paid_tickets
|
for t in paid_tickets
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@qr_api_router.get("/qr/{ticket_id}", response_class=StreamingResponse)
|
||||||
|
async def api_ticket_qr(ticket_id: str):
|
||||||
|
"""PNG of the ticket's scan payload (`ticket://<id>`), branded with the
|
||||||
|
instance QR logo. Anonymous by design — it is what the ticket email
|
||||||
|
embeds — and the id is the same bearer token the ticket page exposes.
|
||||||
|
Port of upstream v1.6.8 without ticket-image compositing."""
|
||||||
|
ticket = await get_ticket(ticket_id)
|
||||||
|
if not ticket:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
logo = await _load_qr_logo()
|
||||||
|
image = make_qr_png(f"ticket://{ticket_id}", size=300, logo=logo)
|
||||||
|
output = BytesIO()
|
||||||
|
image.save(output, format="PNG")
|
||||||
|
output.seek(0)
|
||||||
|
return StreamingResponse(
|
||||||
|
output,
|
||||||
|
media_type="image/png",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Expires": "0",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue