Compare commits
2 commits
0e15f74834
...
3d47862181
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d47862181 | |||
| f835766935 |
8 changed files with 415 additions and 84 deletions
|
|
@ -67,7 +67,10 @@ Events includes a shareable ticket scanner, which can be used to register attend
|
||||||
instance logo, name on ticket, ticket id) also served at
|
instance logo, name on ticket, ticket id) also served at
|
||||||
`GET /events/api/v1/ticket-card/{ticket_id}`; the bare QR stays at
|
`GET /events/api/v1/ticket-card/{ticket_id}`; the bare QR stays at
|
||||||
`GET /events/api/v1/qr/{ticket_id}`. Headers carry Date, Message-ID and a
|
`GET /events/api/v1/qr/{ticket_id}`. Headers carry Date, Message-ID and a
|
||||||
From display name (site title). `POST /events/api/v1/tickets/{ticket_id}/resend-email`
|
From display name — `extra.organizer_name` gives "Organizer via <site title>",
|
||||||
|
otherwise the site title. Replies go to `extra.reply_to_email`, falling back
|
||||||
|
to the event owner's account email (no Reply-To when neither exists). The
|
||||||
|
From _address_ is always the instance mailbox, which is what DKIM signs. `POST /events/api/v1/tickets/{ticket_id}/resend-email`
|
||||||
returns a `TicketResendResult` with per-channel outcome.
|
returns a `TicketResendResult` with per-channel outcome.
|
||||||
|
|
||||||
## Powered by LNbits
|
## Powered by LNbits
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"id": "events",
|
"id": "events",
|
||||||
"version": "1.6.1-aio.10",
|
"version": "1.6.1-aio.14",
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,17 @@ 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
|
`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.
|
in an upstream-compatible form; strike it when the PR merges upstream.
|
||||||
|
|
||||||
| Feature | Where | Upstream target | Readiness |
|
| 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 |
|
| `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 |
|
| 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.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 |
|
| `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 |
|
| `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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 |
|
||||||
| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 |
|
| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 |
|
||||||
| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `<img>` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) |
|
| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `<img>` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) |
|
||||||
|
| Per-event sender identity: `extra.organizer_name` (From display name "X via <site>") + `extra.reply_to_email` (Reply-To, falls back to owner account email) | `models.py`, `services.py`, admin dialog | lnbits/events | small, standalone |
|
||||||
|
|
|
||||||
14
models.py
14
models.py
|
|
@ -31,6 +31,20 @@ class EventExtra(BaseModel):
|
||||||
nostr_notifications: bool = False
|
nostr_notifications: bool = False
|
||||||
notification_subject: str = ""
|
notification_subject: str = ""
|
||||||
notification_body: 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
|
# Rails the organizer accepts for this event. Empty = legacy rule
|
||||||
# ("lightning" always, "fiat" when allow_fiat) — see
|
# ("lightning" always, "fiat" when allow_fiat) — see
|
||||||
# `effective_payment_methods`. Same field name/shape as upstream v2 so the
|
# `effective_payment_methods`. Same field name/shape as upstream v2 so the
|
||||||
|
|
|
||||||
88
services.py
88
services.py
|
|
@ -10,6 +10,8 @@ from email.mime.text import MIMEText
|
||||||
from email.utils import formataddr, formatdate, make_msgid
|
from email.utils import formataddr, formatdate, make_msgid
|
||||||
from html import escape
|
from html import escape
|
||||||
|
|
||||||
|
from lnbits.core.crud import get_user
|
||||||
|
from lnbits.core.crud.wallets import get_wallet
|
||||||
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 send_user_notification
|
||||||
|
|
@ -138,22 +140,38 @@ def _ticket_details(ticket: Ticket, event: Event) -> str:
|
||||||
"or open the link above on your phone, and show the QR code at the "
|
"or open the link above on your phone, and show the QR code at the "
|
||||||
"door to be scanned in."
|
"door to be scanned in."
|
||||||
)
|
)
|
||||||
|
if event.extra.organizer_name:
|
||||||
|
lines.append(f"Organizer: {event.extra.organizer_name}")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) -> str:
|
def _ticket_details_with_reply_hint(
|
||||||
|
ticket: Ticket, event: Event, reply_to: str | None
|
||||||
|
) -> str:
|
||||||
|
details = _ticket_details(ticket, event)
|
||||||
|
if reply_to:
|
||||||
|
who = event.extra.organizer_name or "the organizer"
|
||||||
|
details += f"\nQuestions? Reply to this email and it reaches {who}."
|
||||||
|
return details
|
||||||
|
|
||||||
|
|
||||||
|
def _ticket_delivery_message(
|
||||||
|
ticket: Ticket, event: Event, base_message: str, reply_to: str | None = None
|
||||||
|
) -> str:
|
||||||
return (
|
return (
|
||||||
f"{base_message}\n\n{_ticket_details(ticket, event)}"
|
f"{base_message}\n\n{_ticket_details_with_reply_hint(ticket, event, reply_to)}"
|
||||||
f"\n\nTicket image: {_ticket_image_url(ticket)}"
|
f"\n\nTicket image: {_ticket_image_url(ticket)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) -> str:
|
def _ticket_email_html_message(
|
||||||
|
ticket: Ticket, event: Event, base_message: str, reply_to: str | None = None
|
||||||
|
) -> str:
|
||||||
"""HTML twin of the text part. Deliberately no <img>: the card travels
|
"""HTML twin of the text part. Deliberately no <img>: the card travels
|
||||||
as an attachment (renders inline in most clients, works offline, and
|
as an attachment (renders inline in most clients, works offline, and
|
||||||
keeps SpamAssassin's HTML_IMAGE_ONLY rules quiet), and URLs become
|
keeps SpamAssassin's HTML_IMAGE_ONLY rules quiet), and URLs become
|
||||||
links."""
|
links."""
|
||||||
text_message = _ticket_delivery_message(ticket, event, base_message)
|
text_message = _ticket_delivery_message(ticket, event, base_message, reply_to)
|
||||||
html = escape(text_message)
|
html = escape(text_message)
|
||||||
html = re.sub(
|
html = re.sub(
|
||||||
r"(https?://[^\s<]+)",
|
r"(https?://[^\s<]+)",
|
||||||
|
|
@ -163,13 +181,41 @@ def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str)
|
||||||
return f"<p>{html.replace(chr(10), '<br />')}</p>"
|
return f"<p>{html.replace(chr(10), '<br />')}</p>"
|
||||||
|
|
||||||
|
|
||||||
def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]:
|
def _ticket_notification_payload(
|
||||||
|
ticket: Ticket, event: Event, reply_to: str | None = None
|
||||||
|
) -> tuple[str, str, str]:
|
||||||
subject, base_message = _ticket_notification_message(ticket, event)
|
subject, base_message = _ticket_notification_message(ticket, event)
|
||||||
text_message = _ticket_delivery_message(ticket, event, base_message)
|
text_message = _ticket_delivery_message(ticket, event, base_message, reply_to)
|
||||||
html_message = _ticket_email_html_message(ticket, event, base_message)
|
html_message = _ticket_email_html_message(ticket, event, base_message, reply_to)
|
||||||
return subject, text_message, html_message
|
return subject, text_message, html_message
|
||||||
|
|
||||||
|
|
||||||
|
async def organizer_reply_to(event: Event) -> str | None:
|
||||||
|
"""Where replies to a ticket email should go: the event's explicit
|
||||||
|
`reply_to_email`, else the email on the LNbits account that owns the
|
||||||
|
event wallet, else nothing (no Reply-To header)."""
|
||||||
|
if event.extra.reply_to_email:
|
||||||
|
return str(event.extra.reply_to_email)
|
||||||
|
try:
|
||||||
|
wallet = await get_wallet(event.wallet)
|
||||||
|
if not wallet:
|
||||||
|
return None
|
||||||
|
user = await get_user(wallet.user)
|
||||||
|
email = (user.email or "").strip() if user else ""
|
||||||
|
return email if is_valid_email_address(email) else None
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"Could not resolve organizer email for {event.id}: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def organizer_sender_name(event: Event) -> str:
|
||||||
|
"""From display name: "<organizer> via <site title>" when the event
|
||||||
|
names an organizer, else the site title."""
|
||||||
|
site = (settings.lnbits_site_title or "").strip() or "Tickets"
|
||||||
|
organizer = (event.extra.organizer_name or "").strip()
|
||||||
|
return f"{organizer} via {site}" if organizer else site
|
||||||
|
|
||||||
|
|
||||||
async def _deliver_ticket_notifications(
|
async def _deliver_ticket_notifications(
|
||||||
ticket: Ticket,
|
ticket: Ticket,
|
||||||
event: Event,
|
event: Event,
|
||||||
|
|
@ -180,7 +226,10 @@ async def _deliver_ticket_notifications(
|
||||||
"""Send the ticket by every configured channel and report per-channel
|
"""Send the ticket by every configured channel and report per-channel
|
||||||
outcome (upstream v1.6.8 shape). `email` / `nostr` override the event's
|
outcome (upstream v1.6.8 shape). `email` / `nostr` override the event's
|
||||||
opt-ins when not None; the instance-level prerequisites always apply."""
|
opt-ins when not None; the instance-level prerequisites always apply."""
|
||||||
subject, text_message, html_message = _ticket_notification_payload(ticket, event)
|
reply_to = await organizer_reply_to(event)
|
||||||
|
subject, text_message, html_message = _ticket_notification_payload(
|
||||||
|
ticket, event, reply_to
|
||||||
|
)
|
||||||
updated = False
|
updated = False
|
||||||
|
|
||||||
email_wanted = event.extra.email_notifications if email is None else email
|
email_wanted = event.extra.email_notifications if email is None else email
|
||||||
|
|
@ -220,6 +269,8 @@ async def _deliver_ticket_notifications(
|
||||||
attachments=[
|
attachments=[
|
||||||
(ticket_card_filename(ticket, event), image_png_bytes(card))
|
(ticket_card_filename(ticket, event), image_png_bytes(card))
|
||||||
],
|
],
|
||||||
|
reply_to=reply_to,
|
||||||
|
sender_name=organizer_sender_name(event),
|
||||||
)
|
)
|
||||||
ticket.extra.email_notification_sent = True
|
ticket.extra.email_notification_sent = True
|
||||||
result.email.sent = True
|
result.email.sent = True
|
||||||
|
|
@ -251,6 +302,8 @@ async def _send_ticket_email_notification(
|
||||||
subject: str,
|
subject: str,
|
||||||
html_message: str | None = None,
|
html_message: str | None = None,
|
||||||
attachments: list[tuple[str, bytes]] | None = None,
|
attachments: list[tuple[str, bytes]] | None = None,
|
||||||
|
reply_to: str | None = None,
|
||||||
|
sender_name: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Multipart (text + HTML) ticket email through the instance SMTP
|
"""Multipart (text + HTML) ticket email through the instance SMTP
|
||||||
settings. Core's `send_email_notification` is plain-text only, which is
|
settings. Core's `send_email_notification` is plain-text only, which is
|
||||||
|
|
@ -269,7 +322,14 @@ async def _send_ticket_email_notification(
|
||||||
raise ValueError(f"Invalid email address: {address}")
|
raise ValueError(f"Invalid email address: {address}")
|
||||||
|
|
||||||
msg = build_ticket_email(
|
msg = build_ticket_email(
|
||||||
from_email, to_emails, subject, message, html_message, attachments
|
from_email,
|
||||||
|
to_emails,
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
html_message,
|
||||||
|
attachments,
|
||||||
|
reply_to=reply_to,
|
||||||
|
sender_name=sender_name,
|
||||||
)
|
)
|
||||||
username = settings.lnbits_email_notifications_username or from_email
|
username = settings.lnbits_email_notifications_username or from_email
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
|
|
@ -291,6 +351,8 @@ def build_ticket_email(
|
||||||
message: str,
|
message: str,
|
||||||
html_message: str | None = None,
|
html_message: str | None = None,
|
||||||
attachments: list[tuple[str, bytes]] | None = None,
|
attachments: list[tuple[str, bytes]] | None = None,
|
||||||
|
reply_to: str | None = None,
|
||||||
|
sender_name: str | None = None,
|
||||||
) -> MIMEMultipart:
|
) -> MIMEMultipart:
|
||||||
"""Assemble the ticket email: text + HTML alternatives, PNG attachments
|
"""Assemble the ticket email: text + HTML alternatives, PNG attachments
|
||||||
(the ticket card), and the headers receivers score on — a Date and a
|
(the ticket card), and the headers receivers score on — a Date and a
|
||||||
|
|
@ -312,9 +374,13 @@ def build_ticket_email(
|
||||||
else:
|
else:
|
||||||
msg = body
|
msg = body
|
||||||
|
|
||||||
sender_name = (settings.lnbits_site_title or "").strip() or "Tickets"
|
display_name = (
|
||||||
msg["From"] = formataddr((sender_name, from_email))
|
sender_name or settings.lnbits_site_title or ""
|
||||||
|
).strip() or "Tickets"
|
||||||
|
msg["From"] = formataddr((display_name, from_email))
|
||||||
msg["To"] = ", ".join(to_emails)
|
msg["To"] = ", ".join(to_emails)
|
||||||
|
if reply_to and is_valid_email_address(reply_to):
|
||||||
|
msg["Reply-To"] = reply_to
|
||||||
msg["Subject"] = subject
|
msg["Subject"] = subject
|
||||||
msg["Date"] = formatdate(localtime=True)
|
msg["Date"] = formatdate(localtime=True)
|
||||||
msg["Message-ID"] = make_msgid(domain=from_email.rsplit("@", 1)[-1])
|
msg["Message-ID"] = make_msgid(domain=from_email.rsplit("@", 1)[-1])
|
||||||
|
|
|
||||||
|
|
@ -67,13 +67,15 @@ window.PageEvents = {
|
||||||
name: 'event_start_date',
|
name: 'event_start_date',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
label: 'Start date',
|
label: 'Start date',
|
||||||
field: 'event_start_date'
|
field: 'event_start_date',
|
||||||
|
format: val => this.formatEventDate(val)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'event_end_date',
|
name: 'event_end_date',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
label: 'End date',
|
label: 'End date',
|
||||||
field: 'event_end_date'
|
field: 'event_end_date',
|
||||||
|
format: val => this.formatEventDate(val)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'closing_date',
|
name: 'closing_date',
|
||||||
|
|
@ -149,15 +151,63 @@ window.PageEvents = {
|
||||||
rowsPerPage: 10
|
rowsPerPage: 10
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Rails an organizer can enable per event. Mirrors the webapp's
|
||||||
|
// CreateEventDialog; `fiat` is rendered disabled when the LNbits user
|
||||||
|
// has no fiat provider (see `hasFiatProvider`).
|
||||||
|
paymentMethodOptions: [
|
||||||
|
{
|
||||||
|
value: 'lightning',
|
||||||
|
label: 'Lightning',
|
||||||
|
hint: 'Pay with any Lightning wallet'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'fiat',
|
||||||
|
label: 'Card',
|
||||||
|
hint: 'Card or bank through your configured fiat provider'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// Same list the webapp offers (src/modules/events/types/category.ts);
|
||||||
|
// published as NIP-52 `t` tags so both clients filter on one vocabulary.
|
||||||
|
categoryOptions: [
|
||||||
|
'concert',
|
||||||
|
'workshop',
|
||||||
|
'market',
|
||||||
|
'festival',
|
||||||
|
'exhibition',
|
||||||
|
'sport',
|
||||||
|
'theater',
|
||||||
|
'cinema',
|
||||||
|
'party',
|
||||||
|
'talk',
|
||||||
|
'conference',
|
||||||
|
'meetup',
|
||||||
|
'food',
|
||||||
|
'outdoor',
|
||||||
|
'kids',
|
||||||
|
'wellness',
|
||||||
|
'technology',
|
||||||
|
'art',
|
||||||
|
'music',
|
||||||
|
'dance',
|
||||||
|
'literature',
|
||||||
|
'comedy',
|
||||||
|
'charity',
|
||||||
|
'tradition',
|
||||||
|
'other'
|
||||||
|
].map(c => ({label: c.charAt(0).toUpperCase() + c.slice(1), value: c})),
|
||||||
formDialog: {
|
formDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
data: {
|
data: {
|
||||||
currency: 'sats',
|
currency: 'sats',
|
||||||
allow_fiat: false,
|
allow_fiat: false,
|
||||||
fiat_currency: 'GBP',
|
fiat_currency: 'GBP',
|
||||||
|
location: '',
|
||||||
|
categories: [],
|
||||||
extra: {
|
extra: {
|
||||||
payment_methods: ['lightning'],
|
payment_methods: ['lightning'],
|
||||||
promo_codes: [],
|
promo_codes: [],
|
||||||
|
organizer_name: '',
|
||||||
|
reply_to_email: '',
|
||||||
notification_subject: '',
|
notification_subject: '',
|
||||||
notification_body: ''
|
notification_body: ''
|
||||||
}
|
}
|
||||||
|
|
@ -165,6 +215,27 @@ window.PageEvents = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
hasFiatProvider() {
|
||||||
|
return (this.g.user?.fiat_providers || []).length > 0
|
||||||
|
},
|
||||||
|
fiatProviderNames() {
|
||||||
|
return (this.g.user?.fiat_providers || [])
|
||||||
|
.map(p => p.charAt(0).toUpperCase() + p.slice(1))
|
||||||
|
.join(', ')
|
||||||
|
},
|
||||||
|
acceptsFiat() {
|
||||||
|
return (this.formDialog.data.extra?.payment_methods || []).includes(
|
||||||
|
'fiat'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
isSatPrice() {
|
||||||
|
return !this.isFiatCurrency(this.formDialog.data.currency)
|
||||||
|
},
|
||||||
|
fiatCurrencyOptions() {
|
||||||
|
return this.currencies.filter(c => this.isFiatCurrency(c))
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
isFiatCurrency(currency) {
|
isFiatCurrency(currency) {
|
||||||
return !['sat', 'sats'].includes((currency || '').toLowerCase())
|
return !['sat', 'sats'].includes((currency || '').toLowerCase())
|
||||||
|
|
@ -382,29 +453,62 @@ window.PageEvents = {
|
||||||
},
|
},
|
||||||
splitDateTime(value) {
|
splitDateTime(value) {
|
||||||
// Inverse of foldDateTime: split a stored string back into the
|
// Inverse of foldDateTime: split a stored string back into the
|
||||||
// day/time pieces the form inputs bind to.
|
// day/time pieces the form inputs bind to. Slicing to HH:MM also
|
||||||
|
// drops the seconds + offset suffix withLocalTzOffset stamps on
|
||||||
|
// submit, so the organizer sees the wall-clock they entered.
|
||||||
if (!value) return {day: '', time: ''}
|
if (!value) return {day: '', time: ''}
|
||||||
const [day, time = ''] = value.split('T')
|
const [day, time = ''] = value.split('T')
|
||||||
// Time inputs only accept HH:MM, drop any seconds we stored.
|
// Time inputs only accept HH:MM, drop any seconds we stored.
|
||||||
return {day, time: time.slice(0, 5)}
|
return {day, time: time.slice(0, 5)}
|
||||||
},
|
},
|
||||||
|
withLocalTzOffset(value) {
|
||||||
|
// Stamp the browser's UTC offset on a "YYYY-MM-DDTHH:MM" value.
|
||||||
|
// The publisher's `_to_unix` treats a naive datetime as UTC, so an
|
||||||
|
// event entered as 18:00 in CEST would otherwise go out on Nostr
|
||||||
|
// as 18:00 UTC. Same transform the webapp applies; date-only
|
||||||
|
// values pass through unchanged (they map to NIP-52 kind 31922).
|
||||||
|
if (!value || !value.includes('T')) return value
|
||||||
|
const offMin = -new Date(value).getTimezoneOffset()
|
||||||
|
const sign = offMin >= 0 ? '+' : '-'
|
||||||
|
const abs = Math.abs(offMin)
|
||||||
|
const hh = String(Math.floor(abs / 60)).padStart(2, '0')
|
||||||
|
const mm = String(abs % 60).padStart(2, '0')
|
||||||
|
return `${value}:00${sign}${hh}:${mm}`
|
||||||
|
},
|
||||||
|
formatEventDate(value) {
|
||||||
|
// Table display: "YYYY-MM-DD" or "YYYY-MM-DD HH:MM".
|
||||||
|
if (!value) return ''
|
||||||
|
const {day, time} = this.splitDateTime(value)
|
||||||
|
return time ? `${day} ${time}` : day
|
||||||
|
},
|
||||||
|
validateEndDate() {
|
||||||
|
// Cross-field rule for the end-day input: end >= start, compared
|
||||||
|
// on the folded date+time so an equal-day earlier time is caught.
|
||||||
|
const d = this.formDialog.data
|
||||||
|
const start = this.foldDateTime(d.event_start_day, d.event_start_time)
|
||||||
|
const end = this.foldDateTime(d.event_end_day, d.event_end_time)
|
||||||
|
if (!start || !end) return true
|
||||||
|
return end >= start || 'End must be on or after start'
|
||||||
|
},
|
||||||
sendEventData() {
|
sendEventData() {
|
||||||
const wallet = _.findWhere(this.g.user.wallets, {
|
const wallet = _.findWhere(this.g.user.wallets, {
|
||||||
id: this.formDialog.data.wallet
|
id: this.formDialog.data.wallet
|
||||||
})
|
})
|
||||||
const data = {...this.formDialog.data}
|
const data = {...this.formDialog.data}
|
||||||
data.event_start_date = this.foldDateTime(
|
data.event_start_date = this.withLocalTzOffset(
|
||||||
data.event_start_day,
|
this.foldDateTime(data.event_start_day, data.event_start_time)
|
||||||
data.event_start_time
|
|
||||||
)
|
)
|
||||||
data.event_end_date = this.foldDateTime(
|
data.event_end_date = this.withLocalTzOffset(
|
||||||
data.event_end_day,
|
this.foldDateTime(data.event_end_day, data.event_end_time)
|
||||||
data.event_end_time
|
|
||||||
)
|
)
|
||||||
delete data.event_start_day
|
delete data.event_start_day
|
||||||
delete data.event_start_time
|
delete data.event_start_time
|
||||||
delete data.event_end_day
|
delete data.event_end_day
|
||||||
delete data.event_end_time
|
delete data.event_end_time
|
||||||
|
// Optional NIP-52 fields: blank location is "unset", not "".
|
||||||
|
data.location = (data.location || '').trim() || null
|
||||||
|
data.categories = data.categories || []
|
||||||
|
data.closing_date = data.closing_date || null
|
||||||
|
|
||||||
if (data.extra?.promo_codes) {
|
if (data.extra?.promo_codes) {
|
||||||
data.extra.promo_codes = data.extra.promo_codes
|
data.extra.promo_codes = data.extra.promo_codes
|
||||||
|
|
@ -425,10 +529,12 @@ window.PageEvents = {
|
||||||
// allow_fiat stays the fiat-currency carrier the backend and the
|
// allow_fiat stays the fiat-currency carrier the backend and the
|
||||||
// NIP-52 tags read; keep it in lockstep with the checkbox list.
|
// NIP-52 tags read; keep it in lockstep with the checkbox list.
|
||||||
data.allow_fiat = methods.includes('fiat')
|
data.allow_fiat = methods.includes('fiat')
|
||||||
if (!this.isFiatCurrency(data.currency)) {
|
if (this.isFiatCurrency(data.currency)) {
|
||||||
if (!data.allow_fiat) {
|
// A fiat-priced event settles in its price currency; mirror it so
|
||||||
data.fiat_currency = 'GBP'
|
// the payload (and the tickets_fiat_currency tag) stay coherent.
|
||||||
}
|
data.fiat_currency = data.currency
|
||||||
|
} else if (!data.allow_fiat) {
|
||||||
|
data.fiat_currency = 'GBP'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.id) {
|
if (data.id) {
|
||||||
|
|
@ -453,6 +559,8 @@ window.PageEvents = {
|
||||||
? data.extra.payment_methods
|
? data.extra.payment_methods
|
||||||
: ['lightning', ...(data.allow_fiat ? ['fiat'] : [])]
|
: ['lightning', ...(data.allow_fiat ? ['fiat'] : [])]
|
||||||
},
|
},
|
||||||
|
location: data.location || '',
|
||||||
|
categories: [...(data.categories || [])],
|
||||||
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,
|
||||||
|
|
@ -463,6 +571,8 @@ window.PageEvents = {
|
||||||
currency: 'sats',
|
currency: 'sats',
|
||||||
allow_fiat: false,
|
allow_fiat: false,
|
||||||
fiat_currency: 'GBP',
|
fiat_currency: 'GBP',
|
||||||
|
location: '',
|
||||||
|
categories: [],
|
||||||
event_start_day: '',
|
event_start_day: '',
|
||||||
event_start_time: '',
|
event_start_time: '',
|
||||||
event_end_day: '',
|
event_end_day: '',
|
||||||
|
|
@ -474,6 +584,8 @@ window.PageEvents = {
|
||||||
email_notifications: false,
|
email_notifications: false,
|
||||||
nostr_notifications: false,
|
nostr_notifications: false,
|
||||||
promo_codes: [],
|
promo_codes: [],
|
||||||
|
organizer_name: '',
|
||||||
|
reply_to_email: '',
|
||||||
notification_subject: '',
|
notification_subject: '',
|
||||||
notification_body: ''
|
notification_body: ''
|
||||||
}
|
}
|
||||||
|
|
@ -487,11 +599,15 @@ window.PageEvents = {
|
||||||
currency: 'sats',
|
currency: 'sats',
|
||||||
allow_fiat: false,
|
allow_fiat: false,
|
||||||
fiat_currency: 'GBP',
|
fiat_currency: 'GBP',
|
||||||
|
location: '',
|
||||||
|
categories: [],
|
||||||
extra: {
|
extra: {
|
||||||
payment_methods: ['lightning'],
|
payment_methods: ['lightning'],
|
||||||
email_notifications: false,
|
email_notifications: false,
|
||||||
nostr_notifications: false,
|
nostr_notifications: false,
|
||||||
promo_codes: [],
|
promo_codes: [],
|
||||||
|
organizer_name: '',
|
||||||
|
reply_to_email: '',
|
||||||
notification_subject: '',
|
notification_subject: '',
|
||||||
notification_body: ''
|
notification_body: ''
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -479,8 +479,10 @@
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.trim="formDialog.data.name"
|
v-model.trim="formDialog.data.name"
|
||||||
type="name"
|
type="text"
|
||||||
label="Title of event "
|
label="Title of event *"
|
||||||
|
lazy-rules
|
||||||
|
:rules="[val => !!val || 'Title is required']"
|
||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="col q-pl-sm">
|
<div class="col q-pl-sm">
|
||||||
|
|
@ -504,6 +506,30 @@
|
||||||
label="Info about the event"
|
label="Info about the event"
|
||||||
hint="Markdown supported"
|
hint="Markdown supported"
|
||||||
></q-input>
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.location"
|
||||||
|
type="text"
|
||||||
|
label="Location"
|
||||||
|
hint="Venue or address, e.g. Salle des fêtes, Foix. Published as the NIP-52 location tag."
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="place"></q-icon>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
multiple
|
||||||
|
use-chips
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
v-model="formDialog.data.categories"
|
||||||
|
:options="categoryOptions"
|
||||||
|
label="Categories"
|
||||||
|
hint="Published as NIP-52 hashtags so clients can filter the feed."
|
||||||
|
></q-select>
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
|
|
@ -512,25 +538,16 @@
|
||||||
label="Image URL"
|
label="Image URL"
|
||||||
hint="Optional banner image to display on the event page"
|
hint="Optional banner image to display on the event page"
|
||||||
></q-input>
|
></q-input>
|
||||||
<div class="row q-mt-lg">
|
|
||||||
<div class="col-4">Ticket closing date</div>
|
|
||||||
<div class="col-8">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formDialog.data.closing_date"
|
|
||||||
type="date"
|
|
||||||
></q-input>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row q-col-gutter-sm">
|
<div class="row q-col-gutter-sm">
|
||||||
<div class="col-4">Event begins</div>
|
<div class="col-4">Event begins *</div>
|
||||||
<div class="col-5">
|
<div class="col-5">
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.trim="formDialog.data.event_start_day"
|
v-model.trim="formDialog.data.event_start_day"
|
||||||
type="date"
|
type="date"
|
||||||
|
lazy-rules
|
||||||
|
:rules="[val => !!val || 'Start date is required']"
|
||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-3">
|
<div class="col-3">
|
||||||
|
|
@ -552,6 +569,10 @@
|
||||||
dense
|
dense
|
||||||
v-model.trim="formDialog.data.event_end_day"
|
v-model.trim="formDialog.data.event_end_day"
|
||||||
type="date"
|
type="date"
|
||||||
|
hint="Defaults to the start date"
|
||||||
|
lazy-rules
|
||||||
|
reactive-rules
|
||||||
|
:rules="[validateEndDate]"
|
||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-3">
|
<div class="col-3">
|
||||||
|
|
@ -564,6 +585,18 @@
|
||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row q-col-gutter-sm">
|
||||||
|
<div class="col-4">Ticket sales close</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.closing_date"
|
||||||
|
type="date"
|
||||||
|
hint="Optional. Defaults to the event end date."
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row q-col-gutter-sm">
|
<div class="row q-col-gutter-sm">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<q-select
|
<q-select
|
||||||
|
|
@ -571,7 +604,7 @@
|
||||||
dense
|
dense
|
||||||
v-model="formDialog.data.currency"
|
v-model="formDialog.data.currency"
|
||||||
type="text"
|
type="text"
|
||||||
label="Unit"
|
label="Price currency"
|
||||||
:options="currencies"
|
:options="currencies"
|
||||||
></q-select>
|
></q-select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -581,7 +614,9 @@
|
||||||
dense
|
dense
|
||||||
v-model.number="formDialog.data.amount_tickets"
|
v-model.number="formDialog.data.amount_tickets"
|
||||||
type="number"
|
type="number"
|
||||||
label="Amount of tickets "
|
min="0"
|
||||||
|
label="Amount of tickets"
|
||||||
|
hint="0 = unlimited"
|
||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
|
|
@ -600,34 +635,57 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="q-mt-sm">
|
<div class="q-mt-sm">
|
||||||
<div class="text-caption text-grey-7">Payment methods *</div>
|
<div class="text-subtitle2">Payment methods *</div>
|
||||||
<q-option-group
|
|
||||||
v-model="formDialog.data.extra.payment_methods"
|
|
||||||
type="checkbox"
|
|
||||||
inline
|
|
||||||
:options="paymentMethodOptions"
|
|
||||||
></q-option-group>
|
|
||||||
<div class="text-caption text-grey-7">
|
<div class="text-caption text-grey-7">
|
||||||
Card / fiat checkout goes through the fiat provider configured on
|
Pick the rails buyers can pay with. Untick Lightning for a
|
||||||
this LNbits instance. Untick Lightning for a card-only event.
|
card-only sale.
|
||||||
|
</div>
|
||||||
|
<div class="row q-col-gutter-md q-mt-xs">
|
||||||
|
<div
|
||||||
|
v-for="opt in paymentMethodOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
class="col-12 col-sm-6"
|
||||||
|
>
|
||||||
|
<!-- The span carries the tooltip: a disabled checkbox
|
||||||
|
swallows pointer events, the wrapper still hovers. -->
|
||||||
|
<span class="inline-block full-width">
|
||||||
|
<q-checkbox
|
||||||
|
v-model="formDialog.data.extra.payment_methods"
|
||||||
|
:val="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:disable="opt.value === 'fiat' && !hasFiatProvider"
|
||||||
|
></q-checkbox>
|
||||||
|
<div class="text-caption text-grey-7 q-pl-lg">
|
||||||
|
<span v-text="opt.hint"></span>
|
||||||
|
<span
|
||||||
|
v-if="opt.value === 'fiat' && hasFiatProvider"
|
||||||
|
v-text="' (' + fiatProviderNames + ')'"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<q-tooltip
|
||||||
|
v-if="opt.value === 'fiat' && !hasFiatProvider"
|
||||||
|
max-width="280px"
|
||||||
|
>
|
||||||
|
Your LNbits user has no fiat provider configured. Ask the
|
||||||
|
instance admin to enable Stripe, PayPal or Square (Admin →
|
||||||
|
Fiat providers) to accept card payments.
|
||||||
|
</q-tooltip>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<q-select
|
<q-select
|
||||||
v-if="
|
v-if="acceptsFiat && isSatPrice"
|
||||||
acceptsFiat &&
|
|
||||||
['sat', 'sats'].includes(
|
|
||||||
(formDialog.data.currency || '').toLowerCase()
|
|
||||||
)
|
|
||||||
"
|
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model="formDialog.data.fiat_currency"
|
v-model="formDialog.data.fiat_currency"
|
||||||
label="Fiat checkout currency"
|
label="Fiat currency"
|
||||||
:options="
|
hint="Currency card buyers are charged in."
|
||||||
currencies.filter(
|
:options="fiatCurrencyOptions"
|
||||||
c => !['sat', 'sats'].includes((c || '').toLowerCase())
|
lazy-rules
|
||||||
)
|
:rules="[
|
||||||
"
|
val => !!val || 'Pick a fiat currency for buyers paying by card'
|
||||||
|
]"
|
||||||
></q-select>
|
></q-select>
|
||||||
<q-expansion-item
|
<q-expansion-item
|
||||||
group="advanced"
|
group="advanced"
|
||||||
|
|
@ -765,6 +823,28 @@
|
||||||
label="Ticket notification body"
|
label="Ticket notification body"
|
||||||
hint="Shown before the ticket link in the paid ticket notification."
|
hint="Shown before the ticket link in the paid ticket notification."
|
||||||
></q-input>
|
></q-input>
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.extra.organizer_name"
|
||||||
|
type="text"
|
||||||
|
label="Organizer name (email sender)"
|
||||||
|
hint="Shown as 'Name via <site>' in the From line of ticket emails."
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.extra.reply_to_email"
|
||||||
|
type="email"
|
||||||
|
label="Reply-to email"
|
||||||
|
hint="Where attendee replies go. Blank = your account email."
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
<q-btn
|
<q-btn
|
||||||
|
|
@ -779,14 +859,9 @@
|
||||||
unelevated
|
unelevated
|
||||||
color="primary"
|
color="primary"
|
||||||
:disable="
|
:disable="
|
||||||
formDialog.data.wallet == null ||
|
!formDialog.data.wallet ||
|
||||||
formDialog.data.name == null ||
|
!formDialog.data.name ||
|
||||||
formDialog.data.info == null ||
|
!formDialog.data.event_start_day
|
||||||
formDialog.data.closing_date == null ||
|
|
||||||
formDialog.data.event_start_day == null ||
|
|
||||||
formDialog.data.event_end_day == null ||
|
|
||||||
formDialog.data.amount_tickets == null ||
|
|
||||||
formDialog.data.price_per_ticket == null
|
|
||||||
"
|
"
|
||||||
type="submit"
|
type="submit"
|
||||||
>Create Event</q-btn
|
>Create Event</q-btn
|
||||||
|
|
|
||||||
|
|
@ -87,3 +87,59 @@ def test_payload_includes_event_details_and_qr(monkeypatch):
|
||||||
assert "/events/api/v1/ticket-card/tkt1" in text
|
assert "/events/api/v1/ticket-card/tkt1" in text
|
||||||
assert "<img" not in html and "Ticket ID: tkt1" in html
|
assert "<img" not in html and "Ticket ID: tkt1" in html
|
||||||
assert '<a href="https://lnbits.example/events/api/v1/ticket-card/tkt1">' in html
|
assert '<a href="https://lnbits.example/events/api/v1/ticket-card/tkt1">' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_reply_to_and_organizer_sender_name(monkeypatch):
|
||||||
|
from ..services import organizer_sender_name
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "lnbits_site_title", "Oyez!")
|
||||||
|
event = _event(
|
||||||
|
extra={
|
||||||
|
"organizer_name": "Château du Faune",
|
||||||
|
"reply_to_email": "hello@faune.example",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert organizer_sender_name(event) == "Château du Faune via Oyez!"
|
||||||
|
assert organizer_sender_name(_event()) == "Oyez!"
|
||||||
|
|
||||||
|
msg = build_ticket_email(
|
||||||
|
"tickets@example.org",
|
||||||
|
["ada@example.com"],
|
||||||
|
"Subj",
|
||||||
|
"text",
|
||||||
|
None,
|
||||||
|
reply_to="hello@faune.example",
|
||||||
|
sender_name=organizer_sender_name(event),
|
||||||
|
)
|
||||||
|
assert msg["Reply-To"] == "hello@faune.example"
|
||||||
|
# Non-ASCII display names are RFC 2047-encoded on the wire; decode to compare.
|
||||||
|
from email.header import decode_header, make_header
|
||||||
|
|
||||||
|
assert str(make_header(decode_header(msg["From"]))) == (
|
||||||
|
"Château du Faune via Oyez! <tickets@example.org>"
|
||||||
|
)
|
||||||
|
|
||||||
|
no_reply = build_ticket_email(
|
||||||
|
"tickets@example.org", ["ada@example.com"], "Subj", "text"
|
||||||
|
)
|
||||||
|
assert no_reply["Reply-To"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_reply_hint_only_when_reply_to_exists(monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/")
|
||||||
|
event = _event(extra={"organizer_name": "Château du Faune"})
|
||||||
|
_, text, _ = _ticket_notification_payload(
|
||||||
|
_ticket(), event, reply_to="hello@faune.example"
|
||||||
|
)
|
||||||
|
assert "Organizer: Château du Faune" in text
|
||||||
|
assert "Reply to this email and it reaches Château du Faune" in text
|
||||||
|
_, text, _ = _ticket_notification_payload(_ticket(), event, reply_to=None)
|
||||||
|
assert "Reply to this email" not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_blank_reply_to_email_is_none():
|
||||||
|
from ..models import EventExtra
|
||||||
|
|
||||||
|
assert EventExtra(reply_to_email="").reply_to_email is None
|
||||||
|
assert EventExtra(reply_to_email=" ").reply_to_email is None
|
||||||
|
assert EventExtra(reply_to_email="a@b.example").reply_to_email == "a@b.example"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue