Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@

scheduled_tasks: list[asyncio.Task] = []

# Module-level NostrClient — None when nostrclient is unavailable. Set by the
# bootstrap task in events_start() and read via dynamic attribute lookup
# from nostr_hooks.publish_or_delete_nostr_event.
nostr_client = None


def events_stop():
for task in scheduled_tasks:
Expand All @@ -30,12 +35,48 @@ def events_stop():
except Exception as ex:
logger.warning(ex)

global nostr_client
if nostr_client:
asyncio.get_event_loop().create_task(nostr_client.stop())


def events_start():
from lnbits.tasks import create_permanent_unique_task

task = create_permanent_unique_task("ext_events", wait_for_paid_invoices)
scheduled_tasks.append(task)
task1 = create_permanent_unique_task("ext_events", wait_for_paid_invoices)
scheduled_tasks.append(task1)

async def _start_nostr_client():
global nostr_client
await asyncio.sleep(10) # Wait for nostrclient to be ready
try:
from .nostr.nostr_client import NostrClient

nostr_client = NostrClient()
logger.info("[EVENTS] Starting NostrClient for NIP-52 sync")
await nostr_client.run_forever()
except Exception as exc:
logger.warning(f"[EVENTS] NostrClient failed to start: {exc}")
logger.info("[EVENTS] Events will work without Nostr sync")

task2 = create_permanent_unique_task("ext_events_nostr", _start_nostr_client)
scheduled_tasks.append(task2)

async def _sync_nostr_events():
global nostr_client
await asyncio.sleep(15) # Wait for NostrClient to connect
if not nostr_client:
logger.info("[EVENTS] No NostrClient, skipping Nostr sync")
return
try:
from .nostr_sync import wait_for_nostr_events

await wait_for_nostr_events(nostr_client)
except Exception as exc:
logger.error(f"[EVENTS] Nostr sync task failed: {exc}")

task3 = create_permanent_unique_task("ext_events_nostr_sync", _sync_nostr_events)
scheduled_tasks.append(task3)


__all__ = ["db", "events_ext", "events_start", "events_static_files", "events_stop"]
146 changes: 133 additions & 13 deletions crud.py
Original file line number Diff line number Diff line change
@@ -1,54 +1,125 @@
import json
from datetime import datetime, timedelta, timezone

from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash

