fix: Use db.insert() for ticket creation to fix SQLite serialization #1

Closed
padreug wants to merge 8 commits from fix/sqlite-ticket-extra-serialization into main
14 changed files with 844 additions and 81 deletions

56
API_DOCUMENTATION.md Normal file
View file

@ -0,0 +1,56 @@
# Events API Documentation
## Public Events Endpoint
### GET `/api/v1/events/public`
Retrieve all events in the database with read-only access. No authentication required.
**Authentication:** None required (public endpoint)
**Headers:**
```
None required
```
**Query Parameters:**
- None
**Response:**
```json
[
{
"id": "event_id",
"wallet": "wallet_id",
"name": "Event Name",
"info": "Event description",
"closing_date": "2024-12-31",
"event_start_date": "2024-12-01",
"event_end_date": "2024-12-02",
"currency": "sat",
"amount_tickets": 100,
"price_per_ticket": 1000.0,
"time": "2024-01-01T00:00:00Z",
"sold": 0,
"banner": null
}
]
```
**Example Usage:**
```bash
curl http://your-lnbits-instance/events/api/v1/events/public
```
**Notes:**
- This endpoint allows read-only access to all events in the database
- No authentication required (truly public endpoint)
- Returns events ordered by creation time (newest first)
- Suitable for public event listings or read-only integrations
## Comparison with Existing Endpoints
| Endpoint | Authentication | Scope | Use Case |
|----------|---------------|-------|----------|
| `/api/v1/events` | Invoice Key | User's wallets only | Private event management |
| `/api/v1/events/public` | None | All events | Public event browsing |

View file

@ -2,9 +2,9 @@
<small>For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions)</small> <small>For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions)</small>
## Sell tickets for events and use the built-in scanner for registering attendants ## Sell tickets for events and use the built-in scanner for registering attendees
Events alows you to make tickets for an event. Each ticket is in the form of a uniqque QR code. After registering, and paying for ticket, the user gets a QR code to present at registration/entrance. Events alows you to make tickets for an event. Each ticket is in the form of a unique QR code. After registering, and paying for ticket, the user gets a QR code to present at registration/entrance.
Events includes a shareable ticket scanner, which can be used to register attendees. Events includes a shareable ticket scanner, which can be used to register attendees.

140
crud.py
View file

@ -1,54 +1,147 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional
from lnbits.db import Database from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
from .models import CreateEvent, Event, Ticket from .models import CreateEvent, Event, Ticket, TicketExtra
db = Database("ext_events") db = Database("ext_events")
async def create_ticket( async def create_ticket(
payment_hash: str, wallet: str, event: str, name: str, email: str payment_hash: str,
wallet: str,
event: str,
name: Optional[str] = None,
email: Optional[str] = None,
user_id: Optional[str] = None,
extra: Optional[dict] = None,
) -> Ticket: ) -> Ticket:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
ticket = Ticket(
# TODO: Check if this empty string workaround is still needed.
# This converts None to empty strings for database storage because:
# 1. Database may have NOT NULL constraints on name/email columns
# 2. When user_id is provided, name/email are not used (mutually exclusive)
# 3. The get_ticket() functions convert empty strings back to None when reading
# Consider using nullable columns instead of this empty string pattern.
if user_id:
db_name = ""
db_email = ""
else:
db_name = name or ""
db_email = email or ""
# Create ticket with database-compatible values for insertion
# Using db.insert() ensures proper serialization of the extra field (TicketExtra)
# across all database backends (SQLite, PostgreSQL, CockroachDB)
db_ticket = Ticket(
id=payment_hash,
wallet=wallet,
event=event,
name=db_name,
email=db_email,
user_id=user_id,
registered=False,
paid=False,
reg_timestamp=now,
time=now,
extra=TicketExtra(**extra) if extra else TicketExtra(),
)
await db.insert("events.ticket", db_ticket)
# Return ticket with original name/email values (not empty strings)
# This maintains consistency with how get_ticket() converts empty strings back to None
return Ticket(
id=payment_hash, id=payment_hash,
wallet=wallet, wallet=wallet,
event=event, event=event,
name=name, name=name,
email=email, email=email,
user_id=user_id,
registered=False, registered=False,
paid=False, paid=False,
reg_timestamp=now, reg_timestamp=now,
time=now, time=now,
extra=TicketExtra(**extra) if extra else TicketExtra(),
) )
await db.insert("events.ticket", ticket)
return ticket
async def update_ticket(ticket: Ticket) -> Ticket: async def update_ticket(ticket: Ticket) -> Ticket:
await db.update("events.ticket", ticket) # Create a new Ticket object with corrected values for database constraints
ticket_dict = ticket.dict()
# Convert None values to empty strings for database constraints
if ticket_dict.get("name") is None:
ticket_dict["name"] = ""
if ticket_dict.get("email") is None:
ticket_dict["email"] = ""
# Create a new Ticket object with the corrected values
corrected_ticket = Ticket(**ticket_dict)
await db.update("events.ticket", corrected_ticket)
return ticket return ticket
async def get_ticket(payment_hash: str) -> Ticket | None: async def get_ticket(payment_hash: str) -> Optional[Ticket]:
return await db.fetchone( row = await db.fetchone(
"SELECT * FROM events.ticket WHERE id = :id", "SELECT * FROM events.ticket WHERE id = :id",
{"id": payment_hash}, {"id": payment_hash},
Ticket,
) )
if not row:
return None
# Convert empty strings back to None for the model
ticket_data = dict(row)
if ticket_data.get("name") == "":
ticket_data["name"] = None
if ticket_data.get("email") == "":
ticket_data["email"] = None
return Ticket(**ticket_data)
async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]: async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]:
if isinstance(wallet_ids, str): if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids] wallet_ids = [wallet_ids]
q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids]) q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids])
return await db.fetchall( rows = await db.fetchall(f"SELECT * FROM events.ticket WHERE wallet IN ({q})")
f"SELECT * FROM events.ticket WHERE wallet IN ({q})",
model=Ticket, tickets = []
for row in rows:
# Convert empty strings back to None for the model
ticket_data = dict(row)
if ticket_data.get("name") == "":
ticket_data["name"] = None
if ticket_data.get("email") == "":
ticket_data["email"] = None
tickets.append(Ticket(**ticket_data))
return tickets
async def get_tickets_by_user_id(user_id: str) -> list[Ticket]:
"""Get all tickets for a specific user by their user_id"""
rows = await db.fetchall(
"SELECT * FROM events.ticket WHERE user_id = :user_id ORDER BY time DESC",
{"user_id": user_id}
) )
tickets = []
for row in rows:
# Convert empty strings back to None for the model
ticket_data = dict(row)
if ticket_data.get("name") == "":
ticket_data["name"] = None
if ticket_data.get("email") == "":
ticket_data["email"] = None
tickets.append(Ticket(**ticket_data))
return tickets
async def delete_ticket(payment_hash: str) -> None: async def delete_ticket(payment_hash: str) -> None:
await db.execute("DELETE FROM events.ticket WHERE id = :id", {"id": payment_hash}) await db.execute("DELETE FROM events.ticket WHERE id = :id", {"id": payment_hash})
@ -101,13 +194,32 @@ async def get_events(wallet_ids: str | list[str]) -> list[Event]:
) )
async def get_all_events() -> list[Event]:
"""Get all events from the database without wallet filtering."""
return await db.fetchall(
"SELECT * FROM events.events ORDER BY time DESC",
model=Event,
)
async def delete_event(event_id: str) -> None: async def delete_event(event_id: str) -> None:
await db.execute("DELETE FROM events.events WHERE id = :id", {"id": event_id}) await db.execute("DELETE FROM events.events WHERE id = :id", {"id": event_id})
async def get_event_tickets(event_id: str) -> list[Ticket]: async def get_event_tickets(event_id: str) -> list[Ticket]:
return await db.fetchall( rows = await db.fetchall(
"SELECT * FROM events.ticket WHERE event = :event", "SELECT * FROM events.ticket WHERE event = :event",
{"event": event_id}, {"event": event_id},
Ticket,
) )
tickets = []
for row in rows:
# Convert empty strings back to None for the model
ticket_data = dict(row)
if ticket_data.get("name") == "":
ticket_data["name"] = None
if ticket_data.get("email") == "":
ticket_data["email"] = None
tickets.append(Ticket(**ticket_data))
return tickets

View file

@ -160,3 +160,34 @@ async def m005_add_image_banner(db):
Add a column to allow an image banner for the event Add a column to allow an image banner for the event
""" """
await db.execute("ALTER TABLE events.events ADD COLUMN banner TEXT;") await db.execute("ALTER TABLE events.events ADD COLUMN banner TEXT;")
async def m006_add_user_id_support(db):
"""
Add user_id column to tickets table to support LNbits user-id as identifier
Make name and email optional when user_id is provided
"""
await db.execute("ALTER TABLE events.ticket ADD COLUMN user_id TEXT;")
# Since SQLite doesn't support changing column constraints directly,
# we'll work around this by allowing the application logic to handle
# the validation that either (name AND email) OR user_id is provided
# The database will continue to expect name and email as NOT NULL
# but we'll insert empty strings for user_id tickets
async def m007_add_extra_fields(db):
"""
Add a canceled and 'extra' column to events and ticket tables
to support promo codes and ticket metadata.
"""
# Add canceled and 'extra' columns to events table
# SQLite requires separate ALTER TABLE statements for each column
await db.execute(
"ALTER TABLE events.events ADD COLUMN canceled BOOLEAN NOT NULL DEFAULT FALSE;"
)
await db.execute(
"ALTER TABLE events.events ADD COLUMN extra TEXT;"
)
# Add 'extra' column to ticket table
await db.execute("ALTER TABLE events.ticket ADD COLUMN extra TEXT;")

View file

@ -1,7 +1,30 @@
from datetime import datetime from datetime import datetime
from typing import Optional
from fastapi import Query from fastapi import Query
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr, Field, root_validator, validator
class PromoCode(BaseModel):
code: str
discount_percent: float = 0.0
active: bool = True
# make the promo code uppercase
@validator("code")
def uppercase_code(cls, v):
return v.upper()
@validator("discount_percent")
def validate_discount_percent(cls, v):
assert 0 <= v <= 100, "Discount must be between 0 and 100."
return v
class EventExtra(BaseModel):
promo_codes: list[PromoCode] = Field(default_factory=list)
conditional: bool = False
min_tickets: int = 1
class CreateEvent(BaseModel): class CreateEvent(BaseModel):
@ -14,12 +37,29 @@ class CreateEvent(BaseModel):
currency: str = "sat" currency: str = "sat"
amount_tickets: int = Query(..., ge=0) amount_tickets: int = Query(..., ge=0)
price_per_ticket: float = Query(..., ge=0) price_per_ticket: float = Query(..., ge=0)
banner: str | None = None banner: Optional[str] = None
extra: EventExtra = Field(default_factory=EventExtra)
class CreateTicket(BaseModel): class CreateTicket(BaseModel):
name: str name: Optional[str] = None
email: EmailStr email: Optional[EmailStr] = None
user_id: Optional[str] = None
promo_code: Optional[str] = None
refund_address: Optional[str] = None
@root_validator
def validate_identifiers(cls, values):
# Ensure either (name AND email) OR user_id is provided
name = values.get('name')
email = values.get('email')
user_id = values.get('user_id')
if not user_id and not (name and email):
raise ValueError("Either user_id or both name and email must be provided")
if user_id and (name or email):
raise ValueError("Cannot provide both user_id and name/email")
return values
class Event(BaseModel): class Event(BaseModel):
@ -28,6 +68,7 @@ class Event(BaseModel):
name: str name: str
info: str info: str
closing_date: str closing_date: str
canceled: bool = False
event_start_date: str event_start_date: str
event_end_date: str event_end_date: str
currency: str currency: str
@ -36,15 +77,25 @@ class Event(BaseModel):
time: datetime time: datetime
sold: int = 0 sold: int = 0
banner: str | None = None banner: str | None = None
extra: EventExtra = Field(default_factory=EventExtra)
class TicketExtra(BaseModel):
applied_promo_code: str | None = None
sats_paid: int | None = None
refund_address: str | None = None
refunded: bool = False
class Ticket(BaseModel): class Ticket(BaseModel):
id: str id: str
wallet: str wallet: str
event: str event: str
name: str name: Optional[str] = None
email: str email: Optional[str] = None
user_id: Optional[str] = None
registered: bool registered: bool
paid: bool paid: bool
time: datetime time: datetime
reg_timestamp: datetime reg_timestamp: datetime
extra: TicketExtra = Field(default_factory=TicketExtra)

View file

@ -1,4 +1,13 @@
from .crud import get_event, update_event, update_ticket from lnurl import execute
from loguru import logger
from .crud import (
get_event,
get_event_tickets,
purge_unpaid_tickets,
update_event,
update_ticket,
)
from .models import Ticket from .models import Ticket
@ -16,3 +25,30 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket:
await update_event(event) await update_event(event)
return ticket return ticket
async def refund_tickets(event_id: str):
"""
Refund tickets for an event that has not met the minimum ticket requirement.
This function should be called when the event is closed and the minimum ticket
condition is not met.
"""
await purge_unpaid_tickets(event_id)
tickets = await get_event_tickets(event_id)
if not tickets:
return
for ticket in tickets:
if ticket.extra.refunded:
continue
if ticket.paid and ticket.extra.refund_address and ticket.extra.sats_paid:
try:
res = await execute(
ticket.extra.refund_address, str(ticket.extra.sats_paid)
)
if res:
ticket.extra.refunded = True
await update_ticket(ticket)
except Exception as e:
logger.error(f"Error refunding ticket {ticket.id}: {e}")

View file

@ -9,7 +9,8 @@ window.app = Vue.createApp({
show: false, show: false,
data: { data: {
name: '', name: '',
email: '' email: '',
refund: ''
} }
}, },
ticketLink: { ticketLink: {
@ -29,7 +30,8 @@ window.app = Vue.createApp({
this.info = event_info this.info = event_info
this.info = this.info.substring(1, this.info.length - 1) this.info = this.info.substring(1, this.info.length - 1)
this.banner = event_banner this.banner = event_banner
await this.purgeUnpaidTickets() this.extra = event_extra
this.hasPromoCodes = has_promoCodes
}, },
computed: { computed: {
formatDescription() { formatDescription() {
@ -41,6 +43,7 @@ window.app = Vue.createApp({
e.preventDefault() e.preventDefault()
this.formDialog.data.name = '' this.formDialog.data.name = ''
this.formDialog.data.email = '' this.formDialog.data.email = ''
this.formDialog.data.refund = ''
}, },
closeReceiveDialog() { closeReceiveDialog() {
@ -60,12 +63,12 @@ window.app = Vue.createApp({
const regex = /^[\w\.-]+@[a-zA-Z\d\.-]+\.[a-zA-Z]{2,}$/ const regex = /^[\w\.-]+@[a-zA-Z\d\.-]+\.[a-zA-Z]{2,}$/
return regex.test(val) || 'Please enter valid email.' return regex.test(val) || 'Please enter valid email.'
}, },
Invoice() { Invoice() {
axios axios
.post(`/events/api/v1/tickets/${event_id}`, { .post(`/events/api/v1/tickets/${event_id}`, {
name: this.formDialog.data.name, name: this.formDialog.data.name,
email: this.formDialog.data.email email: this.formDialog.data.email,
promo_code: this.formDialog.data.promo_code || null
}) })
.then(response => { .then(response => {
this.paymentReq = response.data.payment_request this.paymentReq = response.data.payment_request
@ -122,13 +125,6 @@ window.app = Vue.createApp({
}, 2000) }, 2000)
}) })
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
},
async purgeUnpaidTickets() {
try {
await LNbits.api.request('GET', `/events/api/v1/purge/${event_id}`)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
} }
} }
}) })

View file

@ -1,9 +1,6 @@
const mapEvents = function (obj) { const mapEvents = function (obj) {
obj.date = Quasar.date.formatDate( obj.date = LNbits.utils.formatTimestamp(obj.time)
new Date(obj.time * 1000), obj.fsat = new Intl.NumberFormat(window.g.locale).format(obj.price_per_ticket)
'YYYY-MM-DD HH:mm'
)
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.price_per_ticket)
obj.displayUrl = ['/events/', obj.id].join('') obj.displayUrl = ['/events/', obj.id].join('')
return obj return obj
} }
@ -20,8 +17,6 @@ window.app = Vue.createApp({
columns: [ columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'}, {name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'name', align: 'left', label: 'Name', field: 'name'}, {name: 'name', align: 'left', label: 'Name', field: 'name'},
{name: 'info', align: 'left', label: 'Info', field: 'info'},
{name: 'banner', align: 'left', label: 'Banner', field: 'banner'},
{ {
name: 'event_start_date', name: 'event_start_date',
align: 'left', align: 'left',
@ -40,6 +35,17 @@ window.app = Vue.createApp({
label: 'Ticket close', label: 'Ticket close',
field: 'closing_date' field: 'closing_date'
}, },
{
name: 'canceled',
align: 'left',
label: 'Canceled',
field: row => {
if (row.extra.conditional && row.canceled) {
return 'Yes'
}
return 'No'
}
},
{ {
name: 'price_per_ticket', name: 'price_per_ticket',
align: 'left', align: 'left',
@ -65,7 +71,9 @@ window.app = Vue.createApp({
align: 'left', align: 'left',
label: 'Sold', label: 'Sold',
field: 'sold' field: 'sold'
} },
{name: 'info', align: 'left', label: 'Info', field: 'info'},
{name: 'banner', align: 'left', label: 'Banner', field: 'banner'}
], ],
pagination: { pagination: {
rowsPerPage: 10 rowsPerPage: 10
@ -73,7 +81,6 @@ window.app = Vue.createApp({
}, },
ticketsTable: { ticketsTable: {
columns: [ columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'event', align: 'left', label: 'Event', field: 'event'}, {name: 'event', align: 'left', label: 'Event', field: 'event'},
{name: 'name', align: 'left', label: 'Name', field: 'name'}, {name: 'name', align: 'left', label: 'Name', field: 'name'},
{name: 'email', align: 'left', label: 'Email', field: 'email'}, {name: 'email', align: 'left', label: 'Email', field: 'email'},
@ -82,7 +89,14 @@ window.app = Vue.createApp({
align: 'left', align: 'left',
label: 'Registered', label: 'Registered',
field: 'registered' field: 'registered'
} },
{
name: 'promo_code',
align: 'left',
label: 'Promo Code',
field: row => row.extra.applied_promo_code || ''
},
{name: 'id', align: 'left', label: 'ID', field: 'id'}
], ],
pagination: { pagination: {
rowsPerPage: 10 rowsPerPage: 10
@ -90,7 +104,11 @@ window.app = Vue.createApp({
}, },
formDialog: { formDialog: {
show: false, show: false,
data: {} data: {
extra: {
promo_codes: []
}
}
} }
} }
}, },
@ -143,9 +161,10 @@ window.app = Vue.createApp({
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
.then(response => { .then(response => {
this.events = response.data.map(function (obj) { this.events = response.data.map(obj => {
return mapEvents(obj) return mapEvents(obj)
}) })
this.checkCanceledEvents()
}) })
}, },
sendEventData() { sendEventData() {
@ -153,6 +172,11 @@ window.app = Vue.createApp({
id: this.formDialog.data.wallet id: this.formDialog.data.wallet
}) })
const data = this.formDialog.data const data = this.formDialog.data
if (data.extra && !data.extra.promo_codes) {
data.extra.promo_codes = data.extra.promo_codes
.filter(code => code.trim() !== '')
.map(code => code.trim().toUpperCase())
}
if (data.id) { if (data.id) {
this.updateEvent(wallet, data) this.updateEvent(wallet, data)
@ -161,20 +185,41 @@ window.app = Vue.createApp({
} }
}, },
openEventDialog(data = false) {
if (data && data.id) {
this.formDialog.data = {...data}
} else {
this.formDialog.data = {
extra: {
conditional: false,
min_tickets: 1,
promo_codes: []
}
}
}
this.formDialog.show = true
},
resetEventDialog() {
this.formDialog.show = false
this.formDialog.data = {
extra: {
promo_codes: []
}
}
},
createEvent(wallet, data) { createEvent(wallet, data) {
LNbits.api LNbits.api
.request('POST', '/events/api/v1/events', wallet.adminkey, data) .request('POST', '/events/api/v1/events', wallet.adminkey, data)
.then(response => { .then(response => {
this.events.push(mapEvents(response.data)) this.events.push(mapEvents(response.data))
this.formDialog.show = false this.resetEventDialog()
this.formDialog.data = {}
}) })
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
}, },
updateformDialog(formId) { updateformDialog(formId) {
const link = _.findWhere(this.events, {id: formId}) const link = _.findWhere(this.events, {id: formId})
this.formDialog.data = {...link} this.openEventDialog(link)
this.formDialog.show = true
}, },
updateEvent(wallet, data) { updateEvent(wallet, data) {
LNbits.api LNbits.api
@ -189,8 +234,7 @@ window.app = Vue.createApp({
return obj.id == data.id return obj.id == data.id
}) })
this.events.push(mapEvents(response.data)) this.events.push(mapEvents(response.data))
this.formDialog.show = false this.resetEventDialog()
this.formDialog.data = {}
}) })
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
}, },
@ -216,6 +260,30 @@ window.app = Vue.createApp({
}, },
exporteventsCSV() { exporteventsCSV() {
LNbits.utils.exportCSV(this.eventsTable.columns, this.events) LNbits.utils.exportCSV(this.eventsTable.columns, this.events)
},
async checkCanceledEvents() {
const events = this.events
.filter(event => event.extra.conditional)
.filter(e => !e.canceled)
if (!events.length) return
const now = new Date()
events.forEach(async ev => {
if (new Date(ev.closing_date) < now && ev.sold < ev.extra.min_tickets) {
const {data} = await LNbits.api.request(
'PUT',
'/events/api/v1/events/' + ev.id + '/cancel',
_.findWhere(this.g.user.wallets, {id: ev.wallet}).adminkey
)
Quasar.Notify.create({
type: 'warning',
message: `Event ${ev.name} has been canceled and refunds have been issued.`,
icon: null
})
this.events = this.events.map(e =>
e.id === ev.id ? mapEvents(data) : e
)
}
})
} }
}, },
async created() { async created() {

View file

@ -21,8 +21,12 @@ async def on_invoice_paid(payment: Payment) -> None:
if not payment.extra or "events" != payment.extra.get("tag"): if not payment.extra or "events" != payment.extra.get("tag"):
return return
if not payment.extra.get("name") or not payment.extra.get("email"): # Check if ticket has either name/email or user_id
logger.warning(f"Ticket {payment.payment_hash} missing name or email.") has_name_email = payment.extra.get("name") and payment.extra.get("email")
has_user_id = payment.extra.get("user_id")
if not has_name_email and not has_user_id:
logger.warning(f"Ticket {payment.payment_hash} missing name/email or user_id.")
return return
ticket = await get_ticket(payment.payment_hash) ticket = await get_ticket(payment.payment_hash)

View file

@ -6,7 +6,7 @@
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h3 class="q-my-none q-pa-lg">{{ event_name }}</h3> <h3 class="q-my-none q-pa-lg">{{ event_name }}</h3>
<br /> <br />
<div v-html="formatDescription"></div> <div v-html="formatDescription" class="q-pa-md"></div>
<br /> <br />
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -30,7 +30,23 @@
:rules="[val => !!val || '* Required', val => emailValidation(val)]" :rules="[val => !!val || '* Required', val => emailValidation(val)]"
lazy-rules lazy-rules
></q-input> ></q-input>
<q-input
v-if="this.extra?.conditional"
filled
dense
v-model.trim="formDialog.data.refund"
label="Refund lnadress or LNURL "
:rules="[val => !!val || '* Required']"
lazy-rules
:hint="`If minimum tickets (${this.extra?.min_tickets}) are not met, refund will be sent.`"
></q-input>
<q-input
v-if="hasPromoCodes"
filled
dense
v-model.trim="formDialog.data.promo_code"
label="Apply Promo Code "
></q-input>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
unelevated unelevated
@ -93,6 +109,8 @@
const event_name = '{{ event_name }}' const event_name = '{{ event_name }}'
const event_info = '{{ event_info | tojson }}' const event_info = '{{ event_info | tojson }}'
const event_banner = JSON.parse('{{ event_banner | tojson | safe }}') const event_banner = JSON.parse('{{ event_banner | tojson | safe }}')
const event_extra = JSON.parse('{{ event_extra | safe }}')
const has_promoCodes = {{ has_promo_codes | tojson }}
</script> </script>
<script src="{{ static_url_for('events/static', path='js/display.js') }}"></script> <script src="{{ static_url_for('events/static', path='js/display.js') }}"></script>
{% endblock %} {% endblock %}

View file

@ -4,7 +4,7 @@
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md"> <div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card> <q-card>
<q-card-section> <q-card-section>
<q-btn unelevated color="primary" @click="formDialog.show = true" <q-btn unelevated color="primary" @click="openEventDialog"
>New Event</q-btn >New Event</q-btn
> >
</q-card-section> </q-card-section>
@ -33,7 +33,7 @@
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr :props="props"> <q-tr :props="props">
<q-th auto-width></q-th> <q-th auto-width></q-th>
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props"> <q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.label"></span> <span v-text="col.label"></span>
</q-th> </q-th>
@ -43,6 +43,16 @@
</template> </template>
<template v-slot:body="props"> <template v-slot:body="props">
<q-tr :props="props"> <q-tr :props="props">
<q-td auto-width>
<q-btn
size="sm"
color="accent"
round
dense
@click="props.expand = !props.expand"
:icon="props.expand ? 'expand_less' : 'expand_more'"
/>
</q-td>
<q-td auto-width> <q-td auto-width>
<q-btn <q-btn
unelevated unelevated
@ -89,6 +99,52 @@
></q-btn> ></q-btn>
</q-td> </q-td>
</q-tr> </q-tr>
<q-tr v-show="props.expand" :props="props">
<q-td colspan="100%">
<div class="q-pa-md">
<div class="text-subtitle1 q-mb-md">Promo codes</div>
<div class="column">
<div
v-if="props.row.extra.promo_codes.length == 0"
class="text-caption"
>
No promo codes for this event.
</div>
<div
v-for="(code, index) in props.row.extra.promo_codes"
:key="index"
class="row items-center q-col-gutter-sm q-mb-sm"
>
<div class="col-auto">
<q-chip
square
size="md"
clickable
@click="utils.copyText(code.code.toUpperCase())"
>
<q-avatar
icon="bookmark"
:color="code.active ? 'green' : 'grey'"
text-color="white"
></q-avatar>
<span v-text="code.code.toUpperCase()"></span>
</q-chip>
</div>
<div class="col-auto">
Discount: <span v-text="code.discount_percent"></span>%
</div>
<div class="col-auto">
Status:
<span
:class="code.active ? 'text-green' : 'text-grey'"
v-text="code.active ? 'Active' : 'Inactive'"
></span>
</div>
</div>
</div>
</div>
</q-td>
</q-tr>
</template> </template>
</q-table> </q-table>
</q-card-section> </q-card-section>
@ -224,7 +280,6 @@
></q-input> ></q-input>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-4">Event begins</div> <div class="col-4">Event begins</div>
<div class="col-8"> <div class="col-8">
@ -248,7 +303,6 @@
></q-input> ></q-input>
</div> </div>
</div> </div>
<div class="row q-col-gutter-sm"> <div class="row q-col-gutter-sm">
<div class="col"> <div class="col">
<q-select <q-select
@ -280,9 +334,101 @@
:mask="formDialog.data.currency != 'sats' ? '#.##' : '#'" :mask="formDialog.data.currency != 'sats' ? '#.##' : '#'"
fill-mask="0" fill-mask="0"
reverse-fill-mask reverse-fill-mask
:disable="formDialog.data.currency == null"
></q-input> ></q-input>
</div> </div>
</div> </div>
<q-expansion-item
group="advanced"
icon="settings"
label="Advanced options"
>
<div class="row q-mt-lg">
<div class="text-subtitle1 q-mb-md">Conditional Events</div>
<div class="text-caption">
Make this event conditional if
<strong>minimum tickets</strong> are sold. User will be asked to
provide a Lightning Address or LNURL pay for refunds.
</div>
<div class="col-8">
<q-toggle
v-model="formDialog.data.extra.conditional"
label="Conditional Event"
left-label
></q-toggle>
</div>
<div class="col-4">
<q-input
filled
dense
v-model.number="formDialog.data.extra.min_tickets"
type="number"
label="Minimum Tickets"
:disable="!formDialog.data.extra.conditional"
></q-input>
</div>
</div>
<q-separator class="q-my-md"></q-separator>
<div class="text-subtitle1 q-mb-md">Promo Codes</div>
<div class="text-caption">
Allow users to enter a promo code for discounts.
</div>
<div
v-for="(code, index) in formDialog.data.extra.promo_codes"
:key="index"
class="row q-col-gutter-sm q-mt-md"
>
<q-input
class="col-8"
filled
dense
v-model.trim="formDialog.data.extra.promo_codes[index].code"
type="text"
label="Promo Code"
>
<template v-slot:before>
<q-checkbox
left-label
v-model="formDialog.data.extra.promo_codes[index].active"
checked-icon="radio_button_checked"
unchecked-icon="radio_button_unchecked"
></q-checkbox>
<q-tooltip>
<span
v-text="formDialog.data.extra.promo_codes[index].active ? 'Active' : 'Inactive'"
></span>
</q-tooltip>
</template>
</q-input>
<q-input
class="col-4"
filled
dense
v-model.number="formDialog.data.extra.promo_codes[index].discount_percent"
type="number"
label="Discount (%)"
min="0"
max="100"
>
<template v-slot:after>
<q-btn
round
dense
flat
icon="delete"
@click="formDialog.data.extra.promo_codes.splice(index, 1)"
></q-btn>
</template>
</q-input>
</div>
<div class="col-12 q-mt-md">
<q-btn
@click="formDialog.data.extra.promo_codes.push({code: '', discount_percent: 0, active: true})"
>Add Promo Code</q-btn
>
</div>
</q-expansion-item>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn

108
tests/test_api.py Normal file
View file

@ -0,0 +1,108 @@
import pytest
from fastapi.testclient import TestClient
from unittest.mock import AsyncMock, patch
from ..views_api import events_api_router
from ..models import Event
from datetime import datetime, timezone
@pytest.mark.asyncio
async def test_api_events_public():
"""Test the new public events API endpoint"""
from fastapi import FastAPI
app = FastAPI()
app.include_router(events_api_router)
# Mock the database
with patch('events.crud.get_all_events') as mock_get_all_events:
# Create mock events
mock_events = [
Event(
id="test_event_1",
wallet="test_wallet_1",
name="Test Event 1",
info="Test event description",
closing_date="2024-12-31",
event_start_date="2024-12-01",
event_end_date="2024-12-02",
currency="sat",
amount_tickets=100,
price_per_ticket=1000.0,
time=datetime.now(timezone.utc),
sold=0,
banner=None
),
Event(
id="test_event_2",
wallet="test_wallet_2",
name="Test Event 2",
info="Another test event",
closing_date="2024-12-31",
event_start_date="2024-12-03",
event_end_date="2024-12-04",
currency="sat",
amount_tickets=50,
price_per_ticket=500.0,
time=datetime.now(timezone.utc),
sold=0,
banner=None
)
]
mock_get_all_events.return_value = mock_events
client = TestClient(app)
# Test the endpoint without any authentication
response = client.get("/api/v1/events/public")
# Verify the response
assert response.status_code == 200
data = response.json()
assert len(data) == 2
assert data[0]["id"] == "test_event_1"
assert data[1]["id"] == "test_event_2"
assert data[0]["name"] == "Test Event 1"
assert data[1]["name"] == "Test Event 2"
@pytest.mark.asyncio
async def test_get_all_events_crud():
"""Test the get_all_events CRUD function"""
from events.crud import get_all_events
with patch('events.crud.db.fetchall') as mock_fetchall:
# Mock database response
mock_events = [
{
"id": "test_event_1",
"wallet": "test_wallet_1",
"name": "Test Event 1",
"info": "Test event description",
"closing_date": "2024-12-31",
"event_start_date": "2024-12-01",
"event_end_date": "2024-12-02",
"currency": "sat",
"amount_tickets": 100,
"price_per_ticket": 1000.0,
"time": datetime.now(timezone.utc),
"sold": 0,
"banner": None
}
]
mock_fetchall.return_value = mock_events
events = await get_all_events()
# Verify the function was called with correct parameters
mock_fetchall.assert_called_once_with(
"SELECT * FROM events.events ORDER BY time DESC",
model=Event,
)
# Verify the result
assert len(events) == 1
assert events[0]["id"] == "test_event_1"

View file

@ -8,7 +8,8 @@ from lnbits.helpers import template_renderer
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse from starlette.responses import HTMLResponse
from .crud import get_event, get_ticket from .crud import get_event, get_ticket, purge_unpaid_tickets, update_event
from .services import refund_tickets
events_generic_router = APIRouter() events_generic_router = APIRouter()
@ -32,6 +33,15 @@ async def display(request: Request, event_id):
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
) )
await purge_unpaid_tickets(event_id)
is_window_open = (
date.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: if event.amount_tickets < 1:
return events_renderer().TemplateResponse( return events_renderer().TemplateResponse(
"events/error.html", "events/error.html",
@ -41,8 +51,20 @@ async def display(request: Request, event_id):
"event_error": "Sorry, tickets are sold out :(", "event_error": "Sorry, tickets are sold out :(",
}, },
) )
datetime_object = datetime.strptime(event.closing_date, "%Y-%m-%d").date() if event.extra.conditional and not is_min_tickets_met and not is_window_open:
if date.today() > datetime_object: event.canceled = True
await update_event(event)
await refund_tickets(event_id)
return events_renderer().TemplateResponse(
"events/error.html",
{
"request": request,
"event_name": event.name,
"event_error": "Sorry, event was cancelled.",
},
)
if not is_window_open:
return events_renderer().TemplateResponse( return events_renderer().TemplateResponse(
"events/error.html", "events/error.html",
{ {
@ -52,6 +74,12 @@ async def display(request: Request, event_id):
}, },
) )
if len(event.extra.promo_codes) > 0:
has_promo_codes = True
else:
has_promo_codes = False
event.extra.promo_codes = []
return events_renderer().TemplateResponse( return events_renderer().TemplateResponse(
"events/display.html", "events/display.html",
{ {
@ -61,6 +89,8 @@ async def display(request: Request, event_id):
"event_info": event.info, "event_info": event.info,
"event_price": event.price_per_ticket, "event_price": event.price_per_ticket,
"event_banner": event.banner, "event_banner": event.banner,
"event_extra": event.extra.json(),
"has_promo_codes": has_promo_codes,
}, },
) )

View file

@ -26,12 +26,13 @@ from .crud import (
get_events, get_events,
get_ticket, get_ticket,
get_tickets, get_tickets,
get_tickets_by_user_id,
purge_unpaid_tickets, purge_unpaid_tickets,
update_event, update_event,
update_ticket, update_ticket,
) )
from .models import CreateEvent, CreateTicket, Ticket from .models import CreateEvent, CreateTicket, Ticket
from .services import set_ticket_paid from .services import refund_tickets, set_ticket_paid
events_api_router = APIRouter() events_api_router = APIRouter()
@ -50,6 +51,18 @@ async def api_events(
return [event.dict() for event in await get_events(wallet_ids)] return [event.dict() for event in await get_events(wallet_ids)]
@events_api_router.get("/api/v1/events/public")
async def api_events_public():
"""
Retrieve all events in the database with read-only access.
This endpoint allows access to all events using any valid API key (read access).
"""
# Get all events from the database without wallet filtering
from .crud import get_all_events
events = await get_all_events()
return [event.dict() for event in events]
@events_api_router.post("/api/v1/events") @events_api_router.post("/api/v1/events")
@events_api_router.put("/api/v1/events/{event_id}") @events_api_router.put("/api/v1/events/{event_id}")
async def api_event_create( async def api_event_create(
@ -77,6 +90,26 @@ async def api_event_create(
return event.dict() return event.dict()
@events_api_router.put("/api/v1/events/{event_id}/cancel")
async def api_event_cancel(
event_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
event = await get_event(event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
if event.wallet != wallet.wallet.id:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.")
event.canceled = True
event = await update_event(event)
await refund_tickets(event.id)
return event.dict()
@events_api_router.delete("/api/v1/events/{event_id}") @events_api_router.delete("/api/v1/events/{event_id}")
async def api_form_delete( async def api_form_delete(
event_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) event_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
@ -112,15 +145,25 @@ async def api_tickets(
return await get_tickets(wallet_ids) return await get_tickets(wallet_ids)
@events_api_router.get("/api/v1/tickets/user/{user_id}")
async def api_tickets_by_user_id(user_id: str) -> list[Ticket]:
"""Get all tickets for a specific user by their user_id"""
return await get_tickets_by_user_id(user_id)
@events_api_router.post("/api/v1/tickets/{event_id}") @events_api_router.post("/api/v1/tickets/{event_id}")
async def api_ticket_create(event_id: str, data: CreateTicket): async def api_ticket_create(event_id: str, data: CreateTicket):
name = data.name if data.user_id:
email = data.email return await api_ticket_make_ticket_with_user_id(event_id, data.user_id)
return await api_ticket_make_ticket(event_id, name, email) else:
promo_code = data.promo_code.upper() if data.promo_code else None
refund_address = data.refund_address
return await api_ticket_make_ticket(
event_id, data.name, data.email, promo_code, refund_address
)
@events_api_router.get("/api/v1/tickets/{event_id}/{name}/{email}") async def api_ticket_make_ticket_with_user_id(event_id: str, user_id: str):
async def api_ticket_make_ticket(event_id, name, email):
event = await get_event(event_id) event = await get_event(event_id)
if not event: if not event:
raise HTTPException( raise HTTPException(
@ -128,7 +171,7 @@ async def api_ticket_make_ticket(event_id, name, email):
) )
price = event.price_per_ticket price = event.price_per_ticket
extra = {"tag": "events", "name": name, "email": email} extra = {"tag": "events", "user_id": user_id}
if event.currency != "sats": if event.currency != "sats":
price = await fiat_amount_as_satoshis(event.price_per_ticket, event.currency) price = await fiat_amount_as_satoshis(event.price_per_ticket, event.currency)
@ -138,6 +181,61 @@ async def api_ticket_make_ticket(event_id, name, email):
extra["fiatAmount"] = event.price_per_ticket extra["fiatAmount"] = event.price_per_ticket
extra["rate"] = await get_fiat_rate_satoshis(event.currency) extra["rate"] = await get_fiat_rate_satoshis(event.currency)
try:
payment = await create_invoice(
wallet_id=event.wallet,
amount=price,
memo=f"{event_id}",
extra=extra,
)
await create_ticket(
payment_hash=payment.payment_hash,
wallet=event.wallet,
event=event.id,
user_id=user_id,
)
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
) from exc
return {"payment_hash": payment.payment_hash, "payment_request": payment.bolt11}
@events_api_router.get("/api/v1/tickets/{event_id}/user/{user_id}")
async def api_ticket_make_ticket_user_id(event_id: str, user_id: str):
return await api_ticket_make_ticket_with_user_id(event_id, user_id)
@events_api_router.get("/api/v1/tickets/{event_id}/{name}/{email}")
async def api_ticket_make_ticket(event_id, name, email, promo_code, refund_address):
event = await get_event(event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
price = event.price_per_ticket
extra = {"tag": "events", "name": name, "email": email}
if promo_code:
# check if promo_code exists in event.extra.promo_codes
if promo_code not in [pc.code for pc in event.extra.promo_codes]:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code."
)
# 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)
if event.currency != "sats":
extra["fiat"] = True
extra["currency"] = event.currency
extra["fiatAmount"] = price
extra["rate"] = await get_fiat_rate_satoshis(event.currency)
price = await fiat_amount_as_satoshis(price, event.currency)
try: try:
payment = await create_invoice( payment = await create_invoice(
wallet_id=event.wallet, wallet_id=event.wallet,
@ -151,6 +249,11 @@ async def api_ticket_make_ticket(event_id, name, email):
event=event.id, event=event.id,
name=name, name=name,
email=email, email=email,
extra={
"applied_promo_code": promo_code,
"refund_address": refund_address,
"sats_paid": int(price),
},
) )
except Exception as exc: except Exception as exc:
raise HTTPException( raise HTTPException(
@ -176,16 +279,31 @@ async def api_ticket_send_ticket(event_id, payment_hash):
) )
payment = await get_standalone_payment(payment_hash, incoming=True) payment = await get_standalone_payment(payment_hash, incoming=True)
assert payment assert payment
if ticket.extra.applied_promo_code:
promo = next(
(
pc
for pc in event.extra.promo_codes
if pc.code == ticket.extra.applied_promo_code
),
None,
)
if promo:
event.price_per_ticket *= 1 - promo.discount_percent / 100
price = ( price = (
event.price_per_ticket * 1000 event.price_per_ticket * 1000
if event.currency == "sats" if event.currency == "sats"
else await fiat_amount_as_satoshis(event.price_per_ticket, event.currency) else await fiat_amount_as_satoshis(event.price_per_ticket, event.currency)
* 1000 * 1000
) )
# check if price is equal to payment.amount # check if price is equal to payment.amount
lower_bound = price * 0.99 # 1% decrease lower_bound = price * 0.99 # 1% decrease
if not payment.pending and abs(payment.amount) >= lower_bound: # allow 1% error if not payment.pending and abs(payment.amount) >= lower_bound: # allow 1% error
ticket.extra.sats_paid = int(payment.amount / 1000)
await set_ticket_paid(ticket) await set_ticket_paid(ticket)
return {"paid": True, "ticket_id": ticket.id} return {"paid": True, "ticket_id": ticket.id}
@ -208,17 +326,6 @@ async def api_ticket_delete(
await delete_ticket(ticket_id) await delete_ticket(ticket_id)
# TODO: DELETE, updates db! @tal
@events_api_router.get("/api/v1/purge/{event_id}")
async def api_event_purge_tickets(event_id: str):
event = await get_event(event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
return await purge_unpaid_tickets(event_id)
@events_api_router.get("/api/v1/eventtickets/{event_id}") @events_api_router.get("/api/v1/eventtickets/{event_id}")
async def api_event_tickets(event_id: str) -> list[Ticket]: async def api_event_tickets(event_id: str) -> list[Ticket]:
return await get_event_tickets(event_id) return await get_event_tickets(event_id)