diff --git a/__init__.py b/__init__.py index ef37528..827b8d5 100644 --- a/__init__.py +++ b/__init__.py @@ -6,12 +6,13 @@ from .crud import db from .tasks import wait_for_paid_invoices from .views import events_generic_router -from .views_api import events_api_router, tickets_api_router +from .views_api import events_api_router, qr_api_router, tickets_api_router events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"]) events_ext.include_router(events_generic_router) events_ext.include_router(events_api_router) events_ext.include_router(tickets_api_router) +events_ext.include_router(qr_api_router) events_static_files = [ { diff --git a/crud.py b/crud.py index 3046f0e..930915e 100644 --- a/crud.py +++ b/crud.py @@ -1,9 +1,17 @@ from datetime import datetime, timedelta, timezone +from typing import cast -from lnbits.db import Database +from lnbits.db import Database, Filters, Page from lnbits.helpers import urlsafe_short_hash -from .models import CreateEvent, Event, Ticket, TicketExtra +from .models import ( + CreateEvent, + Event, + Ticket, + TicketExtra, + TicketFilters, + sync_event_ticket_waves, +) db = Database("ext_events") @@ -51,6 +59,31 @@ async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]: ) +async def get_tickets_paginated( + wallet_ids: str | list[str], filters: Filters[TicketFilters] | None = None +) -> Page[Ticket]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + wallet_where = [] + values = {} + for idx, wallet_id in enumerate(wallet_ids): + key = f"wallet_id_{idx}" + wallet_where.append(f":{key}") + values[key] = wallet_id + + where = [f"wallet IN ({', '.join(wallet_where)})", "paid = true"] + + return await db.fetch_page( + "SELECT * FROM events.ticket", + where=where, + values=values, + filters=filters, + model=Ticket, + table_name="events.ticket", + ) + + async def delete_ticket(payment_hash: str) -> None: await db.execute("DELETE FROM events.ticket WHERE id = :id", {"id": payment_hash}) @@ -75,31 +108,35 @@ async def purge_unpaid_tickets(event_id: str) -> None: async def create_event(data: CreateEvent) -> Event: event_id = urlsafe_short_hash() event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict()) + event = cast(Event, sync_event_ticket_waves(event)) await db.insert("events.events", event) return event async def update_event(event: Event) -> Event: + event = cast(Event, sync_event_ticket_waves(event)) await db.update("events.events", event) return event async def get_event(event_id: str) -> Event | None: - return await db.fetchone( + event = await db.fetchone( "SELECT * FROM events.events WHERE id = :id", {"id": event_id}, Event, ) + return cast(Event, sync_event_ticket_waves(event)) if event else None async def get_events(wallet_ids: str | list[str]) -> list[Event]: if isinstance(wallet_ids, str): wallet_ids = [wallet_ids] q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids]) - return await db.fetchall( + events = await db.fetchall( f"SELECT * FROM events.events WHERE wallet IN ({q})", model=Event, ) + return [cast(Event, sync_event_ticket_waves(event)) for event in events] async def delete_event(event_id: str) -> None: diff --git a/models.py b/models.py index fa1569f..331ad6d 100644 --- a/models.py +++ b/models.py @@ -1,6 +1,8 @@ -from datetime import datetime +from datetime import date, datetime +from uuid import uuid4 from fastapi import Query +from lnbits.db import FilterModel from pydantic import BaseModel, EmailStr, Field, validator @@ -20,8 +22,23 @@ def validate_discount_percent(cls, v): return v +class TicketWave(BaseModel): + id: str = Field(default_factory=lambda: uuid4().hex[:8]) + title: str = "Primary wave" + opening_date: str + closing_date: str + currency: str = "sat" + use_ticket_image: bool = False + ticket_image_id: str | None = None + allow_fiat: bool = False + fiat_currency: str = "GBP" + amount_tickets: int = Field(default=0, ge=0) + price_per_ticket: float = Field(default=0, ge=0) + + class EventExtra(BaseModel): promo_codes: list[PromoCode] = Field(default_factory=list) + ticket_waves: list[TicketWave] = Field(default_factory=list) conditional: bool = False min_tickets: int = 1 email_notifications: bool = False @@ -84,6 +101,8 @@ class PublicEvent(BaseModel): class TicketExtra(BaseModel): applied_promo_code: str | None = None + ticket_wave_id: str | None = None + ticket_wave_title: str | None = None sats_paid: int | None = None refund_address: str | None = None nostr_identifier: str | None = None @@ -96,6 +115,7 @@ class TicketExtra(BaseModel): class CreateTicket(BaseModel): name: str email: EmailStr + ticket_wave_id: str | None = None promo_code: str | None = None refund_address: str | None = None nostr_identifier: str | None = None @@ -116,6 +136,22 @@ class Ticket(BaseModel): extra: TicketExtra = Field(default_factory=TicketExtra) +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 @@ -131,3 +167,82 @@ class TicketPaymentRequest(BaseModel): fiat_payment_request: str | None = None fiat_provider: str | None = None is_fiat: bool = False + + +class TicketFilters(FilterModel): + __search_fields__ = ["event", "name", "email", "id"] # noqa: RUF012 + __sort_fields__ = [ # noqa: RUF012 + "time", + "event", + "name", + "email", + "registered", + "id", + ] + + event: str | None = None + name: str | None = None + email: str | None = None + registered: bool | None = None + paid: bool | None = None + id: str | None = None + + +def _parse_date(value: str) -> date: + return datetime.strptime(value, "%Y-%m-%d").date() + + +def ensure_ticket_waves(event: Event | PublicEvent | CreateEvent) -> list[TicketWave]: + ticket_waves = list(getattr(event.extra, "ticket_waves", []) or []) + if ticket_waves: + return ticket_waves + + fallback_opening_date = None + event_time = getattr(event, "time", None) + if event_time: + fallback_opening_date = event_time.date().isoformat() + if not fallback_opening_date: + fallback_opening_date = event.closing_date + + return [ + TicketWave( + id="primary", + title="Primary wave", + opening_date=fallback_opening_date, + closing_date=event.closing_date, + currency=event.currency, + allow_fiat=event.allow_fiat, + fiat_currency=event.fiat_currency, + amount_tickets=getattr(event, "amount_tickets", 0), + price_per_ticket=event.price_per_ticket, + ) + ] + + +def sync_event_ticket_waves(event: Event | CreateEvent) -> Event | CreateEvent: + ticket_waves = ensure_ticket_waves(event) + event.extra.ticket_waves = ticket_waves + + primary_wave = ticket_waves[0] + event.closing_date = max(wave.closing_date for wave in ticket_waves) + event.currency = primary_wave.currency + event.allow_fiat = primary_wave.allow_fiat + event.fiat_currency = primary_wave.fiat_currency + event.amount_tickets = sum(wave.amount_tickets for wave in ticket_waves) + event.price_per_ticket = primary_wave.price_per_ticket + + return event + + +def get_active_ticket_waves( + event: Event | PublicEvent, today: date | None = None +) -> list[TicketWave]: + current_day = today or datetime.utcnow().date() + return [ + wave + for wave in ensure_ticket_waves(event) + if _parse_date(wave.opening_date) + <= current_day + <= _parse_date(wave.closing_date) + and wave.amount_tickets > 0 + ] diff --git a/services.py b/services.py index 159bbdc..057026b 100644 --- a/services.py +++ b/services.py @@ -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 @@ 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}" +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 + + 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"

