feat: support optional user_id ticket identifier
Add an alternative ticket identifier scheme: instead of (name, email),
external integrations can issue tickets bound to an LNbits user_id.
- m007 adds the user_id column on events.ticket
- CreateTicket validator enforces exactly one identifier scheme per ticket
- Ticket / PublicTicket: name, email, user_id all Optional
- _parse_ticket_row reverses the empty-string sentinel used to keep the
NOT NULL name/email columns satisfied when user_id is the identifier
- POST /tickets/{event_id} dispatches to _create_user_id_ticket vs
_create_named_ticket based on the supplied identifier
- New GET /tickets/user/{user_id} returns tickets for a given user
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4bf867eef0
commit
dfabcb8f54
2 changed files with 102 additions and 18 deletions
97
crud.py
97
crud.py
|
|
@ -1,3 +1,4 @@
|
||||||
|
import json
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from lnbits.db import Database
|
from lnbits.db import Database
|
||||||
|
|
@ -8,47 +9,115 @@ from .models import CreateEvent, Event, Ticket, TicketExtra
|
||||||
db = Database("ext_events")
|
db = Database("ext_events")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ticket_row(row) -> dict:
|
||||||
|
"""Normalize a ticket row before constructing a Ticket model.
|
||||||
|
|
||||||
|
- Empty-string sentinels in name/email (used because the DB columns are
|
||||||
|
NOT NULL but the Pydantic field is Optional when user_id is set) are
|
||||||
|
converted back to None.
|
||||||
|
- The `extra` JSON column may come back as a string when the row is
|
||||||
|
fetched without a model= argument; parse it so Pydantic can build
|
||||||
|
TicketExtra from a dict.
|
||||||
|
"""
|
||||||
|
ticket_data = dict(row)
|
||||||
|
|
||||||
|
if ticket_data.get("name") == "":
|
||||||
|
ticket_data["name"] = None
|
||||||
|
if ticket_data.get("email") == "":
|
||||||
|
ticket_data["email"] = None
|
||||||
|
|
||||||
|
extra = ticket_data.get("extra")
|
||||||
|
if isinstance(extra, str):
|
||||||
|
ticket_data["extra"] = json.loads(extra) if extra else {}
|
||||||
|
|
||||||
|
return ticket_data
|
||||||
|
|
||||||
|
|
||||||
async def create_ticket(
|
async def create_ticket(
|
||||||
payment_hash: str, wallet: str, event: str, name: str, email: str, extra: dict
|
payment_hash: str,
|
||||||
|
wallet: str,
|
||||||
|
event: str,
|
||||||
|
name: str | None = None,
|
||||||
|
email: str | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
extra: dict | None = None,
|
||||||
) -> Ticket:
|
) -> Ticket:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
ticket = Ticket(
|
|
||||||
|
# name/email columns are NOT NULL in the schema, so we store "" when only
|
||||||
|
# user_id is supplied. _parse_ticket_row reverses this on read.
|
||||||
|
if user_id:
|
||||||
|
db_name = ""
|
||||||
|
db_email = ""
|
||||||
|
else:
|
||||||
|
db_name = name or ""
|
||||||
|
db_email = email or ""
|
||||||
|
|
||||||
|
db_ticket = Ticket(
|
||||||
id=payment_hash,
|
id=payment_hash,
|
||||||
wallet=wallet,
|
wallet=wallet,
|
||||||
event=event,
|
event=event,
|
||||||
name=name,
|
name=db_name,
|
||||||
email=email,
|
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(
|
||||||
|
id=payment_hash,
|
||||||
|
wallet=wallet,
|
||||||
|
event=event,
|
||||||
|
name=name,
|
||||||
|
email=email,
|
||||||
|
user_id=user_id,
|
||||||
registered=False,
|
registered=False,
|
||||||
paid=False,
|
paid=False,
|
||||||
reg_timestamp=now,
|
reg_timestamp=now,
|
||||||
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)
|
ticket_dict = ticket.dict()
|
||||||
|
if ticket_dict.get("name") is None:
|
||||||
|
ticket_dict["name"] = ""
|
||||||
|
if ticket_dict.get("email") is None:
|
||||||
|
ticket_dict["email"] = ""
|
||||||
|
await db.update("events.ticket", Ticket(**ticket_dict))
|
||||||
return ticket
|
return ticket
|
||||||
|
|
||||||
|
|
||||||
async def get_ticket(payment_hash: str) -> Ticket | None:
|
async def get_ticket(payment_hash: str) -> Ticket | None:
|
||||||
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})",
|
return [Ticket(**_parse_ticket_row(row)) for row in rows]
|
||||||
model=Ticket,
|
|
||||||
|
|
||||||
|
async def get_tickets_by_user_id(user_id: str) -> list[Ticket]:
|
||||||
|
"""All tickets owned by the given LNbits 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:
|
||||||
|
|
@ -107,8 +176,8 @@ async def delete_event(event_id: str) -> None:
|
||||||
|
|
||||||
|
|
||||||
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]
|
||||||
|
|
|
||||||
27
models.py
27
models.py
|
|
@ -1,7 +1,7 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import Query
|
from fastapi import Query
|
||||||
from pydantic import BaseModel, EmailStr, Field, validator
|
from pydantic import BaseModel, EmailStr, Field, root_validator, validator
|
||||||
|
|
||||||
|
|
||||||
class PromoCode(BaseModel):
|
class PromoCode(BaseModel):
|
||||||
|
|
@ -94,21 +94,36 @@ class TicketExtra(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class CreateTicket(BaseModel):
|
class CreateTicket(BaseModel):
|
||||||
name: str
|
name: str | None = None
|
||||||
email: EmailStr
|
email: EmailStr | None = None
|
||||||
|
user_id: str | None = None # LNbits user id (alternative to name+email)
|
||||||
promo_code: str | None = None
|
promo_code: str | None = None
|
||||||
refund_address: str | None = None
|
refund_address: str | None = None
|
||||||
nostr_identifier: str | None = None
|
nostr_identifier: str | None = None
|
||||||
payment_method: str | None = None
|
payment_method: str | None = None
|
||||||
fiat_provider: str | None = None
|
fiat_provider: str | None = None
|
||||||
|
|
||||||
|
@root_validator
|
||||||
|
def validate_identifiers(cls, values):
|
||||||
|
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 Ticket(BaseModel):
|
class Ticket(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
wallet: str
|
wallet: str
|
||||||
event: str
|
event: str
|
||||||
name: str
|
name: str | None = None
|
||||||
email: str
|
email: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
registered: bool
|
registered: bool
|
||||||
paid: bool
|
paid: bool
|
||||||
time: datetime
|
time: datetime
|
||||||
|
|
@ -118,7 +133,7 @@ class Ticket(BaseModel):
|
||||||
|
|
||||||
class PublicTicket(BaseModel):
|
class PublicTicket(BaseModel):
|
||||||
event: str
|
event: str
|
||||||
name: str
|
name: str | None = None
|
||||||
registered: bool
|
registered: bool
|
||||||
paid: bool
|
paid: bool
|
||||||
time: datetime
|
time: datetime
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue