From c669da5822429e1a650dd9dd12536afdf4de485e Mon Sep 17 00:00:00 2001 From: padreug Date: Mon, 3 Nov 2025 23:05:31 +0100 Subject: [PATCH 1/7] Adds public events endpoint and user tickets Adds a public events endpoint that allows read-only access to all events. Improves ticket management by adding support for user IDs as an identifier, alongside name and email. This simplifies ticket creation for authenticated users and enhances security. Also introduces an API endpoint to fetch tickets by user ID. --- API_DOCUMENTATION.md | 56 +++++++++++++++++++++ crud.py | 117 +++++++++++++++++++++++++++++++++++++++---- migrations.py | 14 ++++++ models.py | 25 +++++++-- tasks.py | 8 ++- tests/test_api.py | 108 +++++++++++++++++++++++++++++++++++++++ views_api.py | 69 +++++++++++++++++++++++-- 7 files changed, 377 insertions(+), 20 deletions(-) create mode 100644 API_DOCUMENTATION.md create mode 100644 tests/test_api.py diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md new file mode 100644 index 0000000..9ad0d5f --- /dev/null +++ b/API_DOCUMENTATION.md @@ -0,0 +1,56 @@ +# Events API Documentation + +## Public Events Endpoint + +### GET `/api/v1/events/public` + +Retrieve all events in the database with read-only access. No authentication required. + +**Authentication:** None required (public endpoint) + +**Headers:** +``` +None required +``` + +**Query Parameters:** +- None + +**Response:** +```json +[ + { + "id": "event_id", + "wallet": "wallet_id", + "name": "Event Name", + "info": "Event description", + "closing_date": "2024-12-31", + "event_start_date": "2024-12-01", + "event_end_date": "2024-12-02", + "currency": "sat", + "amount_tickets": 100, + "price_per_ticket": 1000.0, + "time": "2024-01-01T00:00:00Z", + "sold": 0, + "banner": null + } +] +``` + +**Example Usage:** +```bash +curl http://your-lnbits-instance/events/api/v1/events/public +``` + +**Notes:** +- This endpoint allows read-only access to all events in the database +- No authentication required (truly public endpoint) +- Returns events ordered by creation time (newest first) +- Suitable for public event listings or read-only integrations + +## Comparison with Existing Endpoints + +| Endpoint | Authentication | Scope | Use Case | +|----------|---------------|-------|----------| +| `/api/v1/events` | Invoice Key | User's wallets only | Private event management | +| `/api/v1/events/public` | None | All events | Public event browsing | \ No newline at end of file diff --git a/crud.py b/crud.py index 51839b0..ef4bbca 100644 --- a/crud.py +++ b/crud.py @@ -10,45 +10,123 @@ db = Database("ext_events") async def create_ticket( - payment_hash: str, wallet: str, event: str, name: str, email: str + payment_hash: str, + wallet: str, + event: str, + name: Optional[str] = None, + email: Optional[str] = None, + user_id: Optional[str] = None ) -> Ticket: now = datetime.now(timezone.utc) + + # Handle database constraints: if user_id is provided, use empty strings for name/email + if user_id: + db_name = "" + db_email = "" + else: + db_name = name or "" + db_email = email or "" + ticket = Ticket( id=payment_hash, wallet=wallet, event=event, name=name, email=email, + user_id=user_id, registered=False, paid=False, reg_timestamp=now, time=now, ) - await db.insert("events.ticket", ticket) + + # Create a dict for database insertion with proper handling of constraints + ticket_dict = ticket.dict() + ticket_dict["name"] = db_name + ticket_dict["email"] = db_email + + await db.execute( + """ + INSERT INTO events.ticket (id, wallet, event, name, email, user_id, registered, paid, time, reg_timestamp) + VALUES (:id, :wallet, :event, :name, :email, :user_id, :registered, :paid, :time, :reg_timestamp) + """, + ticket_dict + ) return ticket async def update_ticket(ticket: Ticket) -> Ticket: - await db.update("events.ticket", ticket) + # Create a new Ticket object with corrected values for database constraints + ticket_dict = ticket.dict() + + # Convert None values to empty strings for database constraints + if ticket_dict.get("name") is None: + ticket_dict["name"] = "" + if ticket_dict.get("email") is None: + ticket_dict["email"] = "" + + # Create a new Ticket object with the corrected values + corrected_ticket = Ticket(**ticket_dict) + + await db.update("events.ticket", corrected_ticket) return ticket async def get_ticket(payment_hash: str) -> Optional[Ticket]: - return await db.fetchone( + row = await db.fetchone( "SELECT * FROM events.ticket WHERE id = :id", {"id": payment_hash}, - 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) async def get_tickets(wallet_ids: Union[str, list[str]]) -> list[Ticket]: if isinstance(wallet_ids, str): wallet_ids = [wallet_ids] q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids]) - return await db.fetchall( - f"SELECT * FROM events.ticket WHERE wallet IN ({q})", - model=Ticket, + 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 + + +async def get_tickets_by_user_id(user_id: str) -> list[Ticket]: + """Get all tickets for a specific user by their user_id""" + rows = await db.fetchall( + "SELECT * FROM events.ticket WHERE user_id = :user_id ORDER BY time DESC", + {"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 async def delete_ticket(payment_hash: str) -> None: @@ -102,13 +180,32 @@ async def get_events(wallet_ids: Union[str, list[str]]) -> list[Event]: ) +async def get_all_events() -> list[Event]: + """Get all events from the database without wallet filtering.""" + return await db.fetchall( + "SELECT * FROM events.events 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}) async def get_event_tickets(event_id: str) -> list[Ticket]: - return await db.fetchall( + rows = await db.fetchall( "SELECT * FROM events.ticket WHERE event = :event", {"event": event_id}, - Ticket, ) + + 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 diff --git a/migrations.py b/migrations.py index 87a0dd4..660fa83 100644 --- a/migrations.py +++ b/migrations.py @@ -160,3 +160,17 @@ async def m005_add_image_banner(db): Add a column to allow an image banner for the event """ await db.execute("ALTER TABLE events.events ADD COLUMN banner TEXT;") + + +async def m006_add_user_id_support(db): + """ + Add user_id column to tickets table to support LNbits user-id as identifier + Make name and email optional when user_id is provided + """ + await db.execute("ALTER TABLE events.ticket ADD COLUMN user_id TEXT;") + + # Since SQLite doesn't support changing column constraints directly, + # we'll work around this by allowing the application logic to handle + # the validation that either (name AND email) OR user_id is provided + # The database will continue to expect name and email as NOT NULL + # but we'll insert empty strings for user_id tickets diff --git a/models.py b/models.py index f475308..d5f3031 100644 --- a/models.py +++ b/models.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import Optional from fastapi import Query -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel, EmailStr, root_validator class CreateEvent(BaseModel): @@ -19,8 +19,22 @@ class CreateEvent(BaseModel): class CreateTicket(BaseModel): - name: str - email: EmailStr + name: Optional[str] = None + email: Optional[EmailStr] = None + user_id: Optional[str] = None + + @root_validator + def validate_identifiers(cls, values): + # Ensure either (name AND email) OR user_id is provided + name = values.get('name') + email = values.get('email') + user_id = values.get('user_id') + + if not user_id and not (name and email): + raise ValueError("Either user_id or both name and email must be provided") + if user_id and (name or email): + raise ValueError("Cannot provide both user_id and name/email") + return values class Event(BaseModel): @@ -43,8 +57,9 @@ class Ticket(BaseModel): id: str wallet: str event: str - name: str - email: str + name: Optional[str] = None + email: Optional[str] = None + user_id: Optional[str] = None registered: bool paid: bool time: datetime diff --git a/tasks.py b/tasks.py index f7300bb..67d5d45 100644 --- a/tasks.py +++ b/tasks.py @@ -21,8 +21,12 @@ async def on_invoice_paid(payment: Payment) -> None: if not payment.extra or "events" != payment.extra.get("tag"): return - if not payment.extra.get("name") or not payment.extra.get("email"): - logger.warning(f"Ticket {payment.payment_hash} missing name or email.") + # Check if ticket has either name/email or user_id + has_name_email = payment.extra.get("name") and payment.extra.get("email") + has_user_id = payment.extra.get("user_id") + + if not has_name_email and not has_user_id: + logger.warning(f"Ticket {payment.payment_hash} missing name/email or user_id.") return ticket = await get_ticket(payment.payment_hash) diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..8c74b39 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,108 @@ +import pytest +from fastapi.testclient import TestClient +from unittest.mock import AsyncMock, patch + +from ..views_api import events_api_router +from ..models import Event +from datetime import datetime, timezone + + +@pytest.mark.asyncio +async def test_api_events_public(): + """Test the new public events API endpoint""" + from fastapi import FastAPI + + app = FastAPI() + app.include_router(events_api_router) + + # Mock the database + with patch('events.crud.get_all_events') as mock_get_all_events: + # Create mock events + mock_events = [ + Event( + id="test_event_1", + wallet="test_wallet_1", + name="Test Event 1", + info="Test event description", + closing_date="2024-12-31", + event_start_date="2024-12-01", + event_end_date="2024-12-02", + currency="sat", + amount_tickets=100, + price_per_ticket=1000.0, + time=datetime.now(timezone.utc), + sold=0, + banner=None + ), + Event( + id="test_event_2", + wallet="test_wallet_2", + name="Test Event 2", + info="Another test event", + closing_date="2024-12-31", + event_start_date="2024-12-03", + event_end_date="2024-12-04", + currency="sat", + amount_tickets=50, + price_per_ticket=500.0, + time=datetime.now(timezone.utc), + sold=0, + banner=None + ) + ] + + mock_get_all_events.return_value = mock_events + + client = TestClient(app) + + # Test the endpoint without any authentication + response = client.get("/api/v1/events/public") + + # Verify the response + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + assert data[0]["id"] == "test_event_1" + assert data[1]["id"] == "test_event_2" + assert data[0]["name"] == "Test Event 1" + assert data[1]["name"] == "Test Event 2" + + +@pytest.mark.asyncio +async def test_get_all_events_crud(): + """Test the get_all_events CRUD function""" + from events.crud import get_all_events + + with patch('events.crud.db.fetchall') as mock_fetchall: + # Mock database response + mock_events = [ + { + "id": "test_event_1", + "wallet": "test_wallet_1", + "name": "Test Event 1", + "info": "Test event description", + "closing_date": "2024-12-31", + "event_start_date": "2024-12-01", + "event_end_date": "2024-12-02", + "currency": "sat", + "amount_tickets": 100, + "price_per_ticket": 1000.0, + "time": datetime.now(timezone.utc), + "sold": 0, + "banner": None + } + ] + + mock_fetchall.return_value = mock_events + + events = await get_all_events() + + # Verify the function was called with correct parameters + mock_fetchall.assert_called_once_with( + "SELECT * FROM events.events ORDER BY time DESC", + model=Event, + ) + + # Verify the result + assert len(events) == 1 + assert events[0]["id"] == "test_event_1" \ No newline at end of file diff --git a/views_api.py b/views_api.py index cbd8b29..353a4bc 100644 --- a/views_api.py +++ b/views_api.py @@ -27,6 +27,7 @@ from .crud import ( get_events, get_ticket, get_tickets, + get_tickets_by_user_id, purge_unpaid_tickets, update_event, update_ticket, @@ -51,6 +52,18 @@ async def api_events( return [event.dict() for event in await get_events(wallet_ids)] +@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). + """ + # Get all events from the database without wallet filtering + from .crud import get_all_events + events = await get_all_events() + return [event.dict() for event in events] + + @events_api_router.post("/api/v1/events") @events_api_router.put("/api/v1/events/{event_id}") async def api_event_create( @@ -113,11 +126,61 @@ async def api_tickets( return await get_tickets(wallet_ids) +@events_api_router.get("/api/v1/tickets/user/{user_id}") +async def api_tickets_by_user_id(user_id: str) -> list[Ticket]: + """Get all tickets for a specific user by their user_id""" + return await get_tickets_by_user_id(user_id) + + @events_api_router.post("/api/v1/tickets/{event_id}") async def api_ticket_create(event_id: str, data: CreateTicket): - name = data.name - email = data.email - return await api_ticket_make_ticket(event_id, name, email) + if data.user_id: + return await api_ticket_make_ticket_with_user_id(event_id, data.user_id) + else: + return await api_ticket_make_ticket(event_id, data.name, data.email) + + +async def api_ticket_make_ticket_with_user_id(event_id: str, user_id: str): + event = await get_event(event_id) + if not event: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." + ) + + price = event.price_per_ticket + extra = {"tag": "events", "user_id": user_id} + + if event.currency != "sats": + price = await fiat_amount_as_satoshis(event.price_per_ticket, event.currency) + + extra["fiat"] = True + extra["currency"] = event.currency + extra["fiatAmount"] = event.price_per_ticket + extra["rate"] = await get_fiat_rate_satoshis(event.currency) + + try: + payment = await create_invoice( + wallet_id=event.wallet, + amount=price, + memo=f"{event_id}", + extra=extra, + ) + await create_ticket( + payment_hash=payment.payment_hash, + wallet=event.wallet, + event=event.id, + user_id=user_id, + ) + except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc) + ) from exc + return {"payment_hash": payment.payment_hash, "payment_request": payment.bolt11} + + +@events_api_router.get("/api/v1/tickets/{event_id}/user/{user_id}") +async def api_ticket_make_ticket_user_id(event_id: str, user_id: str): + return await api_ticket_make_ticket_with_user_id(event_id, user_id) @events_api_router.get("/api/v1/tickets/{event_id}/{name}/{email}") From 33977c53d6ad7e3dc20ce851dc2f5b4762989556 Mon Sep 17 00:00:00 2001 From: padreug Date: Tue, 4 Nov 2025 01:42:14 +0100 Subject: [PATCH 2/7] Imports Optional type hint Imports the `Optional` type hint from the `typing` module into `crud.py` and `models.py`. This provides more explicit type annotations where values can be `None`. --- crud.py | 1 + models.py | 1 + 2 files changed, 2 insertions(+) diff --git a/crud.py b/crud.py index 90c7c9d..8e48475 100644 --- a/crud.py +++ b/crud.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta, timezone +from typing import Optional from lnbits.db import Database from lnbits.helpers import urlsafe_short_hash diff --git a/models.py b/models.py index 06f4d8e..d3bf696 100644 --- a/models.py +++ b/models.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Optional from fastapi import Query from pydantic import BaseModel, EmailStr, root_validator From 44f2cb5a6294415bb3555071c2d70130fa746264 Mon Sep 17 00:00:00 2001 From: arbadacarba <63317640+arbadacarbaYK@users.noreply.github.com> Date: Tue, 9 Dec 2025 10:28:48 +0100 Subject: [PATCH 3/7] Fix typos (#39) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ebd7194..f356ba7 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions) -## Sell tickets for events and use the built-in scanner for registering attendants +## Sell tickets for events and use the built-in scanner for registering attendees -Events alows you to make tickets for an event. Each ticket is in the form of a uniqque QR code. After registering, and paying for ticket, the user gets a QR code to present at registration/entrance. +Events alows you to make tickets for an event. Each ticket is in the form of a unique QR code. After registering, and paying for ticket, the user gets a QR code to present at registration/entrance. Events includes a shareable ticket scanner, which can be used to register attendees. From a9ac6dcfc1c9ca29b90be9929fb218b652ecc1b7 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Tue, 9 Dec 2025 10:48:00 +0000 Subject: [PATCH 4/7] feat: add promo codes and conditional events (#40) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add extra column * add conditional events * refunds * conditional events working * adding promo codes * promo codes logic --------- Co-authored-by: dni ⚡ --- crud.py | 26 +++--- migrations.py | 17 ++++ models.py | 41 ++++++++- services.py | 38 ++++++++- static/js/display.js | 18 ++-- static/js/index.js | 104 +++++++++++++++++++---- templates/events/display.html | 22 ++++- templates/events/index.html | 154 +++++++++++++++++++++++++++++++++- views.py | 36 +++++++- views_api.py | 78 +++++++++++++---- 10 files changed, 463 insertions(+), 71 deletions(-) diff --git a/crud.py b/crud.py index 8e48475..fd82a52 100644 --- a/crud.py +++ b/crud.py @@ -4,18 +4,19 @@ from typing import Optional from lnbits.db import Database from lnbits.helpers import urlsafe_short_hash -from .models import CreateEvent, Event, Ticket +from .models import CreateEvent, Event, Ticket, TicketExtra db = Database("ext_events") async def create_ticket( - payment_hash: str, - wallet: str, - event: str, - name: Optional[str] = None, + payment_hash: str, + wallet: str, + event: str, + name: Optional[str] = None, email: Optional[str] = None, - user_id: Optional[str] = None + user_id: Optional[str] = None, + extra: Optional[dict] = None, ) -> Ticket: now = datetime.now(timezone.utc) @@ -38,6 +39,7 @@ async def create_ticket( paid=False, reg_timestamp=now, time=now, + extra=TicketExtra(**extra) if extra else TicketExtra(), ) # Create a dict for database insertion with proper handling of constraints @@ -47,8 +49,8 @@ async def create_ticket( await db.execute( """ - INSERT INTO events.ticket (id, wallet, event, name, email, user_id, registered, paid, time, reg_timestamp) - VALUES (:id, :wallet, :event, :name, :email, :user_id, :registered, :paid, :time, :reg_timestamp) + INSERT INTO events.ticket (id, wallet, event, name, email, user_id, registered, paid, time, reg_timestamp, extra) + VALUES (:id, :wallet, :event, :name, :email, :user_id, :registered, :paid, :time, :reg_timestamp, :extra) """, ticket_dict ) @@ -72,21 +74,21 @@ async def update_ticket(ticket: Ticket) -> Ticket: return ticket -async def get_ticket(payment_hash: str) -> Ticket | None: - return await db.fetchone( +async def get_ticket(payment_hash: str) -> Optional[Ticket]: + row = await db.fetchone( "SELECT * FROM events.ticket WHERE id = :id", {"id": payment_hash}, ) 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) diff --git a/migrations.py b/migrations.py index 660fa83..8ed79b5 100644 --- a/migrations.py +++ b/migrations.py @@ -174,3 +174,20 @@ async def m006_add_user_id_support(db): # the validation that either (name AND email) OR user_id is provided # The database will continue to expect name and email as NOT NULL # but we'll insert empty strings for user_id tickets + +async def m007_add_extra_fields(db): + """ + Add a canceled and 'extra' column to events and ticket tables + to support promo codes and ticket metadata. + """ + # Add canceled and 'extra' columns to events table + await db.execute( + """ + ALTER TABLE events.events + ADD COLUMN canceled BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN extra TEXT; + """ + ) + + # Add 'extra' column to ticket table + await db.execute("ALTER TABLE events.ticket ADD COLUMN extra TEXT;") diff --git a/models.py b/models.py index d3bf696..31534f0 100644 --- a/models.py +++ b/models.py @@ -2,7 +2,29 @@ from datetime import datetime from typing import Optional from fastapi import Query -from pydantic import BaseModel, EmailStr, root_validator +from pydantic import BaseModel, EmailStr, Field, root_validator, validator + + +class PromoCode(BaseModel): + code: str + discount_percent: float = 0.0 + active: bool = True + + # make the promo code uppercase + @validator("code") + def uppercase_code(cls, v): + return v.upper() + + @validator("discount_percent") + def validate_discount_percent(cls, v): + assert 0 <= v <= 100, "Discount must be between 0 and 100." + return v + + +class EventExtra(BaseModel): + promo_codes: list[PromoCode] = Field(default_factory=list) + conditional: bool = False + min_tickets: int = 1 class CreateEvent(BaseModel): @@ -16,20 +38,23 @@ class CreateEvent(BaseModel): amount_tickets: int = Query(..., ge=0) price_per_ticket: float = Query(..., ge=0) banner: str | None = None + extra: EventExtra = Field(default_factory=EventExtra) class CreateTicket(BaseModel): name: Optional[str] = None email: Optional[EmailStr] = None user_id: Optional[str] = None - + promo_code: Optional[str] = None + refund_address: Optional[str] = None + @root_validator def validate_identifiers(cls, values): # Ensure either (name AND email) OR user_id is provided name = values.get('name') email = values.get('email') user_id = values.get('user_id') - + if not user_id and not (name and email): raise ValueError("Either user_id or both name and email must be provided") if user_id and (name or email): @@ -43,6 +68,7 @@ class Event(BaseModel): name: str info: str closing_date: str + canceled: bool = False event_start_date: str event_end_date: str currency: str @@ -51,6 +77,14 @@ class Event(BaseModel): time: datetime sold: int = 0 banner: str | None = None + extra: EventExtra = Field(default_factory=EventExtra) + + +class TicketExtra(BaseModel): + applied_promo_code: str | None = None + sats_paid: int | None = None + refund_address: str | None = None + refunded: bool = False class Ticket(BaseModel): @@ -64,3 +98,4 @@ class Ticket(BaseModel): paid: bool time: datetime reg_timestamp: datetime + extra: TicketExtra = Field(default_factory=TicketExtra) diff --git a/services.py b/services.py index 1286534..9099ef0 100644 --- a/services.py +++ b/services.py @@ -1,4 +1,13 @@ -from .crud import get_event, update_event, update_ticket +from lnurl import execute +from loguru import logger + +from .crud import ( + get_event, + get_event_tickets, + purge_unpaid_tickets, + update_event, + update_ticket, +) from .models import Ticket @@ -16,3 +25,30 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket: await update_event(event) return ticket + + +async def refund_tickets(event_id: str): + """ + Refund tickets for an event that has not met the minimum ticket requirement. + This function should be called when the event is closed and the minimum ticket + condition is not met. + """ + await purge_unpaid_tickets(event_id) + tickets = await get_event_tickets(event_id) + + if not tickets: + return + + for ticket in tickets: + if ticket.extra.refunded: + continue + if ticket.paid and ticket.extra.refund_address and ticket.extra.sats_paid: + try: + res = await execute( + ticket.extra.refund_address, str(ticket.extra.sats_paid) + ) + if res: + ticket.extra.refunded = True + await update_ticket(ticket) + except Exception as e: + logger.error(f"Error refunding ticket {ticket.id}: {e}") diff --git a/static/js/display.js b/static/js/display.js index 4884751..6098e5a 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -9,7 +9,8 @@ window.app = Vue.createApp({ show: false, data: { name: '', - email: '' + email: '', + refund: '' } }, ticketLink: { @@ -29,7 +30,8 @@ window.app = Vue.createApp({ this.info = event_info this.info = this.info.substring(1, this.info.length - 1) this.banner = event_banner - await this.purgeUnpaidTickets() + this.extra = event_extra + this.hasPromoCodes = has_promoCodes }, computed: { formatDescription() { @@ -41,6 +43,7 @@ window.app = Vue.createApp({ e.preventDefault() this.formDialog.data.name = '' this.formDialog.data.email = '' + this.formDialog.data.refund = '' }, closeReceiveDialog() { @@ -60,12 +63,12 @@ window.app = Vue.createApp({ const regex = /^[\w\.-]+@[a-zA-Z\d\.-]+\.[a-zA-Z]{2,}$/ return regex.test(val) || 'Please enter valid email.' }, - Invoice() { axios .post(`/events/api/v1/tickets/${event_id}`, { name: this.formDialog.data.name, - email: this.formDialog.data.email + email: this.formDialog.data.email, + promo_code: this.formDialog.data.promo_code || null }) .then(response => { this.paymentReq = response.data.payment_request @@ -122,13 +125,6 @@ window.app = Vue.createApp({ }, 2000) }) .catch(LNbits.utils.notifyApiError) - }, - async purgeUnpaidTickets() { - try { - await LNbits.api.request('GET', `/events/api/v1/purge/${event_id}`) - } catch (error) { - LNbits.utils.notifyApiError(error) - } } } }) diff --git a/static/js/index.js b/static/js/index.js index cccbe82..d26133c 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,9 +1,6 @@ const mapEvents = function (obj) { - obj.date = Quasar.date.formatDate( - new Date(obj.time * 1000), - 'YYYY-MM-DD HH:mm' - ) - obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.price_per_ticket) + obj.date = LNbits.utils.formatTimestamp(obj.time) + obj.fsat = new Intl.NumberFormat(window.g.locale).format(obj.price_per_ticket) obj.displayUrl = ['/events/', obj.id].join('') return obj } @@ -20,8 +17,6 @@ window.app = Vue.createApp({ columns: [ {name: 'id', align: 'left', label: 'ID', field: 'id'}, {name: 'name', align: 'left', label: 'Name', field: 'name'}, - {name: 'info', align: 'left', label: 'Info', field: 'info'}, - {name: 'banner', align: 'left', label: 'Banner', field: 'banner'}, { name: 'event_start_date', align: 'left', @@ -40,6 +35,17 @@ window.app = Vue.createApp({ label: 'Ticket close', field: 'closing_date' }, + { + name: 'canceled', + align: 'left', + label: 'Canceled', + field: row => { + if (row.extra.conditional && row.canceled) { + return 'Yes' + } + return 'No' + } + }, { name: 'price_per_ticket', align: 'left', @@ -65,7 +71,9 @@ window.app = Vue.createApp({ align: 'left', label: 'Sold', field: 'sold' - } + }, + {name: 'info', align: 'left', label: 'Info', field: 'info'}, + {name: 'banner', align: 'left', label: 'Banner', field: 'banner'} ], pagination: { rowsPerPage: 10 @@ -73,7 +81,6 @@ window.app = Vue.createApp({ }, ticketsTable: { columns: [ - {name: 'id', align: 'left', label: 'ID', field: 'id'}, {name: 'event', align: 'left', label: 'Event', field: 'event'}, {name: 'name', align: 'left', label: 'Name', field: 'name'}, {name: 'email', align: 'left', label: 'Email', field: 'email'}, @@ -82,7 +89,14 @@ window.app = Vue.createApp({ align: 'left', label: 'Registered', field: 'registered' - } + }, + { + name: 'promo_code', + align: 'left', + label: 'Promo Code', + field: row => row.extra.applied_promo_code || '' + }, + {name: 'id', align: 'left', label: 'ID', field: 'id'} ], pagination: { rowsPerPage: 10 @@ -90,7 +104,11 @@ window.app = Vue.createApp({ }, formDialog: { show: false, - data: {} + data: { + extra: { + promo_codes: [] + } + } } } }, @@ -143,9 +161,10 @@ window.app = Vue.createApp({ this.g.user.wallets[0].inkey ) .then(response => { - this.events = response.data.map(function (obj) { + this.events = response.data.map(obj => { return mapEvents(obj) }) + this.checkCanceledEvents() }) }, sendEventData() { @@ -153,6 +172,11 @@ window.app = Vue.createApp({ id: this.formDialog.data.wallet }) const data = this.formDialog.data + if (data.extra && !data.extra.promo_codes) { + data.extra.promo_codes = data.extra.promo_codes + .filter(code => code.trim() !== '') + .map(code => code.trim().toUpperCase()) + } if (data.id) { this.updateEvent(wallet, data) @@ -161,20 +185,41 @@ window.app = Vue.createApp({ } }, + openEventDialog(data = false) { + if (data && data.id) { + this.formDialog.data = {...data} + } else { + this.formDialog.data = { + extra: { + conditional: false, + min_tickets: 1, + promo_codes: [] + } + } + } + this.formDialog.show = true + }, + resetEventDialog() { + this.formDialog.show = false + this.formDialog.data = { + extra: { + promo_codes: [] + } + } + }, + createEvent(wallet, data) { LNbits.api .request('POST', '/events/api/v1/events', wallet.adminkey, data) .then(response => { this.events.push(mapEvents(response.data)) - this.formDialog.show = false - this.formDialog.data = {} + this.resetEventDialog() }) .catch(LNbits.utils.notifyApiError) }, updateformDialog(formId) { const link = _.findWhere(this.events, {id: formId}) - this.formDialog.data = {...link} - this.formDialog.show = true + this.openEventDialog(link) }, updateEvent(wallet, data) { LNbits.api @@ -189,8 +234,7 @@ window.app = Vue.createApp({ return obj.id == data.id }) this.events.push(mapEvents(response.data)) - this.formDialog.show = false - this.formDialog.data = {} + this.resetEventDialog() }) .catch(LNbits.utils.notifyApiError) }, @@ -216,6 +260,30 @@ window.app = Vue.createApp({ }, exporteventsCSV() { LNbits.utils.exportCSV(this.eventsTable.columns, this.events) + }, + async checkCanceledEvents() { + const events = this.events + .filter(event => event.extra.conditional) + .filter(e => !e.canceled) + if (!events.length) return + const now = new Date() + events.forEach(async ev => { + if (new Date(ev.closing_date) < now && ev.sold < ev.extra.min_tickets) { + const {data} = await LNbits.api.request( + 'PUT', + '/events/api/v1/events/' + ev.id + '/cancel', + _.findWhere(this.g.user.wallets, {id: ev.wallet}).adminkey + ) + Quasar.Notify.create({ + type: 'warning', + message: `Event ${ev.name} has been canceled and refunds have been issued.`, + icon: null + }) + this.events = this.events.map(e => + e.id === ev.id ? mapEvents(data) : e + ) + } + }) } }, async created() { diff --git a/templates/events/display.html b/templates/events/display.html index 24a64e4..73d279d 100644 --- a/templates/events/display.html +++ b/templates/events/display.html @@ -6,7 +6,7 @@

