From 4c8e06a6a9297e553b6c25d86170e56586439523 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 5 May 2026 18:47:49 +0200 Subject: [PATCH] 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) --- crud.py | 51 +++++++++++- migrations.py | 32 +++++++ models.py | 42 ++++++---- static/js/display.vue | 27 +++++- static/js/index.js | 89 +++++++++++++++++++- static/js/index.vue | 148 ++++++++++++++++++++++++++++++++- views_api.py | 188 +++++++++++++++++++++++++++++++++++------- 7 files changed, 526 insertions(+), 51 deletions(-) diff --git a/crud.py b/crud.py index 450c091..004fa7f 100644 --- a/crud.py +++ b/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}) diff --git a/migrations.py b/migrations.py index 3664d69..0c50f19 100644 --- a/migrations.py +++ b/migrations.py @@ -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)" + ) diff --git a/models.py b/models.py index 415a2e7..d6ae9c4 100644 --- a/models.py +++ b/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): diff --git a/static/js/display.vue b/static/js/display.vue index 3f80180..58fec04 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -12,7 +12,32 @@
- + + + + Pending approval — this + event is awaiting an admin review and is not yet open for tickets. + + + + + Not approved — this event + was reviewed and is not being published. + + +
Buy Ticket
diff --git a/static/js/index.js b/static/js/index.js index ca34383..b4e8afa 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -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 { diff --git a/static/js/index.vue b/static/js/index.vue index 174f0c1..4a6c142 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -1,6 +1,23 @@