Compare commits

...

5 commits

Author SHA1 Message Date
eb474b1390 fix: make promo_code and refund_address optional query params
Some checks failed
lint.yml / fix: make promo_code and refund_address optional query params (pull_request) Failing after 0s
These were required query params on the GET ticket endpoint,
causing 400 errors when not provided.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:04:46 +02:00
a41348df94 feat: add event proposal and approval API endpoints
- POST /api/v1/events/propose — submit event for approval (invoice key)
- GET /api/v1/events/pending — list proposed events (admin key)
- PUT /api/v1/events/{id}/approve — approve proposed event (admin key)
- PUT /api/v1/events/{id}/reject — reject proposed event (admin key)
- GET /api/v1/events/public — now returns only approved, non-canceled events

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:04:25 +02:00
0c782e6239 feat: add CRUD functions for public and pending event queries
- get_public_events(): returns approved, non-canceled events
- get_pending_events(): returns proposed events for admin review

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:02:18 +02:00
1dcff37df5 feat: add status field to Event model for approval workflow
Add 'status' column (proposed/approved/rejected) to the events
table with default 'approved' for backward compatibility. Existing
events are unaffected.

Migration m008 adds the column.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:01:50 +02:00
68e6e3d02e fix: Parse JSON extra field when reading tickets from database
Some checks failed
lint / lint (push) Has been cancelled
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
The previous fix used db.insert() which serializes the extra field to JSON.
However, the read functions (get_ticket, get_tickets, etc.) use fetchone/fetchall
without a model parameter, so the extra field comes back as a JSON string.

Added _parse_ticket_row() helper that:
- Converts empty strings to None for name/email (existing logic)
- Parses extra field from JSON string if needed (new)

The isinstance(extra, str) check ensures compatibility with both:
- SQLite: returns JSON as string
- PostgreSQL/CockroachDB: may return native JSONB as dict

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:03:34 +01:00
4 changed files with 137 additions and 47 deletions

90
crud.py
View file

@ -1,3 +1,4 @@
import json
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
@ -6,6 +7,30 @@ from lnbits.helpers import urlsafe_short_hash
from .models import CreateEvent, Event, Ticket, TicketExtra from .models import CreateEvent, Event, Ticket, TicketExtra
def _parse_ticket_row(row) -> dict:
"""
Parse a database row into a dict suitable for Ticket model creation.
Handles:
- Empty string to None conversion for name/email
- JSON string to dict conversion for extra field
"""
ticket_data = dict(row)
# Convert empty strings back to None for the model
if ticket_data.get("name") == "":
ticket_data["name"] = None
if ticket_data.get("email") == "":
ticket_data["email"] = None
# Parse extra field from JSON string if needed
# (db.insert() serializes to JSON, but manual fetchone/fetchall returns string)
extra = ticket_data.get("extra")
if isinstance(extra, str):
ticket_data["extra"] = json.loads(extra)
return ticket_data
db = Database("ext_events") db = Database("ext_events")
@ -94,14 +119,7 @@ async def get_ticket(payment_hash: str) -> Optional[Ticket]:
if not row: if not row:
return None return None
# Convert empty strings back to None for the model return Ticket(**_parse_ticket_row(row))
ticket_data = dict(row)
if ticket_data.get("name") == "":
ticket_data["name"] = None
if ticket_data.get("email") == "":
ticket_data["email"] = None
return Ticket(**ticket_data)
async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]: async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]:
@ -110,17 +128,7 @@ async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]:
q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids]) q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids])
rows = await db.fetchall(f"SELECT * FROM events.ticket WHERE wallet IN ({q})") rows = await db.fetchall(f"SELECT * FROM events.ticket WHERE wallet IN ({q})")
tickets = [] return [Ticket(**_parse_ticket_row(row)) for row in rows]
for row in rows:
# Convert empty strings back to None for the model
ticket_data = dict(row)
if ticket_data.get("name") == "":
ticket_data["name"] = None
if ticket_data.get("email") == "":
ticket_data["email"] = None
tickets.append(Ticket(**ticket_data))
return tickets
async def get_tickets_by_user_id(user_id: str) -> list[Ticket]: async def get_tickets_by_user_id(user_id: str) -> list[Ticket]:
@ -130,17 +138,7 @@ async def get_tickets_by_user_id(user_id: str) -> list[Ticket]:
{"user_id": user_id} {"user_id": user_id}
) )
tickets = [] return [Ticket(**_parse_ticket_row(row)) for row in rows]
for row in rows:
# Convert empty strings back to None for the model
ticket_data = dict(row)
if ticket_data.get("name") == "":
ticket_data["name"] = None
if ticket_data.get("email") == "":
ticket_data["email"] = None
tickets.append(Ticket(**ticket_data))
return tickets
async def delete_ticket(payment_hash: str) -> None: async def delete_ticket(payment_hash: str) -> None:
@ -202,6 +200,26 @@ async def get_all_events() -> list[Event]:
) )
async def get_public_events() -> list[Event]:
"""Get approved, non-canceled events for public display."""
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]:
"""Get proposed events awaiting admin approval."""
return await db.fetchall(
"SELECT * FROM events.events WHERE status = 'proposed' ORDER BY time DESC",
model=Event,
)
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})
@ -212,14 +230,4 @@ async def get_event_tickets(event_id: str) -> list[Ticket]:
{"event": event_id}, {"event": event_id},
) )
tickets = [] return [Ticket(**_parse_ticket_row(row)) for row in rows]
for row in rows:
# Convert empty strings back to None for the model
ticket_data = dict(row)
if ticket_data.get("name") == "":
ticket_data["name"] = None
if ticket_data.get("email") == "":
ticket_data["email"] = None
tickets.append(Ticket(**ticket_data))
return tickets

