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'
'
+ )
+
+
+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
+
+
+
+
- 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"
>
@@ -319,36 +378,57 @@
hint="Optional banner image to display on the event page"
>
-
Ticket closing date
+
Event begins
-
Event begins
+
Event ends
-
-
-
Event ends
-
+
+
+ Primary ticket wave (you can add other waves later)
+
+
@@ -387,6 +467,43 @@
>
+
+
+
+
+ Download template
+ 400/733 jpg
+
+
+
+ Replace
+
+
+ Custom ticket template uploaded.
+
+
-
Promo Codes
+
Ticket Delivery
- Allow users to enter a promo code for discounts.
+ Send the paid ticket link automatically by email or Nostr DM.
+
+
-
-
-
-
-
-
-
-
+ label="Ticket notification subject"
+ hint="Used as the email subject when sending paid ticket links."
+ >
-
-
-
-
-
-
- Add Promo Code
-
-
-
Ticket Delivery
-
- Send the paid ticket link automatically by email or Nostr DM.
+ v-model.trim="formDialog.data.extra.notification_body"
+ type="textarea"
+ label="Ticket notification body"
+ hint="Shown before the ticket link in the paid ticket notification."
+ >
-
-
-
-
-
-
+
+
+
+
+ Promo Codes
+
+ Allow users to enter a promo code for discounts.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Add Promo Code
+
+
+
+ Save Promo Codes
+ Cancel
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Download template
+ 400/733 jpg
+
+
+
+ Replace
+
+
+ Custom ticket template uploaded.
+
+
+
+
+
+ {{
+ ticketWaveDialog.editingWaveId
+ ? 'Update Ticket Wave'
+ : 'Save Ticket Wave'
+ }}
+ Cancel
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
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}")