{{ event_name }}


-
+

@@ -30,7 +30,23 @@ :rules="[val => !!val || '* Required', val => emailValidation(val)]" lazy-rules > - + +
{% endblock %} diff --git a/templates/events/index.html b/templates/events/index.html index d9c6bb3..62752d1 100644 --- a/templates/events/index.html +++ b/templates/events/index.html @@ -4,7 +4,7 @@
- New Event @@ -33,7 +33,7 @@ @@ -224,7 +280,6 @@ >
-
Event begins
@@ -248,7 +303,6 @@ >
-
+ +
+
Conditional Events
+
+ Make this event conditional if + minimum tickets are sold. User will be asked to + provide a Lightning Address or LNURL pay for refunds. +
+
+ +
+
+ +
+
+ +
Promo Codes
+
+ Allow users to enter a promo code for discounts. +
+ +
+ + + + + + +
+
+ Add Promo Code +
+
= event.extra.min_tickets if event.extra.conditional else True + ) + if event.amount_tickets < 1: return events_renderer().TemplateResponse( "events/error.html", @@ -41,8 +51,20 @@ async def display(request: Request, event_id): "event_error": "Sorry, tickets are sold out :(", }, ) - datetime_object = datetime.strptime(event.closing_date, "%Y-%m-%d").date() - if date.today() > datetime_object: + if event.extra.conditional and not is_min_tickets_met and not is_window_open: + event.canceled = True + await update_event(event) + await refund_tickets(event_id) + + return events_renderer().TemplateResponse( + "events/error.html", + { + "request": request, + "event_name": event.name, + "event_error": "Sorry, event was cancelled.", + }, + ) + if not is_window_open: return events_renderer().TemplateResponse( "events/error.html", { @@ -52,6 +74,12 @@ async def display(request: Request, event_id): }, ) + if len(event.extra.promo_codes) > 0: + has_promo_codes = True + else: + has_promo_codes = False + + event.extra.promo_codes = [] return events_renderer().TemplateResponse( "events/display.html", { @@ -61,6 +89,8 @@ async def display(request: Request, event_id): "event_info": event.info, "event_price": event.price_per_ticket, "event_banner": event.banner, + "event_extra": event.extra.json(), + "has_promo_codes": has_promo_codes, }, ) diff --git a/views_api.py b/views_api.py index cecf2c3..ada7323 100644 --- a/views_api.py +++ b/views_api.py @@ -32,7 +32,7 @@ from .crud import ( update_ticket, ) from .models import CreateEvent, CreateTicket, Ticket -from .services import set_ticket_paid +from .services import refund_tickets, set_ticket_paid events_api_router = APIRouter() @@ -90,6 +90,26 @@ async def api_event_create( return event.dict() +@events_api_router.put("/api/v1/events/{event_id}/cancel") +async def api_event_cancel( + event_id: str, + wallet: WalletTypeInfo = Depends(require_admin_key), +): + 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.") + event.canceled = True + event = await update_event(event) + await refund_tickets(event.id) + + return event.dict() + + @events_api_router.delete("/api/v1/events/{event_id}") async def api_form_delete( event_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) @@ -136,7 +156,11 @@ async def api_ticket_create(event_id: str, data: CreateTicket): if data.user_id: return await api_ticket_make_ticket_with_user_id(event_id, data.user_id) else: - return await api_ticket_make_ticket(event_id, data.name, data.email) + promo_code = data.promo_code.upper() if data.promo_code else None + refund_address = data.refund_address + return await api_ticket_make_ticket( + event_id, data.name, data.email, promo_code, refund_address + ) async def api_ticket_make_ticket_with_user_id(event_id: str, user_id: str): @@ -183,7 +207,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): +async def api_ticket_make_ticket(event_id, name, email, promo_code, refund_address): event = await get_event(event_id) if not event: raise HTTPException( @@ -193,14 +217,25 @@ async def api_ticket_make_ticket(event_id, name, email): price = event.price_per_ticket extra = {"tag": "events", "name": name, "email": email} - if event.currency != "sats": - price = await fiat_amount_as_satoshis(event.price_per_ticket, event.currency) + if promo_code: + # check if promo_code exists in event.extra.promo_codes + if promo_code not in [pc.code for pc in event.extra.promo_codes]: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code." + ) + # get the promocode + promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code) + extra["promo_code"] = promo.code + price = event.price_per_ticket * (1 - promo.discount_percent / 100) + if event.currency != "sats": extra["fiat"] = True extra["currency"] = event.currency - extra["fiatAmount"] = event.price_per_ticket + extra["fiatAmount"] = price extra["rate"] = await get_fiat_rate_satoshis(event.currency) + price = await fiat_amount_as_satoshis(price, event.currency) + try: payment = await create_invoice( wallet_id=event.wallet, @@ -214,6 +249,11 @@ async def api_ticket_make_ticket(event_id, name, email): event=event.id, name=name, email=email, + extra={ + "applied_promo_code": promo_code, + "refund_address": refund_address, + "sats_paid": int(price), + }, ) except Exception as exc: raise HTTPException( @@ -239,16 +279,31 @@ async def api_ticket_send_ticket(event_id, payment_hash): ) payment = await get_standalone_payment(payment_hash, incoming=True) assert payment + + if ticket.extra.applied_promo_code: + promo = next( + ( + pc + for pc in event.extra.promo_codes + if pc.code == ticket.extra.applied_promo_code + ), + None, + ) + if promo: + event.price_per_ticket *= 1 - promo.discount_percent / 100 + price = ( event.price_per_ticket * 1000 if event.currency == "sats" else await fiat_amount_as_satoshis(event.price_per_ticket, event.currency) * 1000 ) + # check if price is equal to payment.amount lower_bound = price * 0.99 # 1% decrease if not payment.pending and abs(payment.amount) >= lower_bound: # allow 1% error + ticket.extra.sats_paid = int(payment.amount / 1000) await set_ticket_paid(ticket) return {"paid": True, "ticket_id": ticket.id} @@ -271,17 +326,6 @@ async def api_ticket_delete( await delete_ticket(ticket_id) -# TODO: DELETE, updates db! @tal -@events_api_router.get("/api/v1/purge/{event_id}") -async def api_event_purge_tickets(event_id: str): - event = await get_event(event_id) - if not event: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." - ) - return await purge_unpaid_tickets(event_id) - - @events_api_router.get("/api/v1/eventtickets/{event_id}") async def api_event_tickets(event_id: str) -> list[Ticket]: return await get_event_tickets(event_id) From 4fb6d90fcd40edeb807f2f78bfe8becfe1557945 Mon Sep 17 00:00:00 2001 From: padreug Date: Mon, 3 Nov 2025 23:05:31 +0100 Subject: [PATCH 5/7] Adds public events endpoint and user tickets Adds a public events endpoint that allows read-only access to all events. Improves ticket management by adding support for user IDs as an identifier, alongside name and email. This simplifies ticket creation for authenticated users and enhances security. Also introduces an API endpoint to fetch tickets by user ID. --- crud.py | 26 +++++++++++++------------- models.py | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/crud.py b/crud.py index fd82a52..e92e47e 100644 --- a/crud.py +++ b/crud.py @@ -19,7 +19,7 @@ async def create_ticket( extra: Optional[dict] = None, ) -> Ticket: now = datetime.now(timezone.utc) - + # Handle database constraints: if user_id is provided, use empty strings for name/email if user_id: db_name = "" @@ -27,7 +27,7 @@ async def create_ticket( else: db_name = name or "" db_email = email or "" - + ticket = Ticket( id=payment_hash, wallet=wallet, @@ -41,12 +41,12 @@ async def create_ticket( time=now, extra=TicketExtra(**extra) if extra else TicketExtra(), ) - + # Create a dict for database insertion with proper handling of constraints ticket_dict = ticket.dict() ticket_dict["name"] = db_name ticket_dict["email"] = db_email - + await db.execute( """ INSERT INTO events.ticket (id, wallet, event, name, email, user_id, registered, paid, time, reg_timestamp, extra) @@ -60,16 +60,16 @@ async def create_ticket( async def update_ticket(ticket: Ticket) -> Ticket: # Create a new Ticket object with corrected values for database constraints ticket_dict = ticket.dict() - + # Convert None values to empty strings for database constraints if ticket_dict.get("name") is None: ticket_dict["name"] = "" if ticket_dict.get("email") is None: ticket_dict["email"] = "" - + # Create a new Ticket object with the corrected values corrected_ticket = Ticket(**ticket_dict) - + await db.update("events.ticket", corrected_ticket) return ticket @@ -97,7 +97,7 @@ async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]: wallet_ids = [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})") - + tickets = [] for row in rows: # Convert empty strings back to None for the model @@ -107,7 +107,7 @@ async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]: if ticket_data.get("email") == "": ticket_data["email"] = None tickets.append(Ticket(**ticket_data)) - + return tickets @@ -117,7 +117,7 @@ async def get_tickets_by_user_id(user_id: str) -> list[Ticket]: "SELECT * FROM events.ticket WHERE user_id = :user_id ORDER BY time DESC", {"user_id": user_id} ) - + tickets = [] for row in rows: # Convert empty strings back to None for the model @@ -127,7 +127,7 @@ async def get_tickets_by_user_id(user_id: str) -> list[Ticket]: if ticket_data.get("email") == "": ticket_data["email"] = None tickets.append(Ticket(**ticket_data)) - + return tickets @@ -199,7 +199,7 @@ async def get_event_tickets(event_id: str) -> list[Ticket]: "SELECT * FROM events.ticket WHERE event = :event", {"event": event_id}, ) - + tickets = [] for row in rows: # Convert empty strings back to None for the model @@ -209,5 +209,5 @@ async def get_event_tickets(event_id: str) -> list[Ticket]: if ticket_data.get("email") == "": ticket_data["email"] = None tickets.append(Ticket(**ticket_data)) - + return tickets diff --git a/models.py b/models.py index 31534f0..b05a5da 100644 --- a/models.py +++ b/models.py @@ -37,7 +37,7 @@ class CreateEvent(BaseModel): currency: str = "sat" amount_tickets: int = Query(..., ge=0) price_per_ticket: float = Query(..., ge=0) - banner: str | None = None + banner: Optional[str] = None extra: EventExtra = Field(default_factory=EventExtra) From c49abdb53ff9a3b7def5e53ceb97ccbfd52e8762 Mon Sep 17 00:00:00 2001 From: padreug Date: Wed, 31 Dec 2025 17:07:34 +0100 Subject: [PATCH 6/7] Fix SQLite migration syntax error in m007 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQLite doesn't support adding multiple columns in a single ALTER TABLE statement. Split into separate statements for each column. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- migrations.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/migrations.py b/migrations.py index 8ed79b5..9ed982b 100644 --- a/migrations.py +++ b/migrations.py @@ -181,12 +181,12 @@ async def m007_add_extra_fields(db): to support promo codes and ticket metadata. """ # Add canceled and 'extra' columns to events table + # SQLite requires separate ALTER TABLE statements for each column await db.execute( - """ - ALTER TABLE events.events - ADD COLUMN canceled BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN extra TEXT; - """ + "ALTER TABLE events.events ADD COLUMN canceled BOOLEAN NOT NULL DEFAULT FALSE;" + ) + await db.execute( + "ALTER TABLE events.events ADD COLUMN extra TEXT;" ) # Add 'extra' column to ticket table From a77145e08ee1534e2bda1d014ff580a71675aff1 Mon Sep 17 00:00:00 2001 From: padreug Date: Sat, 3 Jan 2026 17:48:46 +0100 Subject: [PATCH 7/7] fix: Use db.insert() for ticket creation to fix SQLite serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation used db.execute() with a raw dict, which failed on SQLite because the 'extra' field (TicketExtra model) was passed as a Python dict that SQLite cannot serialize. Using db.insert() with the Pydantic model ensures proper JSON serialization of the extra field across all database backends (SQLite, PostgreSQL, CockroachDB). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- crud.py | 44 ++++++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/crud.py b/crud.py index e92e47e..8996a2e 100644 --- a/crud.py +++ b/crud.py @@ -20,7 +20,12 @@ async def create_ticket( ) -> Ticket: now = datetime.now(timezone.utc) - # Handle database constraints: if user_id is provided, use empty strings for name/email + # TODO: Check if this empty string workaround is still needed. + # This converts None to empty strings for database storage because: + # 1. Database may have NOT NULL constraints on name/email columns + # 2. When user_id is provided, name/email are not used (mutually exclusive) + # 3. The get_ticket() functions convert empty strings back to None when reading + # Consider using nullable columns instead of this empty string pattern. if user_id: db_name = "" db_email = "" @@ -28,7 +33,28 @@ async def create_ticket( db_name = name or "" db_email = email or "" - ticket = Ticket( + # Create ticket with database-compatible values for insertion + # Using db.insert() ensures proper serialization of the extra field (TicketExtra) + # across all database backends (SQLite, PostgreSQL, CockroachDB) + db_ticket = Ticket( + id=payment_hash, + wallet=wallet, + event=event, + name=db_name, + email=db_email, + user_id=user_id, + registered=False, + paid=False, + reg_timestamp=now, + time=now, + extra=TicketExtra(**extra) if extra else TicketExtra(), + ) + + await db.insert("events.ticket", db_ticket) + + # Return ticket with original name/email values (not empty strings) + # This maintains consistency with how get_ticket() converts empty strings back to None + return Ticket( id=payment_hash, wallet=wallet, event=event, @@ -42,20 +68,6 @@ async def create_ticket( extra=TicketExtra(**extra) if extra else TicketExtra(), ) - # Create a dict for database insertion with proper handling of constraints - ticket_dict = ticket.dict() - ticket_dict["name"] = db_name - ticket_dict["email"] = db_email - - await db.execute( - """ - INSERT INTO events.ticket (id, wallet, event, name, email, user_id, registered, paid, time, reg_timestamp, extra) - VALUES (:id, :wallet, :event, :name, :email, :user_id, :registered, :paid, :time, :reg_timestamp, :extra) - """, - ticket_dict - ) - return ticket - async def update_ticket(ticket: Ticket) -> Ticket: # Create a new Ticket object with corrected values for database constraints