View file

@ -191,3 +191,14 @@ async def m007_add_extra_fields(db):
# Add 'extra' column to ticket table # Add 'extra' column to ticket table
await db.execute("ALTER TABLE events.ticket ADD COLUMN extra TEXT;") await db.execute("ALTER TABLE events.ticket ADD COLUMN extra TEXT;")
async def m008_add_event_status(db):
"""
Add status column to events table for proposal/approval workflow.
Values: 'proposed', 'approved', 'rejected'.
Default 'approved' for backward compatibility with existing events.
"""
await db.execute(
"ALTER TABLE events.events ADD COLUMN status TEXT NOT NULL DEFAULT 'approved';"
)

View file

@ -39,6 +39,7 @@ class CreateEvent(BaseModel):
price_per_ticket: float = Query(..., ge=0) price_per_ticket: float = Query(..., ge=0)
banner: Optional[str] = None banner: Optional[str] = None
extra: EventExtra = Field(default_factory=EventExtra) extra: EventExtra = Field(default_factory=EventExtra)
status: str = "approved" # proposed, approved, rejected
class CreateTicket(BaseModel): class CreateTicket(BaseModel):
@ -78,6 +79,7 @@ class Event(BaseModel):
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" # proposed, approved, rejected
class TicketExtra(BaseModel): class TicketExtra(BaseModel):

View file

@ -24,6 +24,8 @@ from .crud import (
get_event, get_event,
get_event_tickets, get_event_tickets,
get_events, get_events,
get_pending_events,
get_public_events,
get_ticket, get_ticket,
get_tickets, get_tickets,
get_tickets_by_user_id, get_tickets_by_user_id,
@ -54,12 +56,10 @@ async def api_events(
@events_api_router.get("/api/v1/events/public") @events_api_router.get("/api/v1/events/public")
async def api_events_public(): async def api_events_public():
""" """
Retrieve all events in the database with read-only access. Retrieve approved, non-canceled events for public display.
This endpoint allows access to all events using any valid API key (read access). No authentication required.
""" """
# Get all events from the database without wallet filtering events = await get_public_events()
from .crud import get_all_events
events = await get_all_events()
return [event.dict() for event in events] return [event.dict() for event in events]
@ -128,6 +128,75 @@ async def api_form_delete(
return "", HTTPStatus.NO_CONTENT return "", HTTPStatus.NO_CONTENT
#########Event Approval##########
@events_api_router.post("/api/v1/events/propose")
async def api_event_propose(
data: CreateEvent,
wallet: WalletTypeInfo = Depends(require_invoice_key),
):
"""
Propose a new event for admin approval.
Requires invoice key (any authenticated user, not admin-only).
"""
data.status = "proposed"
data.wallet = wallet.wallet.id
event = await create_event(data)
return event.dict()
@events_api_router.get("/api/v1/events/pending")
async def api_events_pending(
wallet: WalletTypeInfo = Depends(require_admin_key),
):
"""Get all proposed events awaiting approval. Admin only."""
events = await get_pending_events()
return [event.dict() for event in events]
@events_api_router.put("/api/v1/events/{event_id}/approve")
async def api_event_approve(
event_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
"""Approve a proposed event. Admin only."""
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"
event = await update_event(event)
return event.dict()
@events_api_router.put("/api/v1/events/{event_id}/reject")
async def api_event_reject(
event_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
"""Reject a proposed event. Admin only."""
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"
event = await update_event(event)
return event.dict()
#########Tickets########## #########Tickets##########
@ -207,7 +276,7 @@ async def api_ticket_make_ticket_user_id(event_id: str, user_id: str):
@events_api_router.get("/api/v1/tickets/{event_id}/{name}/{email}") @events_api_router.get("/api/v1/tickets/{event_id}/{name}/{email}")
async def api_ticket_make_ticket(event_id, name, email, promo_code, refund_address): async def api_ticket_make_ticket(event_id, name, email, promo_code=None, refund_address=None):
event = await get_event(event_id) event = await get_event(event_id)
if not event: if not event:
raise HTTPException( raise HTTPException(