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:
Padreug 2026-05-05 18:47:49 +02:00
commit 4c8e06a6a9
7 changed files with 526 additions and 51 deletions

View file

@ -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:
if event_id:
event = await get_event(event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
"""Create a new event.
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)
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
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")
@ -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.")