events/models.py
Padreug 72b9c540f7
Some checks failed
lint.yml / feat: per-event organizer name + reply-to on ticket emails (pull_request) Failing after 0s
feat: per-event organizer name + reply-to on ticket emails
Each organizer is different, so the sender identity of ticket emails is
now per event rather than per instance:

- `extra.organizer_name` → From display name "Organizer via <site title>"
  (site title alone when unset). The From address stays the instance
  mailbox — that is what DKIM signs — so this costs nothing in mail auth.
- `extra.reply_to_email` → Reply-To; blank falls back to the email on the
  LNbits account that owns the event wallet; no header when neither
  exists. When a reply-to exists the body says "Questions? Reply to this
  email and it reaches <organizer>", and "Organizer: <name>" is listed
  with the ticket details.

Both fields sit next to the existing per-event subject/body in the admin
dialog. The organizer's own wording (in whatever language) remains
`notification_body`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
2026-09-13 18:19:33 +02:00

284 lines
9.7 KiB
Python

import json
from datetime import datetime
from urllib.parse import urlsplit
from pydantic import BaseModel, EmailStr, Field, root_validator, validator
PAYMENT_METHODS = ("lightning", "fiat")
class PromoCode(BaseModel):
code: str
discount_percent: float = 0.0
active: bool = True
# make the promo code uppercase
@validator("code")
def uppercase_code(cls, v):
return v.upper()
@validator("discount_percent")
def validate_discount_percent(cls, v):
assert 0 <= v <= 100, "Discount must be between 0 and 100."
return v
class EventExtra(BaseModel):
promo_codes: list[PromoCode] = Field(default_factory=list)
conditional: bool = False
min_tickets: int = 1
email_notifications: bool = False
nostr_notifications: bool = False
notification_subject: str = ""
notification_body: str = ""
# Sender identity for ticket emails, per event: the From display name
# becomes "<organizer_name> via <site title>" and replies go to
# `reply_to_email` (falls back to the event owner's account email).
# The From *address* stays the instance mailbox — that is what DKIM
# signs — so organizers get a human sender without touching mail auth.
organizer_name: str = ""
reply_to_email: EmailStr | None = None
@validator("reply_to_email", pre=True)
def blank_reply_to_is_none(cls, v):
if isinstance(v, str) and not v.strip():
return None
return v
# 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):
wallet: str | None = None # filled from caller's wallet if absent
name: str # title (required)
info: str = "" # description (optional)
closing_date: str | None = None # date-only YYYY-MM-DD; defaults to event_end_date
# ISO 8601: date-only ("2026-05-19") or datetime ("2026-05-19T18:30").
# Presence of a "T" toggles NIP-52 kind (31922 date / 31923 time).
event_start_date: str
event_end_date: str | None = None # same format as event_start_date
currency: str = "sat"
allow_fiat: bool = False
fiat_currency: str = "GBP"
amount_tickets: int = 0 # 0 = unlimited / not ticketed
price_per_ticket: float = 0 # 0 = free
banner: str | None = None
location: str | None = None # venue/address (NIP-52 'location' tag)
categories: list[str] = Field(default_factory=list) # NIP-52 't' tags
extra: EventExtra = Field(default_factory=EventExtra)
status: str = "approved" # proposed, approved, rejected
class Event(BaseModel):
id: str
wallet: str
name: str
info: str = ""
closing_date: str | None = None
canceled: bool = False
event_start_date: str
event_end_date: str | None = None
currency: str = "sat"
allow_fiat: bool = False
fiat_currency: str = "GBP"
amount_tickets: int = 0
price_per_ticket: float = 0
time: datetime
sold: int = 0
banner: str | None = None
location: str | None = None
categories: list[str] = Field(default_factory=list)
extra: EventExtra = Field(default_factory=EventExtra)
status: str = "approved"
nostr_event_id: str | None = None
nostr_event_created_at: int | None = None
@validator("categories", pre=True)
def parse_categories(cls, v):
if isinstance(v, str):
return json.loads(v) if v else []
return v or []
class PublicEvent(BaseModel):
id: str
name: str
info: str
closing_date: str | None = None
canceled: bool
event_start_date: str
event_end_date: str | None = None
currency: str
allow_fiat: bool = False
fiat_currency: str = "GBP"
price_per_ticket: float
banner: str | None
location: str | None = None
categories: list[str] = Field(default_factory=list)
extra: EventExtra = Field(default_factory=EventExtra)
status: str = "approved" # surfaces "proposed"/"rejected" so SFC can render banner
@validator("categories", pre=True)
def parse_categories(cls, v):
if isinstance(v, str):
return json.loads(v) if v else []
return v or []
def effective_payment_methods(event: "Event | PublicEvent | CreateEvent") -> list[str]:
"""Rails a buyer may pick for `event`.
Explicit `extra.payment_methods` wins; an empty list falls back to the
pre-#payment-methods rule so events created before the field existed
keep behaving the same (Lightning always, fiat iff `allow_fiat`).
"""
explicit = list(getattr(event.extra, "payment_methods", []) or [])
if explicit:
return explicit
methods = ["lightning"]
if event.allow_fiat:
methods.append("fiat")
return methods
class EventsSettings(BaseModel):
"""Extension-level settings for the events extension."""
auto_approve: bool = False # Skip approval workflow for non-admin users
class TicketExtra(BaseModel):
applied_promo_code: str | None = None
sats_paid: int | None = None
refund_address: str | None = None
nostr_identifier: str | None = None
ticket_base_url: str | None = None
email_notification_sent: bool = False
nostr_notification_sent: bool = False
refunded: bool = False
class CreateTicket(BaseModel):
name: str | None = None
email: EmailStr | None = None
user_id: str | None = None # LNbits user id (alternative to name+email)
promo_code: str | None = None
refund_address: str | None = None
nostr_identifier: str | None = None
payment_method: str | None = None
fiat_provider: str | None = None
# Number of tickets to buy on this single invoice. Bounded so a
# bad client can't run away with the organizer's capacity.
quantity: int = Field(default=1, ge=1, le=10)
# App root of the client that is buying (e.g. https://app.example/events).
# The extension builds the Stripe success/cancel URLs and the emailed
# ticket link under it, so the buyer lands back in the app they came
# from. Origin is allow-listed server-side (see `_resolve_frontend_root`);
# absent = today's behaviour (the LNbits host).
frontend_url: str | None = Field(default=None, max_length=512)
@validator("frontend_url")
def validate_frontend_url(cls, v):
if v is None:
return None
v = v.strip()
if not v:
return None
parts = urlsplit(v)
if parts.scheme not in ("http", "https") or not parts.netloc:
raise ValueError("frontend_url must be an absolute http(s) URL")
if parts.query or parts.fragment or ".." in parts.path:
raise ValueError("frontend_url must not contain a query, fragment or '..'")
return v.rstrip("/")
@root_validator
def validate_identifiers(cls, values):
"""A ticket needs an identity: an LNbits `user_id`, or `name` +
`email` for guests. A logged-in buyer may add `email` (and `name`)
on top of `user_id` so the ticket can be emailed to them."""
name = values.get("name")
email = values.get("email")
user_id = values.get("user_id")
if not user_id and not (name and email):
raise ValueError("Either user_id or both name and email must be provided")
return values
class Ticket(BaseModel):
id: str
wallet: str
event: str
name: str | None = None
email: str | None = None
user_id: str | None = None
registered: bool
paid: bool
time: datetime
reg_timestamp: datetime
extra: TicketExtra = Field(default_factory=TicketExtra)
# Shared LNbits invoice payment_hash. Equals `id` for single-ticket
# purchases (legacy + post-migration default). Multi-ticket
# purchases create N rows sharing one payment_hash so each attendee
# gets a distinct scannable id while the buyer pays once.
payment_hash: str | None = None
class NotificationDeliveryResult(BaseModel):
attempted: bool = False
sent: bool = False
error: str | None = None
class TicketResendResult(BaseModel):
ticket: Ticket
email: NotificationDeliveryResult = Field(
default_factory=NotificationDeliveryResult
)
nostr: NotificationDeliveryResult = Field(
default_factory=NotificationDeliveryResult
)
class PublicTicket(BaseModel):
event: str
name: str | None = None
registered: bool
paid: bool
time: datetime
reg_timestamp: datetime
class TicketPaymentRequest(BaseModel):
payment_hash: str
payment_request: str | None = None
fiat_payment_request: str | None = None
fiat_provider: str | None = None
is_fiat: bool = False
# True when the tickets are already issued + paid with no invoice to
# settle — free events (price 0) or a 100%-off promo. The client skips
# the QR / payment-poll step and goes straight to the ticket QRs.
paid: bool = False
# Row ids created on this invoice — one for single-ticket
# purchases, N for multi-ticket (each independently scannable at
# the door). Buyers fetch these after payment to render N QRs in
# My Tickets.
ticket_ids: list[str] = Field(default_factory=list)