from .models import CreateEvent, Event, Ticket, TicketExtra
from .models import CreateEvent, Event, EventsSettings, Ticket, TicketExtra

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(
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:
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,
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(
id=payment_hash,
wallet=wallet,
event=event,
name=name,
email=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", ticket)
return 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


async def get_ticket(payment_hash: str) -> Ticket | None:
return await db.fetchone(
row: dict | None = await db.fetchone(
"SELECT * FROM events.ticket WHERE id = :id",
{"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]:
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: list[dict] = await db.fetchall(
f"SELECT * FROM events.ticket WHERE wallet IN ({q})"
)
return [Ticket(**_parse_ticket_row(row)) for row in rows]


async def get_tickets_by_user_id(user_id: str) -> list[Ticket]:
"""All tickets owned by the given LNbits user_id."""
rows: list[dict] = 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:
Expand All @@ -74,6 +145,11 @@ async def purge_unpaid_tickets(event_id: str) -> None:

async def create_event(data: CreateEvent) -> Event:
event_id = urlsafe_short_hash()
# Default end_date to start_date and closing_date to end_date when omitted.
if not data.event_end_date:
data.event_end_date = data.event_start_date
if not data.closing_date:
data.closing_date = data.event_end_date
event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict())
await db.insert("events.events", event)
return event
Expand Down Expand Up @@ -102,13 +178,57 @@ async def get_events(wallet_ids: str | list[str]) -> list[Event]:
)


async def get_all_events() -> list[Event]:
"""All events, no wallet filter. Admin-only callers."""
return await db.fetchall(
"SELECT * FROM events.events ORDER BY time DESC",
model=Event,
)


async def get_public_events() -> list[Event]:
"""Approved, non-canceled events for the public listing."""
return await db.fetchall(
"""
SELECT * FROM events.events
WHERE status = 'approved' AND canceled = FALSE
ORDER BY event_start_date ASC
""",
model=Event,
)


async def get_pending_events() -> list[Event]:
"""Proposed events awaiting admin approval."""
return await db.fetchall(
"SELECT * FROM events.events WHERE status = 'proposed' ORDER BY time DESC",
model=Event,
)


async def get_settings() -> EventsSettings:
"""Singleton settings row, seeded by m010."""
row: dict | None = await db.fetchone("SELECT * FROM events.settings WHERE id = 1")
if row:
return EventsSettings(**dict(row))
return EventsSettings()


async def update_settings(settings: EventsSettings) -> EventsSettings:
await db.execute(
"UPDATE events.settings SET auto_approve = :auto_approve WHERE id = 1",
{"auto_approve": settings.auto_approve},
)
return settings


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: list[dict] = await db.fetchall(
"SELECT * FROM events.ticket WHERE event = :event",
{"event": event_id},
Ticket,
)
return [Ticket(**_parse_ticket_row(row)) for row in rows]
90 changes: 85 additions & 5 deletions migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,16 +162,96 @@ async def m005_add_image_banner(db):
await db.execute("ALTER TABLE events.events ADD COLUMN banner TEXT;")


async def _alter_add_column_safe(db, sql: str) -> None:
"""ALTER TABLE ADD COLUMN that swallows duplicate-column errors.

Earlier aiolabs/events forks added some of these columns under different
migration names (e.g. our former m007). Skipping the error keeps the
migration log monotonic for both fresh installs and pre-rebase upgrades.
"""
try:
await db.execute(sql)
except Exception as exc:
msg = str(exc).lower()
if "duplicate column" in msg or "already exists" in msg:
return
raise


async def m006_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 _alter_add_column_safe(
db,
"ALTER TABLE events.events ADD COLUMN canceled BOOLEAN NOT NULL DEFAULT FALSE",
)
await _alter_add_column_safe(db, "ALTER TABLE events.events ADD COLUMN extra TEXT")
await _alter_add_column_safe(db, "ALTER TABLE events.ticket ADD COLUMN extra TEXT")


async def m007_add_user_id_support(db):
"""
Add user_id column to ticket table so a ticket can reference an LNbits
user id instead of (name, email). Application logic enforces that exactly
one identifier scheme is used per ticket.
"""
await _alter_add_column_safe(
db, "ALTER TABLE events.ticket ADD COLUMN user_id TEXT"
)


async def m008_add_event_status(db):
"""
Add status column to events table for the proposal/approval workflow.
Values: 'proposed', 'approved', 'rejected'. Existing rows default to
'approved' so they stay visible after upgrade.
"""
await _alter_add_column_safe(
db,
"ALTER TABLE events.events ADD COLUMN status TEXT NOT NULL DEFAULT 'approved'",
)


async def m009_add_nostr_columns(db):
"""
Track the most recent NIP-52 calendar event we published for this event
(used for replaceable updates and NIP-09 deletes).
"""
await _alter_add_column_safe(
db, "ALTER TABLE events.events ADD COLUMN nostr_event_id TEXT"
)
await _alter_add_column_safe(
db, "ALTER TABLE events.events ADD COLUMN nostr_event_created_at INTEGER"
)


async def m010_add_events_settings(db):
"""
Create the extension settings singleton row used by the admin UI to
toggle e.g. auto_approve.
"""
await db.execute("""
CREATE TABLE IF NOT EXISTS events.settings (
id INTEGER PRIMARY KEY DEFAULT 1,
auto_approve BOOLEAN NOT NULL DEFAULT FALSE
)
""")
await db.execute(
"ALTER TABLE events.events ADD COLUMN canceled BOOLEAN NOT NULL DEFAULT FALSE;"
"INSERT INTO events.settings (id, auto_approve) "
"SELECT 1, FALSE WHERE NOT EXISTS "
"(SELECT 1 FROM events.settings WHERE id = 1)"
)
await db.execute("ALTER TABLE events.events ADD COLUMN extra TEXT;")

# Add 'extra' column to ticket table
await db.execute("ALTER TABLE events.ticket ADD COLUMN extra TEXT;")

async def m011_add_location_and_categories(db):
"""
Add NIP-52 calendar metadata (location and a JSON-encoded category list).
"""
await _alter_add_column_safe(
db, "ALTER TABLE events.events ADD COLUMN location TEXT"
)
await _alter_add_column_safe(
db, "ALTER TABLE events.events ADD COLUMN categories TEXT"
)
Loading
Loading