feat: add event approval workflow with admin UI
Non-admin event submissions now land in a "proposed" queue that LNbits
admins review before the event becomes ticketable and publicly listed.
- m008 adds events.events.status (proposed/approved/rejected); m010 seeds
an events.settings singleton row with the auto_approve toggle.
- Models: Event/CreateEvent.status, EventsSettings, optional date fields
with sensible defaults (closing_date defaults to event_end_date which
defaults to event_start_date), PublicEvent.status surfaces the workflow
state on the public endpoint.
- crud: get_all/public/pending_events for the admin views; get/update_settings
for the auto_approve toggle; create_event auto-fills missing date defaults.
- views_api:
* POST /api/v1/events accepts wallet invoice keys so anyone can submit;
handler stamps status="proposed" for non-admins when auto_approve is off
* /public, /all, /pending, /settings (GET+PUT), /{id}/{approve,reject},
/{id}/tickets endpoints; literal-prefix routes declared before /{event_id}
so FastAPI matches them correctly
* Public GET /{event_id} bypasses sold-out / closing-window gates for
proposed/rejected events and returns the trimmed PublicEvent so the SFC
can render a "pending approval" banner
* POST /tickets/{event_id} rejects when event.status != "approved"
- Frontend: index.vue gains an admin Settings card, Pending Approvals list,
status badge column and approve/reject row actions, plus an All Users'
Events admin table; index.js gains the data + methods + an isAdmin probe
via GET /events/all; display.vue shows pending/rejected banners and
hides the Buy Ticket form unless status === "approved".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
dfabcb8f54
commit
c7e95c5452
6 changed files with 494 additions and 51 deletions
51
crud.py
51
crud.py
|
|
@ -4,7 +4,7 @@ from datetime import datetime, timedelta, timezone
|
||||||
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, TicketExtra
|
from .models import CreateEvent, Event, EventsSettings, Ticket, TicketExtra
|
||||||
|
|
||||||
db = Database("ext_events")
|
db = Database("ext_events")
|
||||||
|
|
||||||
|
|
@ -143,6 +143,11 @@ async def purge_unpaid_tickets(event_id: str) -> None:
|
||||||
|
|
||||||
async def create_event(data: CreateEvent) -> Event:
|
async def create_event(data: CreateEvent) -> Event:
|
||||||
event_id = urlsafe_short_hash()
|
event_id = urlsafe_short_hash()
|
||||||
|
# Default end_date to start_date and closing_date to end_date when omitted.
|
||||||
|
if not data.event_end_date:
|
||||||
|
data.event_end_date = data.event_start_date
|
||||||
|
if not data.closing_date:
|
||||||
|
data.closing_date = data.event_end_date
|
||||||
event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict())
|
event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict())
|
||||||
await db.insert("events.events", event)
|
await db.insert("events.events", event)
|
||||||
return event
|
return event
|
||||||
|
|
@ -171,6 +176,50 @@ async def get_events(wallet_ids: str | list[str]) -> list[Event]:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_events() -> list[Event]:
|
||||||
|
"""All events, no wallet filter. Admin-only callers."""
|
||||||
|
return await db.fetchall(
|
||||||
|
"SELECT * FROM events.events ORDER BY time DESC",
|
||||||
|
model=Event,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_public_events() -> list[Event]:
|
||||||
|
"""Approved, non-canceled events for the public listing."""
|
||||||
|
return await db.fetchall(
|
||||||
|
"""
|
||||||
|
SELECT * FROM events.events
|
||||||
|
WHERE status = 'approved' AND canceled = FALSE
|
||||||
|
ORDER BY event_start_date ASC
|
||||||
|
""",
|
||||||
|
model=Event,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_pending_events() -> list[Event]:
|
||||||
|
"""Proposed events awaiting admin approval."""
|
||||||
|
return await db.fetchall(
|
||||||
|
"SELECT * FROM events.events WHERE status = 'proposed' ORDER BY time DESC",
|
||||||
|
model=Event,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_settings() -> EventsSettings:
|
||||||
|
"""Singleton settings row, seeded by m010."""
|
||||||
|
row = await db.fetchone("SELECT * FROM events.settings WHERE id = 1")
|
||||||
|
if row:
|
||||||
|
return EventsSettings(**dict(row))
|
||||||
|
return EventsSettings()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_settings(settings: EventsSettings) -> EventsSettings:
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE events.settings SET auto_approve = :auto_approve WHERE id = 1",
|
||||||
|
{"auto_approve": settings.auto_approve},
|
||||||
|
)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
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})
|
||||||
|
|
||||||
|
|
|
||||||
42
models.py
42
models.py
|
|
@ -1,6 +1,5 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import Query
|
|
||||||
from pydantic import BaseModel, EmailStr, Field, root_validator, validator
|
from pydantic import BaseModel, EmailStr, Field, root_validator, validator
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -31,55 +30,64 @@ class EventExtra(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class CreateEvent(BaseModel):
|
class CreateEvent(BaseModel):
|
||||||
wallet: str
|
wallet: str | None = None # filled from caller's wallet if absent
|
||||||
name: str
|
name: str # title (required)
|
||||||
info: str
|
info: str = "" # description (optional)
|
||||||
closing_date: str
|
closing_date: str | None = None # defaults to event_end_date
|
||||||
event_start_date: str
|
event_start_date: str # required
|
||||||
event_end_date: str
|
event_end_date: str | None = None # defaults to event_start_date
|
||||||
currency: str = "sat"
|
currency: str = "sat"
|
||||||
allow_fiat: bool = False
|
allow_fiat: bool = False
|
||||||
fiat_currency: str = "GBP"
|
fiat_currency: str = "GBP"
|
||||||
amount_tickets: int = Query(..., ge=0)
|
amount_tickets: int = 0 # 0 = unlimited / not ticketed
|
||||||
price_per_ticket: float = Query(..., ge=0)
|
price_per_ticket: float = 0 # 0 = free
|
||||||
banner: str | None = None
|
banner: str | None = None
|
||||||
extra: EventExtra = Field(default_factory=EventExtra)
|
extra: EventExtra = Field(default_factory=EventExtra)
|
||||||
|
status: str = "approved" # proposed, approved, rejected
|
||||||
|
|
||||||
|
|
||||||
class Event(BaseModel):
|
class Event(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
wallet: str
|
wallet: str
|
||||||
name: str
|
name: str
|
||||||
info: str
|
info: str = ""
|
||||||
closing_date: str
|
closing_date: str | None = None
|
||||||
canceled: bool = False
|
canceled: bool = False
|
||||||
event_start_date: str
|
event_start_date: str
|
||||||
event_end_date: str
|
event_end_date: str | None = None
|
||||||
currency: str
|
currency: str = "sat"
|
||||||
allow_fiat: bool = False
|
allow_fiat: bool = False
|
||||||
fiat_currency: str = "GBP"
|
fiat_currency: str = "GBP"
|
||||||
amount_tickets: int
|
amount_tickets: int = 0
|
||||||
price_per_ticket: float
|
price_per_ticket: float = 0
|
||||||
time: datetime
|
time: datetime
|
||||||
sold: int = 0
|
sold: int = 0
|
||||||
banner: str | None = None
|
banner: str | None = None
|
||||||
extra: EventExtra = Field(default_factory=EventExtra)
|
extra: EventExtra = Field(default_factory=EventExtra)
|
||||||
|
status: str = "approved"
|
||||||
|
|
||||||
|
|
||||||
class PublicEvent(BaseModel):
|
class PublicEvent(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
info: str
|
info: str
|
||||||
closing_date: str
|
closing_date: str | None = None
|
||||||
canceled: bool
|
canceled: bool
|
||||||
event_start_date: str
|
event_start_date: str
|
||||||
event_end_date: str
|
event_end_date: str | None = None
|
||||||
currency: str
|
currency: str
|
||||||
allow_fiat: bool = False
|
allow_fiat: bool = False
|
||||||
fiat_currency: str = "GBP"
|
fiat_currency: str = "GBP"
|
||||||
price_per_ticket: float
|
price_per_ticket: float
|
||||||
banner: str | None
|
banner: str | None
|
||||||
extra: EventExtra = Field(default_factory=EventExtra)
|
extra: EventExtra = Field(default_factory=EventExtra)
|
||||||
|
status: str = "approved" # surfaces "proposed"/"rejected" so SFC can render banner
|
||||||
|
|
||||||
|
|
||||||
|
class EventsSettings(BaseModel):
|
||||||
|
"""Extension-level settings for the events extension."""
|
||||||
|
|
||||||
|
auto_approve: bool = False # Skip approval workflow for non-admin users
|
||||||
|
|
||||||
|
|
||||||
class TicketExtra(BaseModel):
|
class TicketExtra(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,32 @@
|
||||||
<div v-html="event.info" class="q-pa-lg"></div>
|
<div v-html="event.info" class="q-pa-lg"></div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
<q-card class="q-pa-lg">
|
|
||||||
|
<q-banner
|
||||||
|
v-if="event.status === 'proposed'"
|
||||||
|
class="bg-orange-2 text-orange-10"
|
||||||
|
rounded
|
||||||
|
>
|
||||||
|
<template v-slot:avatar>
|
||||||
|
<q-icon name="pending" color="orange-10"></q-icon>
|
||||||
|
</template>
|
||||||
|
<span class="text-weight-medium">Pending approval</span> — this
|
||||||
|
event is awaiting an admin review and is not yet open for tickets.
|
||||||
|
</q-banner>
|
||||||
|
|
||||||
|
<q-banner
|
||||||
|
v-else-if="event.status === 'rejected'"
|
||||||
|
class="bg-red-2 text-red-10"
|
||||||
|
rounded
|
||||||
|
>
|
||||||
|
<template v-slot:avatar>
|
||||||
|
<q-icon name="block" color="red-10"></q-icon>
|
||||||
|
</template>
|
||||||
|
<span class="text-weight-medium">Not approved</span> — this event
|
||||||
|
was reviewed and is not being published.
|
||||||
|
</q-banner>
|
||||||
|
|
||||||
|
<q-card v-if="event.status === 'approved'" class="q-pa-lg">
|
||||||
<q-card-section class="q-pa-none">
|
<q-card-section class="q-pa-none">
|
||||||
<h5 class="q-mt-none">Buy Ticket</h5>
|
<h5 class="q-mt-none">Buy Ticket</h5>
|
||||||
<q-form @submit="createInvoice()" class="q-gutter-md">
|
<q-form @submit="createInvoice()" class="q-gutter-md">
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@ window.PageEvents = {
|
||||||
tickets: [],
|
tickets: [],
|
||||||
resendingTicketEmails: [],
|
resendingTicketEmails: [],
|
||||||
currencies: [],
|
currencies: [],
|
||||||
|
pendingEvents: [],
|
||||||
|
allUserEvents: [],
|
||||||
|
isAdmin: false,
|
||||||
|
settings: {
|
||||||
|
auto_approve: false
|
||||||
|
},
|
||||||
eventsTable: {
|
eventsTable: {
|
||||||
columns: [
|
columns: [
|
||||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||||
|
|
@ -66,7 +72,8 @@ window.PageEvents = {
|
||||||
field: 'sold'
|
field: 'sold'
|
||||||
},
|
},
|
||||||
{name: 'info', align: 'left', label: 'Info', field: 'info'},
|
{name: 'info', align: 'left', label: 'Info', field: 'info'},
|
||||||
{name: 'banner', align: 'left', label: 'Banner', field: 'banner'}
|
{name: 'banner', align: 'left', label: 'Banner', field: 'banner'},
|
||||||
|
{name: 'status', align: 'left', label: 'Status', field: 'status'}
|
||||||
],
|
],
|
||||||
pagination: {
|
pagination: {
|
||||||
rowsPerPage: 10
|
rowsPerPage: 10
|
||||||
|
|
@ -189,6 +196,84 @@ window.PageEvents = {
|
||||||
this.events = response.data
|
this.events = response.data
|
||||||
this.checkCanceledEvents()
|
this.checkCanceledEvents()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Admin probe: a 200 from /all means we're an LNbits admin.
|
||||||
|
LNbits.api
|
||||||
|
.request('GET', '/events/api/v1/events/all')
|
||||||
|
.then(response => {
|
||||||
|
this.isAdmin = true
|
||||||
|
const ownWalletIds = this.g.user.wallets.map(w => w.id)
|
||||||
|
this.allUserEvents = response.data.filter(
|
||||||
|
e => !ownWalletIds.includes(e.wallet)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.isAdmin = false
|
||||||
|
this.allUserEvents = []
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getSettings() {
|
||||||
|
LNbits.api
|
||||||
|
.request('GET', '/events/api/v1/events/settings')
|
||||||
|
.then(response => {
|
||||||
|
this.settings = response.data
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Not admin or settings unavailable; keep defaults.
|
||||||
|
})
|
||||||
|
},
|
||||||
|
saveSettings() {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'PUT',
|
||||||
|
'/events/api/v1/events/settings',
|
||||||
|
null,
|
||||||
|
this.settings
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
Quasar.Notify.create({type: 'positive', message: 'Settings saved'})
|
||||||
|
})
|
||||||
|
.catch(LNbits.utils.notifyApiError)
|
||||||
|
},
|
||||||
|
getPendingEvents() {
|
||||||
|
LNbits.api
|
||||||
|
.request('GET', '/events/api/v1/events/pending')
|
||||||
|
.then(response => {
|
||||||
|
this.pendingEvents = response.data
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.pendingEvents = []
|
||||||
|
})
|
||||||
|
},
|
||||||
|
approveEvent(eventId) {
|
||||||
|
LNbits.utils.confirmDialog('Approve this event?').onOk(() => {
|
||||||
|
LNbits.api
|
||||||
|
.request('PUT', '/events/api/v1/events/' + eventId + '/approve')
|
||||||
|
.then(() => {
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Event approved'
|
||||||
|
})
|
||||||
|
this.getEvents()
|
||||||
|
this.getPendingEvents()
|
||||||
|
})
|
||||||
|
.catch(LNbits.utils.notifyApiError)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
rejectEvent(eventId) {
|
||||||
|
LNbits.utils.confirmDialog('Reject this event?').onOk(() => {
|
||||||
|
LNbits.api
|
||||||
|
.request('PUT', '/events/api/v1/events/' + eventId + '/reject')
|
||||||
|
.then(() => {
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Event rejected'
|
||||||
|
})
|
||||||
|
this.getEvents()
|
||||||
|
this.getPendingEvents()
|
||||||
|
})
|
||||||
|
.catch(LNbits.utils.notifyApiError)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
sendEventData() {
|
sendEventData() {
|
||||||
const wallet = _.findWhere(this.g.user.wallets, {
|
const wallet = _.findWhere(this.g.user.wallets, {
|
||||||
|
|
@ -333,6 +418,8 @@ window.PageEvents = {
|
||||||
if (this.g.user.wallets.length) {
|
if (this.g.user.wallets.length) {
|
||||||
this.getTickets()
|
this.getTickets()
|
||||||
this.getEvents()
|
this.getEvents()
|
||||||
|
this.getSettings()
|
||||||
|
this.getPendingEvents()
|
||||||
if (this.g.allowedCurrencies && this.g.allowedCurrencies.length > 0) {
|
if (this.g.allowedCurrencies && this.g.allowedCurrencies.length > 0) {
|
||||||
this.currencies = ['sats', ...this.g.allowedCurrencies]
|
this.currencies = ['sats', ...this.g.allowedCurrencies]
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,23 @@
|
||||||
<template id="page-events">
|
<template id="page-events">
|
||||||
<div class="row q-col-gutter-md">
|
<div class="row q-col-gutter-md">
|
||||||
<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 v-if="isAdmin">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center justify-between">
|
||||||
|
<div class="col">
|
||||||
|
<span class="text-subtitle1">Settings</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-toggle
|
||||||
|
v-model="settings.auto_approve"
|
||||||
|
label="Auto-approve events"
|
||||||
|
@update:model-value="saveSettings"
|
||||||
|
></q-toggle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<q-btn unelevated color="primary" @click="openEventDialog"
|
<q-btn unelevated color="primary" @click="openEventDialog"
|
||||||
|
|
@ -9,6 +26,63 @@
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
||||||
|
<q-card v-if="pendingEvents.length > 0">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col">
|
||||||
|
<h5 class="text-subtitle1 q-my-none">
|
||||||
|
<q-icon name="pending" color="orange" class="q-mr-sm"></q-icon>
|
||||||
|
Pending Approvals
|
||||||
|
<q-badge
|
||||||
|
color="orange"
|
||||||
|
:label="pendingEvents.length"
|
||||||
|
class="q-ml-sm"
|
||||||
|
></q-badge>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-list separator>
|
||||||
|
<q-item v-for="event in pendingEvents" :key="event.id">
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label v-text="event.name"></q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
<span v-text="event.event_start_date"></span>
|
||||||
|
—
|
||||||
|
<span v-text="event.info.substring(0, 80)"></span
|
||||||
|
><span v-if="event.info.length > 80">...</span>
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
<span v-text="event.amount_tickets"></span> tickets •
|
||||||
|
<span v-text="event.price_per_ticket"></span>
|
||||||
|
<span v-text="event.currency"></span>
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section side>
|
||||||
|
<div class="row q-gutter-sm">
|
||||||
|
<q-btn
|
||||||
|
dense
|
||||||
|
color="green"
|
||||||
|
icon="check_circle"
|
||||||
|
label="Approve"
|
||||||
|
size="sm"
|
||||||
|
@click="approveEvent(event.id)"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
dense
|
||||||
|
outline
|
||||||
|
color="red"
|
||||||
|
icon="block"
|
||||||
|
label="Reject"
|
||||||
|
size="sm"
|
||||||
|
@click="rejectEvent(event.id)"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="row items-center no-wrap q-mb-md">
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
|
@ -75,6 +149,28 @@
|
||||||
></q-btn>
|
></q-btn>
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td auto-width>
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
v-if="isAdmin && props.row.status === 'proposed'"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
@click="approveEvent(props.row.id)"
|
||||||
|
icon="check_circle"
|
||||||
|
color="green"
|
||||||
|
>
|
||||||
|
<q-tooltip>Approve</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
v-if="isAdmin && props.row.status === 'proposed'"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
@click="rejectEvent(props.row.id)"
|
||||||
|
icon="block"
|
||||||
|
color="red"
|
||||||
|
>
|
||||||
|
<q-tooltip>Reject</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
flat
|
flat
|
||||||
dense
|
dense
|
||||||
|
|
@ -94,7 +190,12 @@
|
||||||
></q-btn>
|
></q-btn>
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
<span v-text="col.value"></span>
|
<q-badge
|
||||||
|
v-if="col.name === 'status'"
|
||||||
|
:color="col.value === 'approved' ? 'green' : col.value === 'proposed' ? 'orange' : 'red'"
|
||||||
|
:label="col.value"
|
||||||
|
></q-badge>
|
||||||
|
<span v-else v-text="col.value"></span>
|
||||||
</q-td>
|
</q-td>
|
||||||
</q-tr>
|
</q-tr>
|
||||||
<q-tr v-show="props.expand" :props="props">
|
<q-tr v-show="props.expand" :props="props">
|
||||||
|
|
@ -149,6 +250,51 @@
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
||||||
|
<q-card v-if="isAdmin && allUserEvents.length > 0">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col">
|
||||||
|
<h5 class="text-subtitle1 q-my-none">
|
||||||
|
All Users' Events
|
||||||
|
<q-badge
|
||||||
|
color="blue"
|
||||||
|
:label="allUserEvents.length"
|
||||||
|
class="q-ml-sm"
|
||||||
|
></q-badge>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:rows="allUserEvents"
|
||||||
|
row-key="id"
|
||||||
|
:columns="eventsTable.columns"
|
||||||
|
:pagination="{rowsPerPage: 10}"
|
||||||
|
>
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
<span v-text="col.label"></span>
|
||||||
|
</q-th>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
<q-badge
|
||||||
|
v-if="col.name === 'status'"
|
||||||
|
:color="col.value === 'approved' ? 'green' : col.value === 'proposed' ? 'orange' : 'red'"
|
||||||
|
:label="col.value"
|
||||||
|
></q-badge>
|
||||||
|
<span v-else v-text="col.value"></span>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="row items-center no-wrap q-mb-md">
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
|
|
||||||
188
views_api.py
188
views_api.py
|
|
@ -14,10 +14,11 @@ from fastapi import (
|
||||||
)
|
)
|
||||||
from lnbits.core.crud import get_user
|
from lnbits.core.crud import get_user
|
||||||
from lnbits.core.crud.wallets import get_wallet
|
from lnbits.core.crud.wallets import get_wallet
|
||||||
from lnbits.core.models import WalletTypeInfo
|
from lnbits.core.models import Account, WalletTypeInfo
|
||||||
from lnbits.core.models.payments import CreateInvoice
|
from lnbits.core.models.payments import CreateInvoice
|
||||||
from lnbits.core.services import create_payment_request
|
from lnbits.core.services import create_payment_request
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
|
check_admin,
|
||||||
require_admin_key,
|
require_admin_key,
|
||||||
require_invoice_key,
|
require_invoice_key,
|
||||||
)
|
)
|
||||||
|
|
@ -35,18 +36,25 @@ from .crud import (
|
||||||
delete_event,
|
delete_event,
|
||||||
delete_event_tickets,
|
delete_event_tickets,
|
||||||
delete_ticket,
|
delete_ticket,
|
||||||
|
get_all_events,
|
||||||
get_event,
|
get_event,
|
||||||
|
get_event_tickets,
|
||||||
get_events,
|
get_events,
|
||||||
|
get_pending_events,
|
||||||
|
get_public_events,
|
||||||
|
get_settings,
|
||||||
get_ticket,
|
get_ticket,
|
||||||
get_tickets,
|
get_tickets,
|
||||||
purge_unpaid_tickets,
|
purge_unpaid_tickets,
|
||||||
update_event,
|
update_event,
|
||||||
|
update_settings,
|
||||||
update_ticket,
|
update_ticket,
|
||||||
)
|
)
|
||||||
from .models import (
|
from .models import (
|
||||||
CreateEvent,
|
CreateEvent,
|
||||||
CreateTicket,
|
CreateTicket,
|
||||||
Event,
|
Event,
|
||||||
|
EventsSettings,
|
||||||
PublicEvent,
|
PublicEvent,
|
||||||
PublicTicket,
|
PublicTicket,
|
||||||
Ticket,
|
Ticket,
|
||||||
|
|
@ -63,31 +71,87 @@ def _is_fiat_currency(currency: str | None) -> bool:
|
||||||
return str(currency or "").lower() not in {"sat", "sats"}
|
return str(currency or "").lower() not in {"sat", "sats"}
|
||||||
|
|
||||||
|
|
||||||
|
# Literal-prefix routes (/public, /all, /pending, /settings) MUST be declared
|
||||||
|
# before any "/{event_id}" route or FastAPI matches them as a path parameter.
|
||||||
|
|
||||||
|
|
||||||
@events_api_router.get("")
|
@events_api_router.get("")
|
||||||
async def api_events(
|
async def api_events(
|
||||||
all_wallets: bool = Query(False),
|
all_wallets: bool = Query(False),
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
) -> list[Event]:
|
) -> list[Event]:
|
||||||
wallet_ids = [wallet.wallet.id]
|
wallet_ids = [wallet.wallet.id]
|
||||||
|
|
||||||
if all_wallets:
|
if all_wallets:
|
||||||
user = await get_user(wallet.wallet.user)
|
user = await get_user(wallet.wallet.user)
|
||||||
wallet_ids = user.wallet_ids if user else []
|
wallet_ids = user.wallet_ids if user else []
|
||||||
|
|
||||||
return await get_events(wallet_ids)
|
return await get_events(wallet_ids)
|
||||||
|
|
||||||
|
|
||||||
|
@events_api_router.get("/public")
|
||||||
|
async def api_events_public() -> list[Event]:
|
||||||
|
"""Approved, non-canceled events for an anonymous public listing."""
|
||||||
|
return await get_public_events()
|
||||||
|
|
||||||
|
|
||||||
|
@events_api_router.get("/all")
|
||||||
|
async def api_events_all(
|
||||||
|
admin: Account = Depends(check_admin),
|
||||||
|
) -> list[Event]:
|
||||||
|
"""All events across all wallets. LNbits admin only."""
|
||||||
|
return await get_all_events()
|
||||||
|
|
||||||
|
|
||||||
|
@events_api_router.get("/pending")
|
||||||
|
async def api_events_pending(
|
||||||
|
admin: Account = Depends(check_admin),
|
||||||
|
) -> list[Event]:
|
||||||
|
"""Proposed events awaiting admin approval. LNbits admin only."""
|
||||||
|
return await get_pending_events()
|
||||||
|
|
||||||
|
|
||||||
|
@events_api_router.get("/settings")
|
||||||
|
async def api_get_settings(
|
||||||
|
admin: Account = Depends(check_admin),
|
||||||
|
) -> EventsSettings:
|
||||||
|
return await get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@events_api_router.put("/settings")
|
||||||
|
async def api_update_settings(
|
||||||
|
data: EventsSettings,
|
||||||
|
admin: Account = Depends(check_admin),
|
||||||
|
) -> EventsSettings:
|
||||||
|
return await update_settings(data)
|
||||||
|
|
||||||
|
|
||||||
@events_api_router.get("/{event_id}", response_model=PublicEvent)
|
@events_api_router.get("/{event_id}", response_model=PublicEvent)
|
||||||
async def api_get_event(event_id: str) -> Event:
|
async def api_get_event(event_id: str) -> Event:
|
||||||
|
"""Public event detail used by display.vue.
|
||||||
|
|
||||||
|
For approved events we run the upstream sold-out / closing-window /
|
||||||
|
conditional gates. For non-approved events (proposed / rejected) we
|
||||||
|
return the trimmed PublicEvent with status set so the SFC can render
|
||||||
|
the pending-approval banner without a separate request.
|
||||||
|
"""
|
||||||
event = await get_event(event_id)
|
event = await get_event(event_id)
|
||||||
if not event:
|
if not event:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if event.status != "approved":
|
||||||
|
# Proposed/rejected events are not yet ticketable; skip ticket gates.
|
||||||
|
return event
|
||||||
|
|
||||||
await purge_unpaid_tickets(event_id)
|
await purge_unpaid_tickets(event_id)
|
||||||
|
|
||||||
|
# closing_date is filled in by create_event (defaults to end_date or
|
||||||
|
# start_date) but the field is typed Optional, so guard for the typechecker.
|
||||||
|
closing_date = (
|
||||||
|
event.closing_date or event.event_end_date or event.event_start_date
|
||||||
|
)
|
||||||
is_window_open = datetime.now(timezone.utc) < datetime.strptime(
|
is_window_open = datetime.now(timezone.utc) < datetime.strptime(
|
||||||
event.closing_date, "%Y-%m-%d"
|
closing_date, "%Y-%m-%d"
|
||||||
).replace(tzinfo=timezone.utc)
|
).replace(tzinfo=timezone.utc)
|
||||||
is_min_tickets_met = (
|
is_min_tickets_met = (
|
||||||
event.sold >= event.extra.min_tickets if event.extra.conditional else True
|
event.sold >= event.extra.min_tickets if event.extra.conditional else True
|
||||||
|
|
@ -98,7 +162,6 @@ async def api_get_event(event_id: str) -> Event:
|
||||||
event.canceled = True
|
event.canceled = True
|
||||||
await update_event(event)
|
await update_event(event)
|
||||||
await refund_tickets(event_id)
|
await refund_tickets(event_id)
|
||||||
|
|
||||||
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.")
|
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.")
|
||||||
|
|
||||||
if not is_window_open:
|
if not is_window_open:
|
||||||
|
|
@ -110,30 +173,50 @@ async def api_get_event(event_id: str) -> Event:
|
||||||
|
|
||||||
|
|
||||||
@events_api_router.post("")
|
@events_api_router.post("")
|
||||||
@events_api_router.put("/{event_id}")
|
|
||||||
async def api_event_create(
|
async def api_event_create(
|
||||||
data: CreateEvent,
|
data: CreateEvent,
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
event_id: str | None = None,
|
|
||||||
) -> Event:
|
) -> Event:
|
||||||
if event_id:
|
"""Create a new event.
|
||||||
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:
|
Anyone with a wallet invoice key can submit. Non-LNbits-admins land in
|
||||||
raise HTTPException(
|
`proposed` status unless `auto_approve` is enabled in extension settings.
|
||||||
status_code=HTTPStatus.FORBIDDEN, detail="Not your event."
|
"""
|
||||||
)
|
if not data.wallet:
|
||||||
for k, v in data.dict().items():
|
data.wallet = wallet.wallet.id
|
||||||
setattr(event, k, v)
|
|
||||||
event = await update_event(event)
|
|
||||||
else:
|
|
||||||
event = await create_event(data)
|
|
||||||
|
|
||||||
return event
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
ext_settings = await get_settings()
|
||||||
|
user_id = wallet.wallet.user
|
||||||
|
is_admin = (
|
||||||
|
user_id == settings.super_user
|
||||||
|
or user_id in settings.lnbits_admin_users
|
||||||
|
)
|
||||||
|
if not is_admin and not ext_settings.auto_approve:
|
||||||
|
data.status = "proposed"
|
||||||
|
|
||||||
|
return await create_event(data)
|
||||||
|
|
||||||
|
|
||||||
|
@events_api_router.put("/{event_id}")
|
||||||
|
async def api_event_update(
|
||||||
|
event_id: str,
|
||||||
|
data: CreateEvent,
|
||||||
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
|
) -> Event:
|
||||||
|
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."
|
||||||
|
)
|
||||||
|
for k, v in data.dict().items():
|
||||||
|
setattr(event, k, v)
|
||||||
|
return await update_event(event)
|
||||||
|
|
||||||
|
|
||||||
@events_api_router.put("/{event_id}/cancel")
|
@events_api_router.put("/{event_id}/cancel")
|
||||||
|
|
@ -146,13 +229,11 @@ async def api_event_cancel(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
||||||
)
|
)
|
||||||
|
|
||||||
if event.wallet != wallet.wallet.id:
|
if event.wallet != wallet.wallet.id:
|
||||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.")
|
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.")
|
||||||
event.canceled = True
|
event.canceled = True
|
||||||
event = await update_event(event)
|
event = await update_event(event)
|
||||||
await refund_tickets(event.id)
|
await refund_tickets(event.id)
|
||||||
|
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -165,14 +246,58 @@ async def api_form_delete(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
||||||
)
|
)
|
||||||
|
|
||||||
if event.wallet != wallet.wallet.id:
|
if event.wallet != wallet.wallet.id:
|
||||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.")
|
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.")
|
||||||
|
|
||||||
await delete_event(event_id)
|
await delete_event(event_id)
|
||||||
await delete_event_tickets(event_id)
|
await delete_event_tickets(event_id)
|
||||||
|
|
||||||
|
|
||||||
|
@events_api_router.put("/{event_id}/approve")
|
||||||
|
async def api_event_approve(
|
||||||
|
event_id: str,
|
||||||
|
admin: Account = Depends(check_admin),
|
||||||
|
) -> Event:
|
||||||
|
event = await get_event(event_id)
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
||||||
|
)
|
||||||
|
if event.status != "proposed":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail=f"Event is already {event.status}.",
|
||||||
|
)
|
||||||
|
event.status = "approved"
|
||||||
|
return await update_event(event)
|
||||||
|
|
||||||
|
|
||||||
|
@events_api_router.put("/{event_id}/reject")
|
||||||
|
async def api_event_reject(
|
||||||
|
event_id: str,
|
||||||
|
admin: Account = Depends(check_admin),
|
||||||
|
) -> Event:
|
||||||
|
event = await get_event(event_id)
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
||||||
|
)
|
||||||
|
if event.status != "proposed":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail=f"Event is already {event.status}.",
|
||||||
|
)
|
||||||
|
event.status = "rejected"
|
||||||
|
return await update_event(event)
|
||||||
|
|
||||||
|
|
||||||
|
@events_api_router.get(
|
||||||
|
"/{event_id}/tickets",
|
||||||
|
response_model=list[PublicTicket],
|
||||||
|
)
|
||||||
|
async def api_event_tickets(event_id: str) -> list[Ticket]:
|
||||||
|
return await get_event_tickets(event_id)
|
||||||
|
|
||||||
|
|
||||||
@tickets_api_router.get("")
|
@tickets_api_router.get("")
|
||||||
async def api_tickets(
|
async def api_tickets(
|
||||||
all_wallets: bool = Query(False),
|
all_wallets: bool = Query(False),
|
||||||
|
|
@ -211,10 +336,13 @@ async def api_ticket_create(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
||||||
)
|
)
|
||||||
|
if event.status != "approved":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.GONE,
|
||||||
|
detail="Event is not yet open for tickets.",
|
||||||
|
)
|
||||||
if event.canceled:
|
if event.canceled:
|
||||||
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is 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 > 0 and event.sold >= event.amount_tickets:
|
||||||
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
|
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue