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}")