feat: UI fixes, ticket waves, ticket image (#52)

* email input fixes

* promo code ui fix

* ticket waves

* works, but horrible slop

* slightly cleaner

* filter most recent

* make

* pyright
This commit is contained in:
Arc 2026-05-22 20:18:39 +01:00 • committed by GitHub
commit 777c107c63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1557 additions and 302 deletions

View file

@ -1,15 +1,15 @@
from __future__ import annotations
import smtplib
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.services.nostr import send_nostr_dm
from lnbits.core.services.notifications import (
send_email_notification,
send_user_notification,
)
from lnbits.core.services.notifications import send_user_notification
from lnbits.helpers import is_valid_email_address
from lnbits.settings import settings
from lnbits.utils.nostr import normalize_private_key, normalize_public_key
from lnurl import execute
from loguru import logger
@ -20,13 +20,13 @@ from .crud import (
update_event,
update_ticket,
)
from .models import Event, Ticket
DEFAULT_NOSTR_RELAYS = [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://relay.nostr.band",
]
from .models import (
Event,
NotificationDeliveryResult,
Ticket,
TicketResendResult,
ensure_ticket_waves,
)
async def set_ticket_paid(ticket: Ticket) -> Ticket:
@ -39,7 +39,16 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket:
event = await get_event(ticket.event)
assert event, "Couldn't get event from ticket being paid"
event.sold += 1
event.amount_tickets -= 1
ticket_waves = event.extra.ticket_waves or []
if ticket_waves:
selected_wave = next(
(wave for wave in ticket_waves if wave.id == ticket.extra.ticket_wave_id),
ticket_waves[0],
)
if selected_wave.amount_tickets > 0:
selected_wave.amount_tickets -= 1
elif event.amount_tickets > 0:
event.amount_tickets -= 1
await update_event(event)
return ticket
@ -55,40 +64,12 @@ async def _send_ticket_notification(ticket: Ticket) -> None:
logger.warning(f"Event {ticket.event} not found for ticket notification.")
return
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)
await _deliver_ticket_notifications(ticket, event)
async def resend_ticket_email_notification(ticket: Ticket) -> Ticket:
async def resend_ticket_email_notification(
ticket: Ticket, base_url: str | None = None
) -> TicketResendResult:
event = await get_event(ticket.event)
if not event:
raise ValueError("Event does not exist.")
@ -96,11 +77,10 @@ async def resend_ticket_email_notification(ticket: Ticket) -> Ticket:
raise ValueError("Email notifications are not enabled.")
if not ticket.email:
raise ValueError("Ticket does not have an email address.")
if base_url:
ticket.extra.ticket_base_url = base_url.rstrip("/")
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)
return await _deliver_ticket_notifications(ticket, event)
def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str]:
@ -117,18 +97,144 @@ def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str
return subject, f"{body}\n\nOpen it here: {ticket_url}"
async def _send_nostr_ticket_notification(identifier: str, message: str) -> None:
if "@" in identifier:
await send_user_notification(
UserNotifications(nostr_identifier=identifier),
message,
"text_message",
)
return
def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) -> str:
ticket_image_url = _ticket_image_url(ticket, event)
if not ticket_image_url:
return base_message
private_key = normalize_private_key(settings.lnbits_nostr_notifications_private_key)
public_key = normalize_public_key(identifier)
await send_nostr_dm(private_key, public_key, message, DEFAULT_NOSTR_RELAYS)
return f"{base_message}\n\nTicket image: {ticket_image_url}"
def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) -> str:
text_message = _ticket_delivery_message(ticket, event, base_message)
html_message = f"<p>{escape(text_message).replace(chr(10), '<br />')}</p>"
ticket_image_url = _ticket_image_url(ticket, event)
if not ticket_image_url:
return html_message
return (
f"{html_message}"
f'<p><img src="{escape(ticket_image_url, quote=True)}" alt="Ticket image" '
'style="max-width: 200px; 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, event, base_message)
html_message = _ticket_email_html_message(ticket, event, base_message)
return subject, text_message, html_message
def _supports_nostr_delivery(identifier: str | None) -> bool:
return bool(identifier and "@" in identifier)
async def _deliver_ticket_notifications(
ticket: Ticket, event: Event
) -> TicketResendResult:
subject, text_message, html_message = _ticket_notification_payload(ticket, event)
updated = False
result = TicketResendResult(
ticket=ticket,
email=NotificationDeliveryResult(
attempted=bool(
event.extra.email_notifications
and settings.lnbits_email_notifications_enabled
and ticket.email
)
),
nostr=NotificationDeliveryResult(
attempted=bool(
event.extra.nostr_notifications
and settings.is_nostr_notifications_configured()
and ticket.extra.nostr_identifier
)
),
)
if result.email.attempted:
try:
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 and not _supports_nostr_delivery(
ticket.extra.nostr_identifier
):
result.nostr.error = "Only NIP-05 Nostr identifiers are supported."
elif result.nostr.attempted:
try:
identifier = ticket.extra.nostr_identifier
assert identifier is not None
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_nostr_ticket_notification(identifier: str, message: str) -> None:
await send_user_notification(
UserNotifications(nostr_identifier=identifier),
message,
"text_message",
)
async def _send_ticket_email_notification(
to_emails: list[str],
message: str,
subject: str,
html_message: str | None = None,
) -> None:
if not settings.lnbits_email_notifications_enabled:
raise ValueError("Email notifications are disabled")
if not is_valid_email_address(settings.lnbits_email_notifications_email):
raise ValueError(
f"Invalid from email address: {settings.lnbits_email_notifications_email}"
)
if not to_emails:
raise ValueError("No email addresses provided")
for email in to_emails:
if not is_valid_email_address(email):
raise ValueError(f"Invalid email address: {email}")
msg = MIMEMultipart("alternative")
msg["From"] = settings.lnbits_email_notifications_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 settings.lnbits_email_notifications_email
)
with smtplib.SMTP(
settings.lnbits_email_notifications_server,
settings.lnbits_email_notifications_port,
) as smtp_server:
smtp_server.starttls()
smtp_server.login(username, settings.lnbits_email_notifications_password)
smtp_server.sendmail(
settings.lnbits_email_notifications_email,
to_emails,
msg.as_string(),
)
def _ticket_url(ticket: Ticket) -> str:
@ -136,6 +242,19 @@ def _ticket_url(ticket: Ticket) -> str:
return f"{base_url}/events/ticket/{ticket.id}"
def _ticket_image_url(ticket: Ticket, event: Event) -> str | None:
waves = ensure_ticket_waves(event)
wave = next(
(wave for wave in waves if wave.id == ticket.extra.ticket_wave_id),
waves[0],
)
if not wave.use_ticket_image:
return None
base_url = (ticket.extra.ticket_base_url or settings.lnbits_baseurl).rstrip("/")
return f"{base_url}/events/api/v1/qr/{ticket.id}"
async def refund_tickets(event_id: str):
"""
Refund tickets for an event that has not met the minimum ticket requirement.