{escape(text_message).replace(chr(10), '
')}

" + ticket_image_url = _ticket_image_url(ticket, event) + if not ticket_image_url: + return html_message + + return ( + f"{html_message}" + f'

Ticket image

' + ) + + +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: - if "@" in identifier: - await send_user_notification( - UserNotifications(nostr_identifier=identifier), - message, - "text_message", - ) - return + await send_user_notification( + UserNotifications(nostr_identifier=identifier), + message, + "text_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) + +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. diff --git a/static/image/ticket.jpg b/static/image/ticket.jpg new file mode 100644 index 0000000..e05d931 Binary files /dev/null and b/static/image/ticket.jpg differ diff --git a/static/js/display.js b/static/js/display.js index d8be8e9..6b53d9d 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -13,6 +13,7 @@ window.PageEventsDisplay = { email: '', refund: '', nostr_identifier: '', + ticket_wave_id: null, payment_method: 'lightning' } }, @@ -40,16 +41,37 @@ window.PageEventsDisplay = { formatDescription() { return LNbits.utils.convertMarkdown(this.event?.info || '') }, + activeTicketWaves() { + const today = new Date().toISOString().slice(0, 10) + return (this.event?.extra?.ticket_waves || []).filter( + wave => + wave.amount_tickets > 0 && + wave.opening_date <= today && + wave.closing_date >= today + ) + }, + selectedTicketWave() { + return ( + this.activeTicketWaves.find( + wave => wave.id === this.formDialog.data.ticket_wave_id + ) || + this.activeTicketWaves[0] || + null + ) + }, + showTicketWaveSelector() { + return this.activeTicketWaves.length > 1 + }, allowFiatCheckout() { - return Boolean(this.event?.allow_fiat) + return Boolean(this.selectedTicketWave?.allow_fiat) }, fiatCheckoutLabel() { if (!this.allowFiatCheckout) return 'Fiat' const unit = ['sat', 'sats'].includes( - (this.event?.currency || '').toLowerCase() + (this.selectedTicketWave?.currency || '').toLowerCase() ) - ? this.event?.fiat_currency - : this.event?.currency + ? this.selectedTicketWave?.fiat_currency + : this.selectedTicketWave?.currency return `Fiat (${(unit || 'GBP').toUpperCase()})` }, allowEmailNotifications() { @@ -66,6 +88,16 @@ window.PageEventsDisplay = { 'GET', `/events/api/v1/events/${this.eventId}` ) + const activeWaves = (data.extra?.ticket_waves || []).filter(wave => { + const today = new Date().toISOString().slice(0, 10) + return ( + wave.amount_tickets > 0 && + wave.opening_date <= today && + wave.closing_date >= today + ) + }) + this.formDialog.data.ticket_wave_id = + activeWaves.length === 1 ? activeWaves[0].id : null return data } catch (error) { this.eventErrorLabel = 'Event unavailable.' @@ -78,6 +110,10 @@ window.PageEventsDisplay = { this.formDialog.data.email = '' this.formDialog.data.refund = '' this.formDialog.data.nostr_identifier = '' + this.formDialog.data.ticket_wave_id = + this.activeTicketWaves.length === 1 + ? this.activeTicketWaves[0].id + : null this.formDialog.data.payment_method = 'lightning' }, @@ -112,6 +148,10 @@ window.PageEventsDisplay = { this.formDialog.data.email = '' this.formDialog.data.refund = '' this.formDialog.data.nostr_identifier = '' + this.formDialog.data.ticket_wave_id = + this.activeTicketWaves.length === 1 + ? this.activeTicketWaves[0].id + : null this.formDialog.data.payment_method = 'lightning' Quasar.Notify.create({ type: 'positive', @@ -141,10 +181,13 @@ window.PageEventsDisplay = { { name: this.formDialog.data.name, email: this.formDialog.data.email, + ticket_wave_id: this.formDialog.data.ticket_wave_id || null, promo_code: this.formDialog.data.promo_code || null, refund_address: this.formDialog.data.refund || null, nostr_identifier: this.formDialog.data.nostr_identifier || null, - payment_method: this.formDialog.data.payment_method + payment_method: this.allowFiatCheckout + ? this.formDialog.data.payment_method + : 'lightning' } ) const isFiat = Boolean(data.is_fiat) @@ -178,7 +221,7 @@ window.PageEventsDisplay = { const url = new URL(window.location) url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - url.pathname = `/api/v1/ws/${paymentHash}` + url.pathname = `/events/api/v1/tickets/ws/${paymentHash}` url.search = '' url.hash = '' @@ -187,7 +230,7 @@ window.PageEventsDisplay = { ws.onmessage = event => { const data = JSON.parse(event.data) - if (data.pending === false) { + if (data.paid === true) { this.paymentSuccess(paymentHash) ws.close() } diff --git a/static/js/display.vue b/static/js/display.vue index f5ac5fb..9051fbf 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -49,7 +49,7 @@ filled dense v-model.trim="formDialog.data.nostr_identifier" - label="(optional) Nostr NIP-05 or npub" + label="(optional) Nostr NIP-05" hint="If provided, we'll DM your ticket link after payment." > @@ -64,6 +64,21 @@ lazy-rules :hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`" > +
this.shortenId(row.id) + }, {name: 'name', align: 'left', label: 'Name', field: 'name'}, { name: 'event_start_date', @@ -22,12 +30,6 @@ window.PageEvents = { label: 'End date', field: 'event_end_date' }, - { - name: 'closing_date', - align: 'left', - label: 'Ticket close', - field: 'closing_date' - }, { name: 'canceled', align: 'left', @@ -38,43 +40,21 @@ window.PageEvents = { } return 'No' } - }, - { - name: 'price_per_ticket', - align: 'left', - label: 'Price', - field: row => { - if (this.isFiatCurrency(row.currency)) { - return LNbits.utils.formatCurrency( - row.price_per_ticket.toFixed(2), - row.currency - ) - } - return row.price_per_ticket - } - }, - { - name: 'amount_tickets', - align: 'left', - label: 'No tickets', - field: 'amount_tickets' - }, - { - name: 'sold', - align: 'left', - label: 'Sold', - field: 'sold' - }, - {name: 'info', align: 'left', label: 'Info', field: 'info'}, - {name: 'banner', align: 'left', label: 'Banner', field: 'banner'} + } ], pagination: { rowsPerPage: 10 } }, ticketsTable: { + loading: false, columns: [ - {name: 'event', align: 'left', label: 'Event', field: 'event'}, + { + name: 'event', + align: 'left', + label: 'Event', + field: row => this.shortenId(row.event) + }, {name: 'name', align: 'left', label: 'Name', field: 'name'}, {name: 'email', align: 'left', label: 'Email', field: 'email'}, { @@ -83,6 +63,12 @@ window.PageEvents = { label: 'Registered', field: 'registered' }, + { + name: 'nostr', + align: 'left', + label: 'Nostr', + field: row => row.extra?.nostr_identifier || '' + }, { name: 'promo_code', align: 'left', @@ -92,7 +78,11 @@ window.PageEvents = { {name: 'id', align: 'left', label: 'ID', field: 'id'} ], pagination: { - rowsPerPage: 10 + sortBy: 'time', + descending: true, + page: 1, + rowsPerPage: 10, + rowsNumber: 10 } }, formDialog: { @@ -102,28 +92,198 @@ window.PageEvents = { allow_fiat: false, fiat_currency: 'GBP', extra: { + ticket_waves: [], promo_codes: [], notification_subject: '', notification_body: '' } } + }, + ticketWaveDialog: { + show: false, + eventId: null, + wallet: null, + editingWaveId: null, + data: { + id: null, + title: '', + opening_date: '', + closing_date: '', + currency: 'sats', + use_ticket_image: false, + ticket_image_id: null, + allow_fiat: false, + fiat_currency: 'GBP', + amount_tickets: 0, + price_per_ticket: 0 + } + }, + promoCodesDialog: { + show: false, + data: { + id: null, + wallet: null, + name: '', + extra: { + promo_codes: [] + } + } } } }, methods: { + shortenId(value) { + if (!value) return '' + return value.length > 4 ? `${value.slice(0, 4)}...` : value + }, + primaryTicketWave(data = this.formDialog.data) { + if (!data.extra) data.extra = {} + if (!data.extra.ticket_waves || data.extra.ticket_waves.length === 0) { + data.extra.ticket_waves = [ + { + id: 'primary', + title: 'Primary wave', + opening_date: data.closing_date || '', + closing_date: data.closing_date || '', + currency: data.currency || 'sats', + use_ticket_image: false, + ticket_image_id: null, + allow_fiat: Boolean(data.allow_fiat), + fiat_currency: data.fiat_currency || 'GBP', + amount_tickets: data.amount_tickets || 0, + price_per_ticket: data.price_per_ticket || 0 + } + ] + } + return data.extra.ticket_waves[0] + }, + syncPrimaryWaveFromForm(data = this.formDialog.data) { + const primaryWave = this.primaryTicketWave(data) + primaryWave.title = primaryWave.title || 'Primary wave' + primaryWave.opening_date = primaryWave.opening_date || '' + primaryWave.closing_date = data.closing_date || '' + primaryWave.currency = data.currency || 'sats' + primaryWave.use_ticket_image = Boolean(primaryWave.use_ticket_image) + primaryWave.ticket_image_id = primaryWave.ticket_image_id || null + primaryWave.allow_fiat = Boolean(data.allow_fiat) + primaryWave.fiat_currency = data.fiat_currency || 'GBP' + primaryWave.amount_tickets = Number(data.amount_tickets || 0) + primaryWave.price_per_ticket = Number(data.price_per_ticket || 0) + return primaryWave + }, + hydrateEventForm(data) { + const formData = { + ...data, + extra: { + ...(data.extra || {}), + ticket_waves: [...((data.extra && data.extra.ticket_waves) || [])] + } + } + const primaryWave = this.primaryTicketWave(formData) + formData.currency = primaryWave.currency || formData.currency || 'sats' + formData.allow_fiat = Boolean(primaryWave.allow_fiat) + formData.fiat_currency = primaryWave.fiat_currency || 'GBP' + formData.amount_tickets = primaryWave.amount_tickets + formData.price_per_ticket = primaryWave.price_per_ticket + formData.closing_date = + primaryWave.closing_date || formData.closing_date || '' + return formData + }, isFiatCurrency(currency) { return !['sat', 'sats'].includes((currency || '').toLowerCase()) }, - getTickets() { - LNbits.api - .request( + normalizePromoCodes(promoCodes = []) { + return promoCodes + .filter(code => code.code?.trim() !== '') + .map(code => ({ + ...code, + code: code.code.trim().toUpperCase() + })) + }, + templateDownloadUrl() { + return '/events/static/image/ticket.jpg' + }, + async uploadAssetFile(file) { + const form = new FormData() + form.append('file', file) + form.append('public_asset', 'true') + const {data} = await LNbits.api.request( + 'POST', + '/api/v1/assets?public_asset=true', + null, + form + ) + return data.id + }, + triggerTicketImageUpload(target) { + this.ticketImageUploadTarget = target + this.$refs.ticketImageUpload.value = null + this.$refs.ticketImageUpload.click() + }, + async handleTicketImageSelected(event) { + const file = event.target.files?.[0] + if (!file || !this.ticketImageUploadTarget) return + + this.isUploadingTicketTemplate = true + try { + const assetId = await this.uploadAssetFile(file) + if (this.ticketImageUploadTarget === 'primary') { + const wave = this.primaryTicketWave() + wave.use_ticket_image = true + wave.ticket_image_id = assetId + } else if (this.ticketImageUploadTarget === 'dialog') { + this.ticketWaveDialog.data.use_ticket_image = true + this.ticketWaveDialog.data.ticket_image_id = assetId + } + Quasar.Notify.create({ + type: 'positive', + message: 'Ticket template uploaded.', + icon: null + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.isUploadingTicketTemplate = false + this.ticketImageUploadTarget = null + } + }, + soldTicketsForWave(eventId, waveId) { + return this.allPaidTickets.filter( + ticket => + ticket.event === eventId && + ticket.paid && + (ticket.extra?.ticket_wave_id === waveId || + (!ticket.extra?.ticket_wave_id && waveId === 'primary')) + ).length + }, + async getAllTickets() { + try { + const {data} = await LNbits.api.request( 'GET', '/events/api/v1/tickets?all_wallets=true', this.g.user.wallets[0].adminkey ) - .then(response => { - this.tickets = response.data.filter(e => e.paid) - }) + this.allPaidTickets = data.filter(ticket => ticket.paid) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + async getTickets(props) { + try { + this.ticketsTable.loading = true + const params = LNbits.utils.prepareFilterQuery(this.ticketsTable, props) + const {data} = await LNbits.api.request( + 'GET', + `/events/api/v1/tickets/paginated?all_wallets=true&${params}`, + this.g.user.wallets[0].adminkey + ) + this.tickets = data.data + this.ticketsTable.pagination.rowsNumber = data.total + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.ticketsTable.loading = false + } }, deleteTicket(ticketId) { const tickets = _.findWhere(this.tickets, {id: ticketId}) @@ -138,10 +298,9 @@ window.PageEvents = { '/events/api/v1/tickets/' + ticketId, wallet.adminkey ) - .then(response => { - this.tickets = _.reject(this.tickets, function (obj) { - return obj.id == ticketId - }) + .then(async () => { + await this.getTickets() + await this.getAllTickets() }) .catch(LNbits.utils.notifyApiError) }) @@ -159,14 +318,30 @@ window.PageEvents = { wallet.adminkey ) .then(response => { + const result = response.data this.tickets = this.tickets.map(obj => - obj.id === ticket.id ? response.data : obj + obj.id === ticket.id ? result.ticket : obj ) - Quasar.Notify.create({ - type: 'positive', - message: 'Ticket email resent.', - icon: null - }) + + if (result.email?.attempted) { + Quasar.Notify.create({ + type: result.email.sent ? 'positive' : 'negative', + message: result.email.sent + ? 'Ticket email resent.' + : `Ticket email failed: ${result.email.error || 'Unknown error.'}`, + icon: null + }) + } + + if (result.nostr?.attempted) { + Quasar.Notify.create({ + type: result.nostr.sent ? 'positive' : 'negative', + message: result.nostr.sent + ? 'Ticket Nostr DM resent.' + : `Ticket Nostr DM failed: ${result.nostr.error || 'Unknown error.'}`, + icon: null + }) + } }) .catch(LNbits.utils.notifyApiError) .finally(() => { @@ -176,7 +351,7 @@ window.PageEvents = { }) }, exportticketsCSV() { - LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets) + LNbits.utils.exportCSV(this.ticketsTable.columns, this.allPaidTickets) }, getEvents() { LNbits.api @@ -195,19 +370,18 @@ window.PageEvents = { id: this.formDialog.data.wallet }) const data = this.formDialog.data + this.syncPrimaryWaveFromForm(data) if (data.extra?.promo_codes) { - data.extra.promo_codes = data.extra.promo_codes - .filter(code => code.code?.trim() !== '') - .map(code => ({ - ...code, - code: code.code.trim().toUpperCase() - })) + data.extra.promo_codes = this.normalizePromoCodes( + data.extra.promo_codes + ) } if (!this.isFiatCurrency(data.currency)) { if (!data.allow_fiat) { data.fiat_currency = 'GBP' } } + this.syncPrimaryWaveFromForm(data) if (data.id) { this.updateEvent(wallet, data) @@ -218,7 +392,7 @@ window.PageEvents = { openEventDialog(data = false) { if (data && data.id) { - this.formDialog.data = {...data} + this.formDialog.data = this.hydrateEventForm(data) } else { this.formDialog.data = { currency: 'sats', @@ -229,6 +403,21 @@ window.PageEvents = { min_tickets: 1, email_notifications: false, nostr_notifications: false, + ticket_waves: [ + { + id: 'primary', + title: 'Primary wave', + opening_date: '', + closing_date: '', + currency: 'sats', + use_ticket_image: false, + ticket_image_id: null, + allow_fiat: false, + fiat_currency: 'GBP', + amount_tickets: 0, + price_per_ticket: 0 + } + ], promo_codes: [], notification_subject: '', notification_body: '' @@ -244,8 +433,25 @@ window.PageEvents = { allow_fiat: false, fiat_currency: 'GBP', extra: { + conditional: false, + min_tickets: 1, email_notifications: false, nostr_notifications: false, + ticket_waves: [ + { + id: 'primary', + title: 'Primary wave', + opening_date: '', + closing_date: '', + currency: 'sats', + use_ticket_image: false, + ticket_image_id: null, + allow_fiat: false, + fiat_currency: 'GBP', + amount_tickets: 0, + price_per_ticket: 0 + } + ], promo_codes: [], notification_subject: '', notification_body: '' @@ -266,6 +472,177 @@ window.PageEvents = { const link = _.findWhere(this.events, {id: formId}) this.openEventDialog(link) }, + openTicketWaveDialog(event, wave = null) { + const primaryWave = (event.extra?.ticket_waves || [])[0] || {} + const isEditing = Boolean(wave) + this.ticketWaveDialog = { + show: true, + eventId: event.id, + wallet: event.wallet, + editingWaveId: wave?.id || null, + data: { + id: wave?.id || null, + title: wave?.title || '', + opening_date: wave?.opening_date || '', + closing_date: wave?.closing_date || '', + currency: + wave?.currency || primaryWave.currency || event.currency || 'sats', + use_ticket_image: Boolean(wave?.use_ticket_image), + ticket_image_id: wave?.ticket_image_id || null, + allow_fiat: isEditing + ? Boolean(wave?.allow_fiat) + : Boolean(primaryWave.allow_fiat ?? event.allow_fiat), + fiat_currency: + wave?.fiat_currency || + primaryWave.fiat_currency || + event.fiat_currency || + 'GBP', + amount_tickets: wave?.amount_tickets || 0, + price_per_ticket: + wave?.price_per_ticket || + primaryWave.price_per_ticket || + event.price_per_ticket || + 0 + } + } + }, + resetTicketWaveDialog() { + this.ticketWaveDialog = { + show: false, + eventId: null, + wallet: null, + editingWaveId: null, + data: { + id: null, + title: '', + opening_date: '', + closing_date: '', + currency: 'sats', + use_ticket_image: false, + ticket_image_id: null, + allow_fiat: false, + fiat_currency: 'GBP', + amount_tickets: 0, + price_per_ticket: 0 + } + } + }, + saveTicketWave() { + const event = _.findWhere(this.events, { + id: this.ticketWaveDialog.eventId + }) + const wallet = _.findWhere(this.g.user.wallets, { + id: this.ticketWaveDialog.wallet + }) + if (!event || !wallet) return + + const payload = { + ...event, + extra: { + ...event.extra, + ticket_waves: (event.extra?.ticket_waves || []).map(existingWave => + existingWave.id === this.ticketWaveDialog.editingWaveId + ? {...this.ticketWaveDialog.data} + : existingWave + ) + } + } + + if (!this.ticketWaveDialog.editingWaveId) { + payload.extra.ticket_waves.push({...this.ticketWaveDialog.data}) + } + + if (payload.extra?.promo_codes) { + payload.extra.promo_codes = this.normalizePromoCodes( + payload.extra.promo_codes + ) + } + + LNbits.api + .request( + 'PUT', + '/events/api/v1/events/' + payload.id, + wallet.adminkey, + payload + ) + .then(response => { + this.events = this.events.map(item => + item.id === payload.id ? response.data : item + ) + Quasar.Notify.create({ + type: 'positive', + message: this.ticketWaveDialog.editingWaveId + ? 'Ticket wave updated.' + : 'Ticket wave added.', + icon: null + }) + this.resetTicketWaveDialog() + }) + .catch(LNbits.utils.notifyApiError) + }, + openPromoCodesDialog(event) { + this.promoCodesDialog.data = { + ...event, + extra: { + ...event.extra, + promo_codes: [...(event.extra?.promo_codes || [])] + } + } + this.promoCodesDialog.show = true + }, + resetPromoCodesDialog() { + this.promoCodesDialog.show = false + this.promoCodesDialog.data = { + id: null, + wallet: null, + name: '', + extra: { + promo_codes: [] + } + } + }, + addPromoCodeToDialog() { + this.promoCodesDialog.data.extra.promo_codes.push({ + code: '', + discount_percent: 0, + active: true + }) + }, + savePromoCodes() { + const data = this.promoCodesDialog.data + const wallet = _.findWhere(this.g.user.wallets, { + id: data.wallet + }) + if (!wallet) return + + const payload = { + ...data, + extra: { + ...data.extra, + promo_codes: this.normalizePromoCodes(data.extra?.promo_codes || []) + } + } + + LNbits.api + .request( + 'PUT', + '/events/api/v1/events/' + data.id, + wallet.adminkey, + payload + ) + .then(response => { + this.events = this.events.map(event => + event.id === data.id ? response.data : event + ) + Quasar.Notify.create({ + type: 'positive', + message: 'Promo codes updated.', + icon: null + }) + this.resetPromoCodesDialog() + }) + .catch(LNbits.utils.notifyApiError) + }, updateEvent(wallet, data) { LNbits.api .request( @@ -332,6 +709,7 @@ window.PageEvents = { async created() { if (this.g.user.wallets.length) { this.getTickets() + this.getAllTickets() this.getEvents() if (this.g.allowedCurrencies && this.g.allowedCurrencies.length > 0) { this.currencies = ['sats', ...this.g.allowedCurrencies] diff --git a/static/js/index.vue b/static/js/index.vue index fa39a4e..b90e922 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -100,45 +100,102 @@
-
Promo codes
+
+
Ticket waves
+ +
+
+
+
+ + + +
+
+
+ +
+
Promo codes
+ +
- No promo codes for this event. + No active promo codes for this event.
-
-
+
+
- - +
-
- Discount: - % -
-
- Status: - -
@@ -165,9 +222,11 @@ dense flat :rows="tickets" + :loading="ticketsTable.loading" row-key="id" :columns="ticketsTable.columns" v-model:pagination="ticketsTable.pagination" + @request="getTickets" > diff --git a/static/js/ticket.js b/static/js/ticket.js index 82fbd6d..6ff55b6 100644 --- a/static/js/ticket.js +++ b/static/js/ticket.js @@ -3,16 +3,36 @@ window.PageEventsTicket = { data() { return { ticketId: null, - ticket: null + ticket: null, + printMode: false, + qrSrc: '' } }, methods: { - printWindow() { - window.print() + async printWindow() { + this.printMode = true + await this.$nextTick() + await this.waitForPrintAssets() + setTimeout(() => window.print(), 50) + }, + async waitForPrintAssets() { + await this.$nextTick() + const img = document.querySelector('.ticket-print-qr') + if (!img) return + if (img.complete && img.naturalWidth > 0) return + await new Promise(resolve => { + const done = () => resolve() + img.addEventListener('load', done, {once: true}) + img.addEventListener('error', done, {once: true}) + setTimeout(done, 500) + }) } }, async created() { this.ticketId = this.$route.params.id + this.qrSrc = `/api/v1/qrcode?data=${encodeURIComponent( + `ticket://${this.ticketId}` + )}` try { const {data} = await LNbits.api.request( 'GET', @@ -22,5 +42,8 @@ window.PageEventsTicket = { } catch (error) { LNbits.utils.notifyApiError(error) } + window.addEventListener('afterprint', () => { + this.printMode = false + }) } } diff --git a/static/js/ticket.vue b/static/js/ticket.vue index 3c932e1..23a8dc4 100644 --- a/static/js/ticket.vue +++ b/static/js/ticket.vue @@ -36,4 +36,53 @@
+ + +
+ Ticket QR +
+
+ + diff --git a/views_api.py b/views_api.py index abdc1ef..49b8ec9 100644 --- a/views_api.py +++ b/views_api.py @@ -1,8 +1,11 @@ import asyncio from datetime import datetime, timezone from http import HTTPStatus +from io import BytesIO +from pathlib import Path from typing import Any +import pyqrcode # type: ignore[import-untyped] from fastapi import ( APIRouter, Depends, @@ -12,22 +15,27 @@ WebSocket, WebSocketDisconnect, ) +from fastapi.responses import StreamingResponse from lnbits.core.crud import get_user +from lnbits.core.crud.assets import get_public_asset from lnbits.core.crud.wallets import get_wallet from lnbits.core.models import WalletTypeInfo from lnbits.core.models.payments import CreateInvoice from lnbits.core.services import create_payment_request +from lnbits.db import Filters, Page from lnbits.decorators import ( + parse_filters, require_admin_key, require_invoice_key, ) +from lnbits.helpers import generate_filter_params_openapi from lnbits.settings import settings from lnbits.utils.exchange_rates import ( fiat_amount_as_satoshis, get_fiat_rate_satoshis, satoshis_amount_as_fiat, ) -from lnbits.utils.nostr import normalize_public_key +from PIL import Image, ImageDraw from .crud import ( create_event, @@ -39,6 +47,7 @@ get_events, get_ticket, get_tickets, + get_tickets_paginated, purge_unpaid_tickets, update_event, update_ticket, @@ -50,19 +59,53 @@ PublicEvent, PublicTicket, Ticket, + TicketFilters, TicketPaymentRequest, + TicketResendResult, + ensure_ticket_waves, + get_active_ticket_waves, ) from .services import refund_tickets, resend_ticket_email_notification from .tasks import deregister_payment_listener, register_payment_listener events_api_router = APIRouter(prefix="/api/v1/events") tickets_api_router = APIRouter(prefix="/api/v1/tickets") +qr_api_router = APIRouter(prefix="/api/v1") +tickets_filters = parse_filters(TicketFilters) def _is_fiat_currency(currency: str | None) -> bool: return str(currency or "").lower() not in {"sat", "sats"} +def make_qr_png(data: str, size: int = 235, border: int = 4) -> Image.Image: + qr = pyqrcode.create(data) + 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) + + return img + + @events_api_router.get("") async def api_events( all_wallets: bool = Query(False), @@ -86,24 +129,29 @@ async def api_get_event(event_id: str) -> Event: ) await purge_unpaid_tickets(event_id) - is_window_open = datetime.now(timezone.utc) < datetime.strptime( - event.closing_date, "%Y-%m-%d" - ).replace(tzinfo=timezone.utc) + today = datetime.now(timezone.utc).date() + active_waves = get_active_ticket_waves(event, today) + is_sales_closed = today > datetime.strptime(event.closing_date, "%Y-%m-%d").date() is_min_tickets_met = ( event.sold >= event.extra.min_tickets if event.extra.conditional else True ) if event.amount_tickets < 1: raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.") - if event.extra.conditional and not is_min_tickets_met and not is_window_open: + if event.extra.conditional and not is_min_tickets_met and is_sales_closed: event.canceled = True await update_event(event) await refund_tickets(event_id) raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.") - if not is_window_open: + if not active_waves: raise HTTPException( - status_code=HTTPStatus.GONE, detail="Ticket closing date has passed." + status_code=HTTPStatus.GONE, + detail=( + "Ticket closing date has passed." + if is_sales_closed + else "No ticket wave is currently open." + ), ) return event @@ -127,8 +175,17 @@ async def api_event_create( raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Not your event." ) - for k, v in data.dict().items(): - setattr(event, k, v) + event = Event( + **{ + **event.dict(), + **data.dict(), + "id": event.id, + "wallet": event.wallet, + "time": event.time, + "sold": event.sold, + "canceled": event.canceled, + } + ) event = await update_event(event) else: event = await create_event(data) @@ -187,6 +244,31 @@ async def api_tickets( return await get_tickets(wallet_ids) +@tickets_api_router.get( + "/paginated", + summary="Get paginated list of tickets", + openapi_extra=generate_filter_params_openapi(TicketFilters), + response_model=Page[Ticket], +) +async def api_tickets_paginated( + all_wallets: bool = Query(False), + filters: Filters = Depends(tickets_filters), + key_info: WalletTypeInfo = Depends(require_admin_key), +) -> Page[Ticket]: + wallet_ids = [key_info.wallet.id] + + if all_wallets: + user = await get_user(key_info.wallet.user) + wallet_ids = user.wallet_ids if user else [] + + if not filters.sortby: + filters.sortby = "time" + if not filters.direction: + filters.direction = "desc" + + return await get_tickets_paginated(wallet_ids, filters) + + @tickets_api_router.get("/{ticket_id}", response_model=PublicTicket) async def api_get_ticket(ticket_id: str) -> Ticket: ticket = await get_ticket(ticket_id) @@ -202,6 +284,71 @@ async def api_get_ticket(ticket_id: str) -> Ticket: return ticket +@qr_api_router.get("/qr/{ticket_id}", response_class=StreamingResponse) +async def api_ticket_qr(ticket_id: str): + ticket = await get_ticket(ticket_id) + if not ticket: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist." + ) + + event = await get_event(ticket.event) + if not event: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." + ) + + waves = ensure_ticket_waves(event) + wave = next( + (wave for wave in waves if wave.id == ticket.extra.ticket_wave_id), + waves[0], + ) + + qr_img = make_qr_png(f"ticket://{ticket_id}", size=157) + output = BytesIO() + + if not wave.use_ticket_image: + qr_img.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", + }, + ) + + background_bytes = None + if wave.ticket_image_id: + asset = await get_public_asset(wave.ticket_image_id) + if asset: + background_bytes = asset.data + + if background_bytes: + ticket_image = Image.open(BytesIO(background_bytes)).convert("RGBA") + else: + default_template = ( + Path(__file__).resolve().parent / "static" / "image" / "ticket.jpg" + ) + ticket_image = Image.open(default_template).convert("RGBA") + + ticket_image.paste(qr_img, (122, 505)) + ticket_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", + }, + ) + + @tickets_api_router.post("/{event_id}") async def api_ticket_create( event_id: str, data: CreateTicket, request: Request @@ -215,7 +362,7 @@ async def api_ticket_create( if event.canceled: raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.") - if event.amount_tickets > 0 and event.sold >= event.amount_tickets: + if event.amount_tickets < 1: raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.") name = data.name @@ -230,14 +377,36 @@ async def api_ticket_create( detail="Unsupported payment method.", ) if nostr_identifier and "@" not in nostr_identifier: - try: - nostr_identifier = normalize_public_key(nostr_identifier) - except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Only NIP-05 Nostr identifiers are supported.", + ) + active_waves = get_active_ticket_waves(event) + if not active_waves: + raise HTTPException( + status_code=HTTPStatus.GONE, detail="No ticket wave is currently open." + ) + + selected_wave = None + if data.ticket_wave_id: + selected_wave = next( + (wave for wave in active_waves if wave.id == data.ticket_wave_id), + None, + ) + if not selected_wave: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, - detail="Invalid Nostr identifier.", - ) from exc - price = event.price_per_ticket + detail="Invalid ticket wave selected.", + ) + elif len(active_waves) == 1: + selected_wave = active_waves[0] + else: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Please select a ticket wave.", + ) + + price = selected_wave.price_per_ticket extra: dict[str, Any] = {"tag": "events", "name": name, "email": email} if promo_code: @@ -249,31 +418,31 @@ async def api_ticket_create( # get the promocode promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code) extra["promo_code"] = promo.code - price = event.price_per_ticket * (1 - promo.discount_percent / 100) + price = selected_wave.price_per_ticket * (1 - promo.discount_percent / 100) - if payment_method == "fiat" and not event.allow_fiat: + if payment_method == "fiat" and not selected_wave.allow_fiat: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="Fiat payments are not enabled for this event.", ) - if _is_fiat_currency(event.currency): + if _is_fiat_currency(selected_wave.currency): extra["fiat"] = True - extra["currency"] = event.currency + extra["currency"] = selected_wave.currency extra["fiatAmount"] = price - extra["rate"] = await get_fiat_rate_satoshis(event.currency) + extra["rate"] = await get_fiat_rate_satoshis(selected_wave.currency) if payment_method != "fiat": - price = await fiat_amount_as_satoshis(price, event.currency) + price = await fiat_amount_as_satoshis(price, selected_wave.currency) - invoice_unit = event.currency + invoice_unit = selected_wave.currency fiat_amount = price fiat_provider = None if payment_method == "fiat": - if _is_fiat_currency(event.currency): - invoice_unit = event.currency + if _is_fiat_currency(selected_wave.currency): + invoice_unit = selected_wave.currency else: - invoice_unit = event.fiat_currency + invoice_unit = selected_wave.fiat_currency fiat_amount = await satoshis_amount_as_fiat(price, invoice_unit) extra["fiat"] = True extra["currency"] = invoice_unit @@ -314,6 +483,8 @@ async def api_ticket_create( email=email, extra={ "applied_promo_code": promo_code, + "ticket_wave_id": selected_wave.id, + "ticket_wave_title": selected_wave.title, "refund_address": refund_address, "nostr_identifier": nostr_identifier, "ticket_base_url": str(request.base_url).rstrip("/"), @@ -388,10 +559,12 @@ async def api_ticket_delete( await delete_ticket(ticket_id) -@tickets_api_router.post("/{ticket_id}/resend-email") +@tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult) async def api_ticket_resend_email( - ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) -) -> Ticket: + ticket_id: str, + request: Request, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> TicketResendResult: ticket = await get_ticket(ticket_id) if not ticket: raise HTTPException( @@ -408,16 +581,13 @@ async def api_ticket_resend_email( ) try: - return await resend_ticket_email_notification(ticket) + return await resend_ticket_email_notification( + ticket, str(request.base_url).rstrip("/") + ) except ValueError as exc: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail=str(exc) ) from exc - except Exception as exc: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Failed to resend ticket email.", - ) from exc @tickets_api_router.put("/register/{ticket_id}")