Compare commits

..

9 commits

Author SHA1 Message Date
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
a77145e08e fix: Use db.insert() for ticket creation to fix SQLite serialization
Some checks failed
lint / lint (push) Waiting to run
lint / lint (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-01-03 17:48:46 +01:00
c49abdb53f Fix SQLite migration syntax error in m007
Some checks failed
lint / lint (push) Has been cancelled
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
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 <noreply@anthropic.com>
2025-12-31 17:07:34 +01:00
4fb6d90fcd Adds public events endpoint and user tickets
Some checks are pending
lint / lint (push) Waiting to run
/ release (push) Waiting to run
/ pullrequest (push) Blocked by required conditions
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.
2025-12-31 12:54:12 +01:00
Tiago Vasconcelos
a9ac6dcfc1 feat: add promo codes and conditional events (#40)
* add extra column
* add conditional events
* refunds
* conditional events working
* adding promo codes
* promo codes logic

---------

Co-authored-by: dni  <office@dnilabs.com>
2025-12-31 12:52:32 +01:00
arbadacarba
44f2cb5a62 Fix typos (#39) 2025-12-31 12:49:29 +01:00
33977c53d6 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`.
2025-11-04 01:42:14 +01:00
7cc622fc44 Merge remote-tracking branch 'upstream/main' 2025-11-03 23:13:22 +01:00
c669da5822 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.
2025-11-03 23:05:31 +01:00
12 changed files with 1436 additions and 1282 deletions

56
API_DOCUMENTATION.md Normal file
View file

@ -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 |

View file

@ -1,20 +1,10 @@
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:280px">
</picture>
</a>
[![License: MIT](https://img.shields.io/badge/License-MIT-success?logo=open-source-initiative&logoColor=white)](./LICENSE)
[![Built for LNbits](https://img.shields.io/badge/Built%20for-LNbits-4D4DFF?logo=lightning&logoColor=white)](https://github.com/lnbits/lnbits)
# Events - <small>[LNbits](https://github.com/lnbits/lnbits) extension</small> # Events - <small>[LNbits](https://github.com/lnbits/lnbits) extension</small>
<small>For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions)</small> <small>For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions)</small>
## Sell tickets for events and use the built-in scanner for registering attendees ## Sell tickets for events and use the built-in scanner for registering attendees
Events allows you to create tickets for an event. Each ticket is in the form of a unique QR code. After registering and paying, 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. Events includes a shareable ticket scanner, which can be used to register attendees.
@ -43,10 +33,3 @@ Events includes a shareable ticket scanner, which can be used to register attend
4. Use the built-in ticket scanner to validate registered, and paid, attendees\ 4. Use the built-in ticket scanner to validate registered, and paid, attendees\
![ticket scanner](https://i.imgur.com/zrm9202.jpg) ![ticket scanner](https://i.imgur.com/zrm9202.jpg)
## Powered by LNbits
[LNbits](https://lnbits.com) is a free and open-source lightning accounts system.
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)

View file

@ -1,11 +1,8 @@
{ {
"id": "events",
"version": "1.2.1",
"name": "Events", "name": "Events",
"repo": "https://github.com/lnbits/events",
"short_description": "Sell and register event tickets", "short_description": "Sell and register event tickets",
"description": "",
"tile": "/events/static/image/events.png", "tile": "/events/static/image/events.png",
"lnbits": "1.1.0",
"min_lnbits_version": "1.3.0", "min_lnbits_version": "1.3.0",
"contributors": [ "contributors": [
{ {
@ -54,9 +51,5 @@
], ],
"description_md": "https://raw.githubusercontent.com/lnbits/events/main/description.md", "description_md": "https://raw.githubusercontent.com/lnbits/events/main/description.md",
"terms_and_conditions_md": "https://raw.githubusercontent.com/lnbits/events/main/toc.md", "terms_and_conditions_md": "https://raw.githubusercontent.com/lnbits/events/main/toc.md",
"license": "MIT", "license": "MIT"
"paid_features": "",
"tags": ["Fun & Social", "Ticketing"],
"donate": "",
"hidden": false
} }

122
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")
@ -13,13 +38,48 @@ async def create_ticket(
payment_hash: str, payment_hash: str,
wallet: str, wallet: str,
event: str, event: str,
name: str = "", name: Optional[str] = None,
email: str = "", email: Optional[str] = None,
user_id: Optional[str] = None, user_id: Optional[str] = None,
extra: Optional[dict] = None, extra: Optional[dict] = None,
) -> Ticket: ) -> Ticket:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
ticket = Ticket(
# 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 = ""
else:
db_name = name or ""
db_email = email or ""
# 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, id=payment_hash,
wallet=wallet, wallet=wallet,
event=event, event=event,
@ -32,32 +92,54 @@ async def create_ticket(
time=now, time=now,
extra=TicketExtra(**extra) if extra else TicketExtra(), extra=TicketExtra(**extra) if extra else TicketExtra(),
) )
await db.insert("events.ticket", ticket)
return ticket
async def update_ticket(ticket: Ticket) -> 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 return ticket
async def get_ticket(payment_hash: str) -> Ticket | None: async def get_ticket(payment_hash: str) -> Optional[Ticket]:
return await db.fetchone( row = await db.fetchone(
"SELECT * FROM events.ticket WHERE id = :id", "SELECT * FROM events.ticket WHERE id = :id",
{"id": payment_hash}, {"id": payment_hash},
Ticket,
) )
if not row:
return None
return Ticket(**_parse_ticket_row(row))
async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]: async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]:
if isinstance(wallet_ids, str): if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids] wallet_ids = [wallet_ids]
q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids]) q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids])
return await db.fetchall( rows = await db.fetchall(f"SELECT * FROM events.ticket WHERE wallet IN ({q})")
f"SELECT * FROM events.ticket WHERE wallet IN ({q})",
model=Ticket, return [Ticket(**_parse_ticket_row(row)) for row in rows]
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}
) )
return [Ticket(**_parse_ticket_row(row)) for row in rows]
async def delete_ticket(payment_hash: str) -> None: async def delete_ticket(payment_hash: str) -> None:
await db.execute("DELETE FROM events.ticket WHERE id = :id", {"id": payment_hash}) await db.execute("DELETE FROM events.ticket WHERE id = :id", {"id": payment_hash})
@ -111,29 +193,21 @@ async def get_events(wallet_ids: str | list[str]) -> list[Event]:
async def get_all_events() -> list[Event]: async def get_all_events() -> list[Event]:
"""Get all events without wallet filtering (public endpoint).""" """Get all events from the database without wallet filtering."""
return await db.fetchall( return await db.fetchall(
"SELECT * FROM events.events ORDER BY time DESC", "SELECT * FROM events.events ORDER BY time DESC",
model=Event, model=Event,
) )
async def get_tickets_by_user_id(user_id: str) -> list[Ticket]:
"""Get all tickets for a specific user by their user_id."""
return await db.fetchall(
"SELECT * FROM events.ticket WHERE user_id = :user_id ORDER BY time DESC",
{"user_id": user_id},
model=Ticket,
)
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})
async def get_event_tickets(event_id: str) -> list[Ticket]: 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", "SELECT * FROM events.ticket WHERE event = :event",
{"event": event_id}, {"event": event_id},
Ticket,
) )
return [Ticket(**_parse_ticket_row(row)) for row in rows]

View file

@ -1,10 +1,5 @@
Sell tickets for events and manage attendee registration with a built-in QR scanner. Sell tickets for events and use the built-in scanner for registering attendants
Its features include: 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.
- Creating events with ticket pricing Events includes a shareable ticket scanner, which can be used to register attendees.
- Generating unique QR code tickets after payment
- Providing a shareable ticket scanner for check-in
- Tracking registered and checked-in attendees
A complete ticketing solution for event organizers, meetup hosts, and conference planners who want to sell tickets and manage attendance with Bitcoin.

View file

@ -162,24 +162,32 @@ async def m005_add_image_banner(db):
await db.execute("ALTER TABLE events.events ADD COLUMN banner TEXT;") await db.execute("ALTER TABLE events.events ADD COLUMN banner TEXT;")
async def m006_add_extra_fields(db): 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
async def m007_add_extra_fields(db):
""" """
Add a canceled and 'extra' column to events and ticket tables Add a canceled and 'extra' column to events and ticket tables
to support promo codes and ticket metadata. to support promo codes and ticket metadata.
""" """
# Add canceled and 'extra' columns to events table # Add canceled and 'extra' columns to events table
# SQLite requires separate ALTER TABLE statements for each column
await db.execute( await db.execute(
"ALTER TABLE events.events ADD COLUMN canceled BOOLEAN NOT NULL DEFAULT FALSE;" "ALTER TABLE events.events ADD COLUMN canceled BOOLEAN NOT NULL DEFAULT FALSE;"
) )
await db.execute("ALTER TABLE events.events ADD COLUMN extra TEXT;") await db.execute(
"ALTER TABLE events.events ADD COLUMN extra TEXT;"
)
# 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 m007_add_user_id(db):
"""
Add user_id column to tickets table.
Allows ticket purchase via LNbits user-id without name/email.
"""
await db.execute("ALTER TABLE events.ticket ADD COLUMN user_id TEXT;")

View file

@ -37,10 +37,31 @@ class CreateEvent(BaseModel):
currency: str = "sat" currency: str = "sat"
amount_tickets: int = Query(..., ge=0) amount_tickets: int = Query(..., ge=0)
price_per_ticket: float = Query(..., ge=0) price_per_ticket: float = Query(..., ge=0)
banner: str | None = None banner: Optional[str] = None
extra: EventExtra = Field(default_factory=EventExtra) 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):
raise ValueError("Cannot provide both user_id and name/email")
return values
class Event(BaseModel): class Event(BaseModel):
id: str id: str
wallet: str wallet: str
@ -66,29 +87,12 @@ class TicketExtra(BaseModel):
refunded: bool = False refunded: bool = False
class CreateTicket(BaseModel):
name: Optional[str] = None
email: Optional[str] = None
user_id: Optional[str] = None
promo_code: str | None = None
refund_address: str | None = None
@root_validator
def validate_identifiers(cls, values):
user_id = values.get("user_id")
name = values.get("name")
email = values.get("email")
if not user_id and not (name and email):
raise ValueError("Either user_id or both name and email must be provided")
return values
class Ticket(BaseModel): class Ticket(BaseModel):
id: str id: str
wallet: str wallet: str
event: str event: str
name: str = "" name: Optional[str] = None
email: str = "" email: Optional[str] = None
user_id: Optional[str] = None user_id: Optional[str] = None
registered: bool registered: bool
paid: bool paid: bool

View file

@ -7,8 +7,11 @@ authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/events" } urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/events" }
dependencies = [ "lnbits>1" ] dependencies = [ "lnbits>1" ]
[dependency-groups] [tool.poetry]
dev = [ package-mode = false
[tool.uv]
dev-dependencies = [
"black", "black",
"pytest-asyncio", "pytest-asyncio",
"pytest", "pytest",
@ -17,9 +20,6 @@ dev = [
"ruff", "ruff",
] ]
[tool.poetry]
package-mode = false
[tool.mypy] [tool.mypy]
exclude = "(nostr/*)" exclude = "(nostr/*)"
plugins = ["pydantic.mypy"] plugins = ["pydantic.mypy"]

View file

@ -21,8 +21,12 @@ async def on_invoice_paid(payment: Payment) -> None:
if not payment.extra or "events" != payment.extra.get("tag"): if not payment.extra or "events" != payment.extra.get("tag"):
return return
if not payment.extra.get("name") or not payment.extra.get("email"): # Check if ticket has either name/email or user_id
logger.warning(f"Ticket {payment.payment_hash} missing name or email.") 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 return
ticket = await get_ticket(payment.payment_hash) ticket = await get_ticket(payment.payment_hash)

108
tests/test_api.py Normal file
View file

@ -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"

2142
uv.lock generated

File diff suppressed because it is too large Load diff

View file

@ -21,13 +21,13 @@ from .crud import (
delete_event, delete_event,
delete_event_tickets, delete_event_tickets,
delete_ticket, delete_ticket,
get_all_events,
get_event, get_event,
get_event_tickets, get_event_tickets,
get_events, get_events,
get_ticket, get_ticket,
get_tickets, get_tickets,
get_tickets_by_user_id, get_tickets_by_user_id,
purge_unpaid_tickets,
update_event, update_event,
update_ticket, update_ticket,
) )
@ -53,8 +53,14 @@ 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 (read-only, no auth required).""" """
return [event.dict() for event in await get_all_events()] 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.post("/api/v1/events")
@ -141,7 +147,7 @@ async def api_tickets(
@events_api_router.get("/api/v1/tickets/user/{user_id}") @events_api_router.get("/api/v1/tickets/user/{user_id}")
async def api_tickets_by_user_id(user_id: str) -> list[Ticket]: async def api_tickets_by_user_id(user_id: str) -> list[Ticket]:
"""Get all tickets for a specific user by their user_id.""" """Get all tickets for a specific user by their user_id"""
return await get_tickets_by_user_id(user_id) return await get_tickets_by_user_id(user_id)
@ -149,6 +155,7 @@ async def api_tickets_by_user_id(user_id: str) -> list[Ticket]:
async def api_ticket_create(event_id: str, data: CreateTicket): async def api_ticket_create(event_id: str, data: CreateTicket):
if data.user_id: if data.user_id:
return await api_ticket_make_ticket_with_user_id(event_id, data.user_id) return await api_ticket_make_ticket_with_user_id(event_id, data.user_id)
else:
promo_code = data.promo_code.upper() if data.promo_code else None promo_code = data.promo_code.upper() if data.promo_code else None
refund_address = data.refund_address refund_address = data.refund_address
return await api_ticket_make_ticket( return await api_ticket_make_ticket(
@ -156,6 +163,49 @@ async def api_ticket_create(event_id: str, data: CreateTicket):
) )
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}") @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, refund_address):
event = await get_event(event_id) event = await get_event(event_id)
@ -212,43 +262,6 @@ async def api_ticket_make_ticket(event_id, name, email, promo_code, refund_addre
return {"payment_hash": payment.payment_hash, "payment_request": payment.bolt11} return {"payment_hash": payment.payment_hash, "payment_request": payment.bolt11}
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":
extra["fiat"] = True
extra["currency"] = event.currency
extra["fiatAmount"] = event.price_per_ticket
extra["rate"] = await get_fiat_rate_satoshis(event.currency)
price = await fiat_amount_as_satoshis(event.price_per_ticket, 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.post("/api/v1/tickets/{event_id}/{payment_hash}") @events_api_router.post("/api/v1/tickets/{event_id}/{payment_hash}")
async def api_ticket_send_ticket(event_id, payment_hash): async def api_ticket_send_ticket(event_id, payment_hash):
event = await get_event(event_id) event = await get_event(event_id)