Adds public events endpoint and user tickets
Some checks are pending
lint / lint (push) Waiting to run
Some checks are pending
lint / lint (push) Waiting to run
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.
This commit is contained in:
parent
c729ef17a6
commit
c669da5822
7 changed files with 377 additions and 20 deletions
56
API_DOCUMENTATION.md
Normal file
56
API_DOCUMENTATION.md
Normal 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 |
|
||||
117
crud.py
117
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
25
models.py
25
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
|
||||
|
|
|
|||
8
tasks.py
8
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)
|
||||
|
|
|
|||
108
tests/test_api.py
Normal file
108
tests/test_api.py
Normal 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"
|
||||
69
views_api.py
69
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}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue