Compare commits
No commits in common. "8b423f185afbde2a8b791d7c6ce9774249021e86" and "fe9f005b53783d2cedcbe4e28380b1793e3f8f0d" have entirely different histories.
8b423f185a
...
fe9f005b53
20 changed files with 114 additions and 844 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -2,6 +2,3 @@ __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,15 +30,10 @@ 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 --frozen pytest
|
uv run 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,6 +23,7 @@ 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
|
||||||
|
|
@ -33,6 +34,7 @@ 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\
|
||||||

|

|
||||||
|
|
||||||
|
|
@ -42,31 +44,6 @@ 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,13 +6,12 @@ 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, qr_api_router, tickets_api_router
|
from .views_api import events_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.8",
|
"version": "1.6.1-aio.7",
|
||||||
"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,12 +55,14 @@ 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 a
|
# name/email columns are NOT NULL in the schema, so we store "" when only
|
||||||
# value is absent. _parse_ticket_row reverses this on read. A user_id
|
# user_id is supplied. _parse_ticket_row reverses this on read.
|
||||||
# ticket may carry an email too — that is how logged-in webapp buyers get
|
if user_id:
|
||||||
# their ticket emailed.
|
db_name = ""
|
||||||
db_name = name or ""
|
db_email = ""
|
||||||
db_email = email or ""
|
else:
|
||||||
|
db_name = name or ""
|
||||||
|
db_email = email or ""
|
||||||
|
|
||||||
db_ticket = Ticket(
|
db_ticket = Ticket(
|
||||||
id=row_id,
|
id=row_id,
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
# 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,3 +127,4 @@ 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,11 +1,8 @@
|
||||||
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
|
||||||
|
|
@ -31,26 +28,6 @@ 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):
|
||||||
|
|
@ -130,22 +107,6 @@ 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."""
|
||||||
|
|
||||||
|
|
@ -175,37 +136,16 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -228,22 +168,6 @@ 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, effective_payment_methods
|
from .models import Event
|
||||||
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,11 +110,6 @@ 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,16 +1,14 @@
|
||||||
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 send_user_notification
|
from lnbits.core.services.notifications import (
|
||||||
from lnbits.helpers import is_valid_email_address
|
send_email_notification,
|
||||||
|
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
|
||||||
|
|
@ -23,7 +21,7 @@ from .crud import (
|
||||||
update_event,
|
update_event,
|
||||||
update_ticket,
|
update_ticket,
|
||||||
)
|
)
|
||||||
from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult
|
from .models import Event, Ticket
|
||||||
from .nostr_hooks import publish_or_delete_nostr_event
|
from .nostr_hooks import publish_or_delete_nostr_event
|
||||||
|
|
||||||
DEFAULT_NOSTR_RELAYS = [
|
DEFAULT_NOSTR_RELAYS = [
|
||||||
|
|
@ -82,13 +80,40 @@ 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
|
||||||
|
|
||||||
await _deliver_ticket_notifications(ticket, event)
|
subject, message = _ticket_notification_message(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) -> TicketResendResult:
|
async def resend_ticket_email_notification(ticket: Ticket) -> Ticket:
|
||||||
"""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.")
|
||||||
|
|
@ -97,7 +122,10 @@ async def resend_ticket_email_notification(ticket: Ticket) -> TicketResendResult
|
||||||
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.")
|
||||||
|
|
||||||
return await _deliver_ticket_notifications(ticket, event, email=True, nostr=False)
|
subject, message = _ticket_notification_message(ticket, event)
|
||||||
|
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]:
|
||||||
|
|
@ -114,149 +142,6 @@ 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(
|
||||||
|
|
@ -276,14 +161,6 @@ 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,29 +35,13 @@ 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 this.paymentMethods.includes('fiat')
|
return Boolean(this.event?.allow_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'
|
||||||
|
|
@ -94,8 +78,7 @@ 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 =
|
this.formDialog.data.payment_method = 'lightning'
|
||||||
this.paymentMethods[0] || 'lightning'
|
|
||||||
},
|
},
|
||||||
|
|
||||||
closeReceiveDialog() {
|
closeReceiveDialog() {
|
||||||
|
|
@ -129,8 +112,7 @@ 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 =
|
this.formDialog.data.payment_method = 'lightning'
|
||||||
this.paymentMethods[0] || 'lightning'
|
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Sent, thank you!',
|
message: 'Sent, thank you!',
|
||||||
|
|
|
||||||
|
|
@ -90,18 +90,20 @@
|
||||||
: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="paymentMethods.length > 1" class="col-auto">
|
<div v-if="allowFiatCheckout" class="col-auto">
|
||||||
<q-option-group
|
<q-option-group
|
||||||
v-model="formDialog.data.payment_method"
|
v-model="formDialog.data.payment_method"
|
||||||
inline
|
inline
|
||||||
:options="paymentMethodOptions"
|
:options="[
|
||||||
|
{label: 'Lightning', value: 'lightning'},
|
||||||
|
{
|
||||||
|
label: fiatCheckoutLabel,
|
||||||
|
value: 'fiat'
|
||||||
|
}
|
||||||
|
]"
|
||||||
></q-option-group>
|
></q-option-group>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div :class="allowFiatCheckout ? 'col-12 col-md-3' : 'col-12'">
|
||||||
:class="
|
|
||||||
paymentMethods.length > 1 ? 'col-12 col-md-3' : 'col-12'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,6 @@ 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: ''
|
||||||
|
|
@ -272,7 +271,12 @@ window.PageEvents = {
|
||||||
},
|
},
|
||||||
saveSettings() {
|
saveSettings() {
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request('PUT', '/events/api/v1/events/settings', null, this.settings)
|
.request(
|
||||||
|
'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'})
|
||||||
})
|
})
|
||||||
|
|
@ -322,7 +326,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
|
||||||
|
|
@ -347,7 +351,9 @@ window.PageEvents = {
|
||||||
},
|
},
|
||||||
republishMyEvents() {
|
republishMyEvents() {
|
||||||
LNbits.utils
|
LNbits.utils
|
||||||
.confirmDialog('Re-emit your approved events to Nostr relays?')
|
.confirmDialog(
|
||||||
|
'Re-emit your approved events to Nostr relays?'
|
||||||
|
)
|
||||||
.onOk(() => {
|
.onOk(() => {
|
||||||
this.republishingMine = true
|
this.republishingMine = true
|
||||||
LNbits.api
|
LNbits.api
|
||||||
|
|
@ -414,17 +420,6 @@ 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'
|
||||||
|
|
@ -444,15 +439,6 @@ 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,
|
||||||
|
|
@ -468,7 +454,6 @@ 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,
|
||||||
|
|
@ -488,7 +473,6 @@ 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 up the
|
Re-emit every approved event so connected clients pick
|
||||||
latest tag set. Useful after the extension publisher changes
|
up the latest tag set. Useful after the extension
|
||||||
(e.g. new tickets_* tags) so existing events don't need a
|
publisher changes (e.g. new tickets_* tags) so existing
|
||||||
per-event edit.
|
events don't need a 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 a
|
Re-emit your approved events to Nostr relays. Useful after
|
||||||
publisher upgrade or if a relay dropped your events.
|
a publisher upgrade or if a relay dropped your events.
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
@ -228,13 +228,7 @@
|
||||||
<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="
|
:color="col.value === 'approved' ? 'green' : col.value === 'proposed' ? 'orange' : 'red'"
|
||||||
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>
|
||||||
|
|
@ -405,13 +399,7 @@
|
||||||
<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="
|
:color="col.value === 'approved' ? 'green' : col.value === 'proposed' ? 'orange' : 'red'"
|
||||||
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>
|
||||||
|
|
@ -599,22 +587,15 @@
|
||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="q-mt-sm">
|
<q-toggle
|
||||||
<div class="text-caption text-grey-7">Payment methods *</div>
|
v-model="formDialog.data.allow_fiat"
|
||||||
<q-option-group
|
label="Allow fiat checkout"
|
||||||
v-model="formDialog.data.extra.payment_methods"
|
left-label
|
||||||
type="checkbox"
|
hint="Lets attendees pay through a configured fiat provider using the event currency."
|
||||||
inline
|
></q-toggle>
|
||||||
: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="
|
||||||
acceptsFiat &&
|
formDialog.data.allow_fiat &&
|
||||||
['sat', 'sats'].includes(
|
['sat', 'sats'].includes(
|
||||||
(formDialog.data.currency || '').toLowerCase()
|
(formDialog.data.currency || '').toLowerCase()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
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 == ""
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
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"])
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
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,13 +1,8 @@
|
||||||
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,
|
||||||
|
|
@ -17,19 +12,18 @@ 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,
|
||||||
|
|
@ -37,8 +31,6 @@ 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,
|
||||||
|
|
@ -72,8 +64,6 @@ 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 (
|
||||||
|
|
@ -86,7 +76,6 @@ 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:
|
||||||
|
|
@ -524,136 +513,6 @@ 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,
|
||||||
|
|
@ -663,7 +522,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,
|
||||||
frontend_root: str,
|
request: Request,
|
||||||
) -> TicketPaymentRequest:
|
) -> TicketPaymentRequest:
|
||||||
"""Issue `quantity` free tickets without minting an invoice.
|
"""Issue `quantity` free tickets without minting an invoice.
|
||||||
|
|
||||||
|
|
@ -693,7 +552,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": frontend_root,
|
"ticket_base_url": str(request.base_url).rstrip("/"),
|
||||||
"sats_paid": 0,
|
"sats_paid": 0,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -729,9 +588,7 @@ 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(
|
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
|
||||||
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(
|
||||||
|
|
@ -761,7 +618,6 @@ 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
|
||||||
|
|
@ -789,16 +645,13 @@ 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,
|
||||||
frontend_root=frontend_root,
|
request=request,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Organizer-controlled rails (extra.payment_methods; legacy events fall
|
if payment_method == "fiat" and not event.allow_fiat:
|
||||||
# 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="Payment method not enabled for this event.",
|
detail="Fiat payments are not enabled for this event.",
|
||||||
)
|
)
|
||||||
|
|
||||||
if _is_fiat_currency(event.currency):
|
if _is_fiat_currency(event.currency):
|
||||||
|
|
@ -839,36 +692,6 @@ 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(
|
||||||
|
|
@ -880,8 +703,15 @@ 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 row_id in ticket_ids:
|
for _ in range(quantity):
|
||||||
|
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,
|
||||||
|
|
@ -894,10 +724,11 @@ 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": frontend_root,
|
"ticket_base_url": str(request.base_url).rstrip("/"),
|
||||||
"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,
|
||||||
|
|
@ -993,10 +824,10 @@ async def api_ticket_delete(
|
||||||
await delete_ticket(ticket_id)
|
await delete_ticket(ticket_id)
|
||||||
|
|
||||||
|
|
||||||
@tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult)
|
@tickets_api_router.post("/{ticket_id}/resend-email")
|
||||||
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)
|
||||||
) -> TicketResendResult:
|
) -> Ticket:
|
||||||
ticket = await get_ticket(ticket_id)
|
ticket = await get_ticket(ticket_id)
|
||||||
if not ticket:
|
if not ticket:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -1128,31 +959,3 @@ 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