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
188
views_api.py
188
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:
|
||||
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.")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue