From 68e6e3d02e5bfc353ddc81a890deaab69ccf522e Mon Sep 17 00:00:00 2001 From: padreug Date: Sat, 3 Jan 2026 18:03:34 +0100 Subject: [PATCH 1/5] fix: Parse JSON extra field when reading tickets from database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crud.py | 70 ++++++++++++++++++++++++--------------------------------- 1 file changed, 29 insertions(+), 41 deletions(-) diff --git a/crud.py b/crud.py index 8996a2e..8abcbbb 100644 --- a/crud.py +++ b/crud.py @@ -1,3 +1,4 @@ +import json from datetime import datetime, timedelta, timezone from typing import Optional @@ -6,6 +7,30 @@ from lnbits.helpers import urlsafe_short_hash 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") @@ -94,14 +119,7 @@ async def get_ticket(payment_hash: str) -> Optional[Ticket]: if not row: return None - # 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 - - return Ticket(**ticket_data) + return Ticket(**_parse_ticket_row(row)) 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]) rows = await db.fetchall(f"SELECT * FROM events.ticket WHERE wallet IN ({q})") - tickets = [] - 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 + return [Ticket(**_parse_ticket_row(row)) for row in rows] 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} ) - tickets = [] - 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 + return [Ticket(**_parse_ticket_row(row)) for row in rows] async def delete_ticket(payment_hash: str) -> None: @@ -212,14 +210,4 @@ async def get_event_tickets(event_id: str) -> list[Ticket]: {"event": event_id}, ) - tickets = [] - 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 + return [Ticket(**_parse_ticket_row(row)) for row in rows] From 1dcff37df5e61699df0ee5042741e89fafebe86c Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 09:01:50 +0200 Subject: [PATCH 2/5] 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) --- migrations.py | 11 +++++++++++ models.py | 2 ++ 2 files changed, 13 insertions(+) diff --git a/migrations.py b/migrations.py index 9ed982b..357c3ed 100644 --- a/migrations.py +++ b/migrations.py @@ -191,3 +191,14 @@ async def m007_add_extra_fields(db): # Add 'extra' column to ticket table 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';" + ) diff --git a/models.py b/models.py index b05a5da..78917d4 100644 --- a/models.py +++ b/models.py @@ -39,6 +39,7 @@ class CreateEvent(BaseModel): price_per_ticket: float = Query(..., ge=0) banner: Optional[str] = None extra: EventExtra = Field(default_factory=EventExtra) + status: str = "approved" # proposed, approved, rejected class CreateTicket(BaseModel): @@ -78,6 +79,7 @@ class Event(BaseModel): sold: int = 0 banner: str | None = None extra: EventExtra = Field(default_factory=EventExtra) + status: str = "approved" # proposed, approved, rejected class TicketExtra(BaseModel): From 0c782e623959b0bd8dbca7a361b07287befce908 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 09:02:18 +0200 Subject: [PATCH 3/5] 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) --- crud.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crud.py b/crud.py index 8abcbbb..9642a83 100644 --- a/crud.py +++ b/crud.py @@ -200,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: await db.execute("DELETE FROM events.events WHERE id = :id", {"id": event_id}) From a41348df941a69d5a232788cd639a10a6846e00d Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 09:04:25 +0200 Subject: [PATCH 4/5] feat: add event proposal and approval API endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- views_api.py | 79 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 5 deletions(-) diff --git a/views_api.py b/views_api.py index ada7323..d8a2b75 100644 --- a/views_api.py +++ b/views_api.py @@ -24,6 +24,8 @@ from .crud import ( get_event, get_event_tickets, get_events, + get_pending_events, + get_public_events, get_ticket, get_tickets, get_tickets_by_user_id, @@ -54,12 +56,10 @@ async def api_events( @events_api_router.get("/api/v1/events/public") async def api_events_public(): """ - Retrieve all events in the database with read-only access. - This endpoint allows access to all events using any valid API key (read access). + Retrieve approved, non-canceled events for public display. + No authentication required. """ - # Get all events from the database without wallet filtering - from .crud import get_all_events - events = await get_all_events() + events = await get_public_events() return [event.dict() for event in events] @@ -128,6 +128,75 @@ async def api_form_delete( 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########## From eb474b139033a805de0f21fe5de5fbfd62290c19 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 09:04:46 +0200 Subject: [PATCH 5/5] fix: make promo_code and refund_address optional query params 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) --- views_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views_api.py b/views_api.py index d8a2b75..6fae87a 100644 --- a/views_api.py +++ b/views_api.py @@ -276,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}") -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) if not event: raise HTTPException(