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
11043ec8a7
commit
4c8e06a6a9
7 changed files with 526 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.helpers import urlsafe_short_hash
|
||||
|
||||
from .models import CreateEvent, Event, Ticket, TicketExtra
|
||||
from .models import CreateEvent, Event, EventsSettings, Ticket, TicketExtra
|
||||
|
||||
db = Database("ext_events")
|
||||
|
||||
|
|
@ -143,6 +143,11 @@ async def purge_unpaid_tickets(event_id: str) -> None:
|
|||
|
||||
async def create_event(data: CreateEvent) -> Event:
|
||||
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())
|
||||
await db.insert("events.events", 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:
|
||||
await db.execute("DELETE FROM events.events WHERE id = :id", {"id": event_id})
|
||||
|
||||
|
|
|
|||
|
|
@ -200,3 +200,35 @@ async def m007_add_user_id_support(db):
|
|||
await _alter_add_column_safe(
|
||||
db, "ALTER TABLE events.ticket ADD COLUMN user_id TEXT"
|
||||
)
|
||||
|
||||
|
||||
async def m008_add_event_status(db):
|
||||
"""
|
||||
Add status column to events table for the proposal/approval workflow.
|
||||
Values: 'proposed', 'approved', 'rejected'. Existing rows default to
|
||||
'approved' so they stay visible after upgrade.
|
||||
"""
|
||||
await _alter_add_column_safe(
|
||||
db,
|
||||
"ALTER TABLE events.events ADD COLUMN status TEXT NOT NULL DEFAULT 'approved'",
|
||||
)
|
||||
|
||||
|
||||
async def m010_add_events_settings(db):
|
||||
"""
|
||||
Create the extension settings singleton row used by the admin UI to
|
||||
toggle e.g. auto_approve.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS events.settings (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
auto_approve BOOLEAN NOT NULL DEFAULT FALSE
|
||||
)
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
"INSERT INTO events.settings (id, auto_approve) "
|
||||
"SELECT 1, FALSE WHERE NOT EXISTS "
|
||||
"(SELECT 1 FROM events.settings WHERE id = 1)"
|
||||
)
|
||||
|
|
|
|||
42
models.py
42
models.py
|
|
@ -1,6 +1,5 @@
|
|||
from datetime import datetime
|
||||
|
||||
from fastapi import Query
|
||||
from pydantic import BaseModel, EmailStr, Field, root_validator, validator
|
||||
|
||||
|
||||
|
|
@ -27,46 +26,55 @@ class EventExtra(BaseModel):
|
|||
|
||||
|
||||
class CreateEvent(BaseModel):
|
||||
wallet: str
|
||||
name: str
|
||||
info: str
|
||||
closing_date: str
|
||||
event_start_date: str
|
||||
event_end_date: str
|
||||
wallet: str | None = None # filled from caller's wallet if absent
|
||||
name: str # title (required)
|
||||
info: str = "" # description (optional)
|
||||
closing_date: str | None = None # defaults to event_end_date
|
||||
event_start_date: str # required
|
||||
event_end_date: str | None = None # defaults to event_start_date
|
||||
currency: str = "sat"
|
||||
amount_tickets: int = Query(..., ge=0)
|
||||
price_per_ticket: float = Query(..., ge=0)
|
||||
amount_tickets: int = 0 # 0 = unlimited / not ticketed
|
||||
price_per_ticket: float = 0 # 0 = free
|
||||
banner: str | None = None
|
||||
extra: EventExtra = Field(default_factory=EventExtra)
|
||||
status: str = "approved" # proposed, approved, rejected
|
||||
|
||||
|
||||
class Event(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
name: str
|
||||
info: str
|
||||
closing_date: str
|
||||
info: str = ""
|
||||
closing_date: str | None = None
|
||||
canceled: bool = False
|
||||
event_start_date: str
|
||||
event_end_date: str
|
||||
currency: str
|
||||
amount_tickets: int
|
||||
price_per_ticket: float
|
||||
event_end_date: str | None = None
|
||||
currency: str = "sat"
|
||||
amount_tickets: int = 0
|
||||
price_per_ticket: float = 0
|
||||
time: datetime
|
||||
sold: int = 0
|
||||
banner: str | None = None
|
||||
extra: EventExtra = Field(default_factory=EventExtra)
|
||||
status: str = "approved"
|
||||
|
||||
|
||||
class PublicEvent(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
info: str
|
||||
closing_date: str
|
||||
closing_date: str | None = None
|
||||
canceled: bool
|
||||
event_start_date: str
|
||||
event_end_date: str
|
||||
event_end_date: str | None = None
|
||||
banner: str | None
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -12,7 +12,32 @@
|
|||
<div v-html="event.info" class="q-pa-lg"></div>
|
||||
</q-card-section>
|
||||
</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">
|
||||
<h5 class="q-mt-none">Buy Ticket</h5>
|
||||
<q-form @submit="createInvoice()" class="q-gutter-md">
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ window.PageEvents = {
|
|||
events: [],
|
||||
tickets: [],
|
||||
currencies: [],
|
||||
pendingEvents: [],
|
||||
allUserEvents: [],
|
||||
isAdmin: false,
|
||||
settings: {
|
||||
auto_approve: false
|
||||
},
|
||||
eventsTable: {
|
||||
columns: [
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||
|
|
@ -65,7 +71,8 @@ window.PageEvents = {
|
|||
field: 'sold'
|
||||
},
|
||||
{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: {
|
||||
rowsPerPage: 10
|
||||
|
|
@ -152,6 +159,84 @@ window.PageEvents = {
|
|||
this.events = response.data
|
||||
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() {
|
||||
const wallet = _.findWhere(this.g.user.wallets, {
|
||||
|
|
@ -275,6 +360,8 @@ window.PageEvents = {
|
|||
if (this.g.user.wallets.length) {
|
||||
this.getTickets()
|
||||
this.getEvents()
|
||||
this.getSettings()
|
||||
this.getPendingEvents()
|
||||
if (this.g.allowedCurrencies && this.g.allowedCurrencies.length > 0) {
|
||||
this.currencies = ['sats', ...this.g.allowedCurrencies]
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,23 @@
|
|||
<template id="page-events">
|
||||
<div class="row q-col-gutter-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-section>
|
||||
<q-btn unelevated color="primary" @click="openEventDialog"
|
||||
|
|
@ -9,6 +26,63 @@
|
|||
</q-card-section>
|
||||
</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-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
|
|
@ -75,6 +149,28 @@
|
|||
></q-btn>
|
||||
</q-td>
|
||||
<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
|
||||
flat
|
||||
dense
|
||||
|
|
@ -94,7 +190,12 @@
|
|||
></q-btn>
|
||||
</q-td>
|
||||
<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-tr>
|
||||
<q-tr v-show="props.expand" :props="props">
|
||||
|
|
@ -149,6 +250,51 @@
|
|||
</q-card-section>
|
||||
</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-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
|
|
|
|||
170
views_api.py
170
views_api.py
|
|
@ -12,9 +12,10 @@ from fastapi import (
|
|||
WebSocketDisconnect,
|
||||
)
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.core.models import WalletTypeInfo
|
||||
from lnbits.core.models import Account, WalletTypeInfo
|
||||
from lnbits.core.services import create_invoice
|
||||
from lnbits.decorators import (
|
||||
check_admin,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
|
|
@ -29,19 +30,26 @@ from .crud import (
|
|||
delete_event,
|
||||
delete_event_tickets,
|
||||
delete_ticket,
|
||||
get_all_events,
|
||||
get_event,
|
||||
get_event_tickets,
|
||||
get_events,
|
||||
get_pending_events,
|
||||
get_public_events,
|
||||
get_settings,
|
||||
get_ticket,
|
||||
get_tickets,
|
||||
get_tickets_by_user_id,
|
||||
purge_unpaid_tickets,
|
||||
update_event,
|
||||
update_settings,
|
||||
update_ticket,
|
||||
)
|
||||
from .models import (
|
||||
CreateEvent,
|
||||
CreateTicket,
|
||||
Event,
|
||||
EventsSettings,
|
||||
PublicEvent,
|
||||
PublicTicket,
|
||||
Ticket,
|
||||
|
|
@ -54,31 +62,87 @@ events_api_router = APIRouter(prefix="/api/v1/events")
|
|||
tickets_api_router = APIRouter(prefix="/api/v1/tickets")
|
||||
|
||||
|
||||
# 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("")
|
||||
async def api_events(
|
||||
all_wallets: bool = Query(False),
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
) -> list[Event]:
|
||||
wallet_ids = [wallet.wallet.id]
|
||||
|
||||
if all_wallets:
|
||||
user = await get_user(wallet.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
|
||||
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)
|
||||
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)
|
||||
if not event:
|
||||
raise HTTPException(
|
||||
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)
|
||||
|
||||
# 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(
|
||||
event.closing_date, "%Y-%m-%d"
|
||||
closing_date, "%Y-%m-%d"
|
||||
).replace(tzinfo=timezone.utc)
|
||||
is_min_tickets_met = (
|
||||
event.sold >= event.extra.min_tickets if event.extra.conditional else True
|
||||
|
|
@ -89,7 +153,6 @@ async def api_get_event(event_id: str) -> Event:
|
|||
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:
|
||||
|
|
@ -101,30 +164,50 @@ async def api_get_event(event_id: str) -> Event:
|
|||
|
||||
|
||||
@events_api_router.post("")
|
||||
@events_api_router.put("/{event_id}")
|
||||
async def api_event_create(
|
||||
data: CreateEvent,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
event_id: str | None = None,
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
) -> Event:
|
||||
"""Create a new event.
|
||||
|
||||
Anyone with a wallet invoice key can submit. Non-LNbits-admins land in
|
||||
`proposed` status unless `auto_approve` is enabled in extension settings.
|
||||
"""
|
||||
if not data.wallet:
|
||||
data.wallet = wallet.wallet.id
|
||||
|
||||
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:
|
||||
if event_id:
|
||||
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)
|
||||
event = await update_event(event)
|
||||
else:
|
||||
event = await create_event(data)
|
||||
|
||||
return event
|
||||
return await update_event(event)
|
||||
|
||||
|
||||
@events_api_router.put("/{event_id}/cancel")
|
||||
|
|
@ -137,13 +220,11 @@ async def api_event_cancel(
|
|||
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
|
||||
|
||||
|
||||
|
|
@ -156,14 +237,58 @@ async def api_form_delete(
|
|||
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.")
|
||||
|
||||
await delete_event(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("")
|
||||
async def api_tickets(
|
||||
all_wallets: bool = Query(False),
|
||||
|
|
@ -212,10 +337,13 @@ async def api_ticket_create(
|
|||
raise HTTPException(
|
||||
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:
|
||||
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.")
|
||||
|
||||
if event.amount_tickets > 0 and event.sold >= event.amount_tickets:
|
||||
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue