diff --git a/.gitignore b/.gitignore index e31da20..0152b6e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,3 @@ __pycache__ node_modules .mypy_cache .venv - -# lnbits data dir created by `make test` (settings default lnbits_data_folder) -data/ diff --git a/Makefile b/Makefile index d7e2d37..0fac253 100644 --- a/Makefile +++ b/Makefile @@ -30,15 +30,10 @@ checkblack: checkeditorconfig: editorconfig-checker -# The uv env resolves *upstream* lnbits from PyPI, which lacks the aio fork's -# modules (lnbits.core.signers, …). Point PYTHONPATH at a fork checkout so -# `import lnbits` picks it up; override with LNBITS_SRC=/path/to/lnbits. -LNBITS_SRC ?= $(HOME)/dev/lnbits/dev test: - PYTHONPATH=$(LNBITS_SRC) \ PYTHONUNBUFFERED=1 \ DEBUG=true \ - uv run --frozen pytest + uv run pytest install-pre-commit-hook: @echo "Installing pre-commit hook to git" @echo "Uninstall the hook with uv run pre-commit uninstall" diff --git a/README.md b/README.md index 4ac72c5..15748c7 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Events includes a shareable ticket scanner, which can be used to register attend 1. Create an event\ ![create event](https://i.imgur.com/dadK1dp.jpg) 2. Fill out the event information: + - event name - wallet (normally there's only one) - event information @@ -33,6 +34,7 @@ Events includes a shareable ticket scanner, which can be used to register attend 3. Share the event registration link\ ![event ticket](https://imgur.com/AQWUOBY.jpg) + - ticket example\ ![ticket example](https://i.imgur.com/trAVSLd.jpg) @@ -42,41 +44,6 @@ 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\ ![ticket scanner](https://i.imgur.com/zrm9202.jpg) -## Guest checkout, card payments and email delivery (aio fork) - -- **Identity.** `POST /events/api/v1/tickets/{event_id}` accepts either an - LNbits `user_id` or a guest `name` + `email`; a `user_id` ticket may also - carry an `email` so logged-in buyers get their ticket mailed. -- **Payment methods.** `extra.payment_methods` (`lightning`, `fiat`) lists the - rails an event accepts; an empty list keeps the legacy rule (Lightning always, - fiat when `allow_fiat`). The effective list is published on the NIP-52 event - as `tickets_payment_methods` and enforced at purchase. -- **Return to the calling app.** A client may send `frontend_url` (its app - root, e.g. `https://app.example/events`). Its origin must be one of - `LNBITS_CORS_ALLOWED_ORIGINS`, the LNbits base URL or - `LNBITS_CUSTOM_FRONTEND_URL`, otherwise the request is refused. Under that - root the extension builds the Stripe `success_url` - (`/events/{event_id}?checkout=success&tickets=`), `cancel_url` - (`/events/{event_id}?checkout=cancelled`) and the emailed ticket link - (`/events/ticket/{ticket_id}`). Absent, the LNbits host is used as before. -- **Stripe session.** The buyer's email is passed as `customer_email` - (prefilled and locked on the hosted page); the line item is named after the - event; `event_id`, `quantity` and `ticket_ids` ride along as metadata. -- **Promo codes.** `extra.promo_codes` (`code`, `discount_percent`, `active`, - `max_uses`; `used_count` is derived from paid tickets) are organizer-only: they are - never part of public responses. Buyers preview a code with - `POST /events/api/v1/promo/validate/{event_id}` (`{codes, quantity}` → v2-shaped - `BasketTotals` + `currency`); purchase enforces `active` and `max_uses` (each ticket - of a multi-ticket purchase consumes one use) and rejects bad codes with a distinct - `detail`. Updates that omit `extra.promo_codes` keep the stored list. -- **Email.** Multipart text + HTML (links, no images) with the **ticket card** - attached — a self-describing PNG (site, event, when, where, QR with the - instance logo, name on ticket, ticket id) also served at - `GET /events/api/v1/ticket-card/{ticket_id}`; the bare QR stays at - `GET /events/api/v1/qr/{ticket_id}`. Headers carry Date, Message-ID and a - From display name (site title). `POST /events/api/v1/tickets/{ticket_id}/resend-email` - returns a `TicketResendResult` with per-channel outcome. - ## Powered by LNbits [LNbits](https://lnbits.com) is a free and open-source lightning accounts system. diff --git a/__init__.py b/__init__.py index e443ce0..01b145e 100644 --- a/__init__.py +++ b/__init__.py @@ -6,19 +6,12 @@ from loguru import logger from .crud import db from .tasks import wait_for_paid_invoices from .views import events_generic_router -from .views_api import ( - events_api_router, - promo_api_router, - qr_api_router, - tickets_api_router, -) +from .views_api import events_api_router, tickets_api_router events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"]) events_ext.include_router(events_generic_router) events_ext.include_router(events_api_router) events_ext.include_router(tickets_api_router) -events_ext.include_router(qr_api_router) -events_ext.include_router(promo_api_router) events_static_files = [ { @@ -34,15 +27,6 @@ scheduled_tasks: list[asyncio.Task] = [] # from nostr_hooks.publish_or_delete_nostr_event. nostr_client = None -# Reconciliation sweep for NIP-52 publishes that never reached a relay -# (aiolabs/events#35). Five minutes is well under the window in which a -# stale ticket count matters to a buyer, and the query costs nothing -# when there is no drift — the normal case returns zero rows. -REPUBLISH_SWEEP_INTERVAL = 300 -# Long enough for _start_nostr_client's own 10s wait plus the relay -# handshake, so the first pass isn't guaranteed to fail on a cold boot. -REPUBLISH_SWEEP_FIRST_DELAY = 60 - def events_stop(): for task in scheduled_tasks: @@ -126,55 +110,5 @@ def events_start(): task3 = create_permanent_unique_task("ext_events_nostr_sync", _sync_nostr_events) scheduled_tasks.append(task3) - async def _republish_pending_sweep(): - """Retry NIP-52 publishes that never landed. - - Inventory reaches clients only through the republished calendar - event, and a publish can fail (signer outage) or be skipped - entirely (no signer, no NostrClient) without anything noticing. - Both leave `nostr_publish_pending` set, so this sweep retries - from the DB rather than from an in-memory queue — it survives a - restart, which the previous behaviour did not. - - Quiet by design: on a healthy instance the query returns nothing - and this logs nothing. It only speaks up when there is drift. - """ - from .crud import flag_wave_transitions, get_events_pending_republish - from .nostr_hooks import publish_or_delete_nostr_event - - await asyncio.sleep(REPUBLISH_SWEEP_FIRST_DELAY) - while True: - try: - # Wave boundaries are time-driven, so nothing else flags - # them. Done here rather than on a timer of its own: the - # boundary is day-granular, so one sweep interval of - # staleness is immaterial (aiolabs/events#61). - moved = await flag_wave_transitions() - if moved: - logger.info( - f"[EVENTS] Republish sweep: {moved} event(s) changed " - f"ticket wave" - ) - pending = await get_events_pending_republish() - if pending: - total = len(pending) - logger.info(f"[EVENTS] Republish sweep: {total} event(s) pending") - recovered = 0 - for event in pending: - take_down = event.canceled or event.status != "approved" - if await publish_or_delete_nostr_event(event, delete=take_down): - recovered += 1 - logger.info( - f"[EVENTS] Republish sweep: {recovered}/{total} recovered" - ) - except Exception as exc: - logger.error(f"[EVENTS] Republish sweep failed: {exc}") - await asyncio.sleep(REPUBLISH_SWEEP_INTERVAL) - - task4 = create_permanent_unique_task( - "ext_events_republish_sweep", _republish_pending_sweep - ) - scheduled_tasks.append(task4) - __all__ = ["db", "events_ext", "events_start", "events_static_files", "events_stop"] diff --git a/config.json b/config.json index f7e0ec1..57a7f75 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "id": "events", - "version": "1.6.8-aio.4", + "version": "1.6.1-aio.1", "name": "Events", "repo": "https://git.atitlan.io/aiolabs/events", "short_description": "Sell and register event tickets", diff --git a/crud.py b/crud.py index 52ce233..551a3bc 100644 --- a/crud.py +++ b/crud.py @@ -1,20 +1,10 @@ import json from datetime import datetime, timedelta, timezone -from typing import cast -from lnbits.db import Database, Filters, Page +from lnbits.db import Database from lnbits.helpers import urlsafe_short_hash -from .models import ( - CreateEvent, - Event, - EventsSettings, - Ticket, - TicketExtra, - TicketFilters, - advertised_wave_key, - sync_event_ticket_waves, -) +from .models import CreateEvent, Event, EventsSettings, Ticket, TicketExtra db = Database("ext_events") @@ -65,12 +55,14 @@ async def create_ticket( now = datetime.now(timezone.utc) row_id = ticket_id or payment_hash - # name/email columns are NOT NULL in the schema, so we store "" when a - # value is absent. _parse_ticket_row reverses this on read. A user_id - # ticket may carry an email too — that is how logged-in webapp buyers get - # their ticket emailed. - db_name = name or "" - db_email = email or "" + # 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=row_id, @@ -165,31 +157,6 @@ async def get_tickets_by_user_id(user_id: str) -> list[Ticket]: return [Ticket(**_parse_ticket_row(row)) for row in rows] -async def get_tickets_paginated( - wallet_ids: str | list[str], filters: Filters[TicketFilters] | None = None -) -> Page[Ticket]: - if isinstance(wallet_ids, str): - wallet_ids = [wallet_ids] - - wallet_where = [] - values = {} - for idx, wallet_id in enumerate(wallet_ids): - key = f"wallet_id_{idx}" - wallet_where.append(f":{key}") - values[key] = wallet_id - - where = [f"wallet IN ({', '.join(wallet_where)})", "paid = true"] - - return await db.fetch_page( - "SELECT * FROM events.ticket", - where=where, - values=values, - filters=filters, - model=Ticket, - table_name="events.ticket", - ) - - async def delete_ticket(payment_hash: str) -> None: await db.execute("DELETE FROM events.ticket WHERE id = :id", {"id": payment_hash}) @@ -219,55 +186,44 @@ async def create_event(data: CreateEvent) -> Event: if not data.closing_date: data.closing_date = data.event_end_date event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict()) - event = cast(Event, sync_event_ticket_waves(event)) await db.insert("events.events", event) return event async def update_event(event: Event) -> Event: - event = cast(Event, sync_event_ticket_waves(event)) await db.update("events.events", event) return event async def get_event(event_id: str) -> Event | None: - event = await db.fetchone( + return await db.fetchone( "SELECT * FROM events.events WHERE id = :id", {"id": event_id}, Event, ) - return cast(Event, sync_event_ticket_waves(event)) if event else None async def get_events(wallet_ids: str | list[str]) -> list[Event]: if isinstance(wallet_ids, str): wallet_ids = [wallet_ids] q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids]) - events = await db.fetchall( + return await db.fetchall( f"SELECT * FROM events.events WHERE wallet IN ({q})", model=Event, ) - return [cast(Event, sync_event_ticket_waves(event)) for event in events] async def get_all_events() -> list[Event]: """All events, no wallet filter. Admin-only callers.""" - events = await db.fetchall( + return await db.fetchall( "SELECT * FROM events.events ORDER BY time DESC", model=Event, ) - # Wave-sync on read, exactly as upstream's `get_event` / `get_events` - # do. These four getters are fork-only, so upstream's v1.6.8 diff - # never reached them — and two of them publish: `get_all_events` - # backs /republish-all and `get_events_pending_republish` drives the - # #55 sweep. Without this they would emit the stale roll-up rather - # than the current per-wave figures. - return [cast(Event, sync_event_ticket_waves(e)) for e in events] async def get_public_events() -> list[Event]: """Approved, non-canceled events for the public listing.""" - events = await db.fetchall( + return await db.fetchall( """ SELECT * FROM events.events WHERE status = 'approved' AND canceled = FALSE @@ -275,97 +231,14 @@ async def get_public_events() -> list[Event]: """, model=Event, ) - # Wave-sync on read, exactly as upstream's `get_event` / `get_events` - # do. These four getters are fork-only, so upstream's v1.6.8 diff - # never reached them — and two of them publish: `get_all_events` - # backs /republish-all and `get_events_pending_republish` drives the - # #55 sweep. Without this they would emit the stale roll-up rather - # than the current per-wave figures. - return [cast(Event, sync_event_ticket_waves(e)) for e in events] async def get_pending_events() -> list[Event]: """Proposed events awaiting admin approval.""" - events = await db.fetchall( + return await db.fetchall( "SELECT * FROM events.events WHERE status = 'proposed' ORDER BY time DESC", model=Event, ) - # Wave-sync on read, exactly as upstream's `get_event` / `get_events` - # do. These four getters are fork-only, so upstream's v1.6.8 diff - # never reached them — and two of them publish: `get_all_events` - # backs /republish-all and `get_events_pending_republish` drives the - # #55 sweep. Without this they would emit the stale roll-up rather - # than the current per-wave figures. - return [cast(Event, sync_event_ticket_waves(e)) for e in events] - - -async def flag_wave_transitions() -> int: - """Flag events whose advertised ticket wave has moved on. - - Every republish this extension performs is *sale*-driven. A wave - boundary is a *date* boundary, so when early bird closes at midnight - nothing fires and the relay keeps serving the closed wave's price until - the next ticket happens to sell (aiolabs/events#61). - - Rather than add a scheduler and a second publish path, this compares the - wave a row would advertise now against the one its last successful - publish did (`nostr_published_wave_id`) and sets `nostr_publish_pending` - on a mismatch — handing the work to the existing reconciliation sweep, - which already retries, survives restarts and logs. - - Rows with NULL `nostr_published_wave_id` are skipped: that means "never - published, or published before the column existed", which is no evidence - of drift. Flagging them would republish the whole table on first boot - after the upgrade. - - Returns the number of rows newly flagged. - """ - events = await db.fetchall( - """ - SELECT * FROM events.events - WHERE nostr_published_wave_id IS NOT NULL - AND nostr_publish_pending = FALSE - AND canceled = FALSE - AND status = 'approved' - """, - model=Event, - ) - flagged = 0 - for event in events: - event = cast(Event, sync_event_ticket_waves(event)) - if advertised_wave_key(event) == event.nostr_published_wave_id: - continue - event.nostr_publish_pending = True - await update_event(event) - flagged += 1 - return flagged - - -async def get_events_pending_republish() -> list[Event]: - """Events whose relay copy may be behind this row. - - `nostr_publish_pending` is set before every publish attempt and - cleared only on a confirmed success, so a row still flagged here - either failed to publish or never got the chance. Drives the - reconciliation sweep in `events_start`. - - Ordered oldest-first so a backlog drains in the order it accrued. - """ - events = await db.fetchall( - """ - SELECT * FROM events.events - WHERE nostr_publish_pending = TRUE - ORDER BY time ASC - """, - model=Event, - ) - # Wave-sync on read, exactly as upstream's `get_event` / `get_events` - # do. These four getters are fork-only, so upstream's v1.6.8 diff - # never reached them — and two of them publish: `get_all_events` - # backs /republish-all and `get_events_pending_republish` drives the - # #55 sweep. Without this they would emit the stale roll-up rather - # than the current per-wave figures. - return [cast(Event, sync_event_ticket_waves(e)) for e in events] async def get_settings() -> EventsSettings: diff --git a/docs/rebase-playbook.md b/docs/rebase-playbook.md deleted file mode 100644 index affbce7..0000000 --- a/docs/rebase-playbook.md +++ /dev/null @@ -1,173 +0,0 @@ -# Rebase playbook - -How to merge an upstream release into this fork without shipping the -failures a clean `git merge` cannot see. - -Written during the v1.6.1 → v1.6.8 rebase (#33) after the first two -instances showed up within minutes of each other. Both were textually -clean merges that would have been semantically wrong in production. -Modes C and D were added later in the same rebase, from `services.py`. - -## The four failure modes - -Git resolves _text_. Neither of these produces a conflict marker. - -### A. Missed application - -Upstream establishes an invariant and applies it at every call site **it -knows about**. The fork has additional call sites upstream cannot see, -so the invariant silently does not hold there. - -> **Worked example.** v1.6.8 made `event.amount_tickets` a per-wave -> roll-up recomputed by `sync_event_ticket_waves`, and added that call to -> `get_event` and `get_events` in `crud.py`. Both merged cleanly. But the -> fork has four getters upstream never had — `get_all_events`, -> `get_public_events`, `get_pending_events`, -> `get_events_pending_republish` — and two of them _publish_ -> (`/republish-all` and the #55 sweep). Without the same call they emit -> the stale roll-up, so waves would have been wrong on exactly the paths -> that push to relays, and nowhere else. - -### B. Changed meaning - -Upstream redefines what an existing field _means_. Fork code that reads -it is untouched by the diff and keeps compiling, while now saying -something false. - -> **Worked example.** After `sync_event_ticket_waves`, -> `event.price_per_ticket` is the **primary (first)** wave's price and -> `event.amount_tickets` is the **sum across all waves**. Our -> `nostr_publisher.build_nip52_event` reads both. Merged untouched, it -> would advertise the early-bird price after early-bird closed, and count -> stock in waves that have not opened. See #61. - -### C. Misplaced conflict boundary - -Git anchors a conflict on whatever lines happen to match. When both sides -rewrote the same region, an _incidental_ shared line inside it can become -the anchor — and everything past that line lands **outside** the markers, -where it reads as cleanly merged. - -Resolving only what sits between the markers then leaves **both** -implementations in the file. Python does not complain: the later `def` -silently wins. Since the merge appends upstream after ours, the survivor -is upstream's — the fork's version is shadowed without a single warning. - -> **Worked example.** In `services.py` the notification stack conflicted. -> Ours (220 lines: multipart HTML mail, the QR-card attachment, the -> Date/Message-ID headers that keep SpamAssassin quiet, npub DM support) -> appeared between the markers; upstream's showed as 4 lines. But both -> sides define the _same nine functions_, and git had anchored on a -> shared `_send_nostr_ticket_notification` line — so upstream's entire -> parallel stack sat below `>>>>>>>`, looking merged. Taking "ours" and -> moving on would have left upstream's definitions last in the file and -> therefore live, quietly reverting every one of those features. - -**Do not trust the marker as the edit's boundary.** Before resolving a -hunk, list the function names on each side and compare them to the names -already in the file: - -```sh -grep -o '^\(async \)\?def \w*' .py | sed 's/.*def //' | sort | uniq -d -``` - -Run that per file **as you resolve**, not at the end. `ruff` does not -flag redefinition (verified: F ruleset passes on a duplicated `def`). -Only `mypy` does, via `no-redef` — and mypy refuses to run at all while -any file in the package still has conflict markers, so the one tool that -catches mode C is unavailable for the whole merge. The `uniq -d` line -above is the substitute. - -### D. Collided names - -Ours and upstream independently grew a function with the **same name for -a different feature**. Every resolution that reads as sane — take ours, -take theirs, take "the newer one" — silently deletes a feature, and the -diff looks like an ordinary reconciliation of one function. - -> **Worked example.** Both sides had `_ticket_image_url`. Ours: the -> rendered QR ticket-card PNG, always attached, served by this extension -> off `lnbits_baseurl`. Upstream's: an organiser-uploaded template, opt-in -> per wave via `use_ticket_image`, served off `ticket_base_url` and -> returning `None` when the wave has not enabled it. Same name, different -> arity, different return type, unrelated features. Resolved by renaming -> ours to `_ticket_card_url` and keeping both — upstream's ticket-image -> upload UI had already merged into `index.vue`, so dropping their backend -> would have orphaned live UI. - -Signals worth stopping on: the two versions differ in **arity**, in -**return type** (`str` vs `str | None`), or in which setting they build a -URL from. Any of those means it is probably not one function with two -histories. - -## The procedure - -Run this _after_ the merge resolves and _before_ the release. - -### 1. Enumerate what upstream introduced - -```sh -MB=$(git merge-base HEAD upstream/main) -# new public names -git diff $MB.. -- '*.py' | grep -E "^\+(def |class |async def )" -# fields whose meaning was redefined -git show :models.py | sed -n '/^def sync_event_ticket_waves/,/return event/p' -``` - -Split the result into **new symbols** (mode A candidates) and -**redefined fields** (mode B candidates). - -### 2. For each new symbol upstream _calls_, find the fork-only siblings - -Ask what category of place the call belongs to — "every function that -returns an `Event` from the DB", "every path that prices a ticket" — then -enumerate that whole category in the merged tree and check coverage. - -```sh -grep -n "sync_event_ticket_waves" crud.py # where upstream put it -grep -n "^async def get_.*-> \(list\[\)\?Event" crud.py # where it belongs -``` - -The gap between those two lists is the work. - -### 3. For each redefined field, grep the fork-only files - -The 30-odd files upstream has never seen are where mode B hides, because -nothing in the diff touches them: - -```sh -git diff --name-only $MB..HEAD > /tmp/fork.txt -git diff --name-only $MB.. > /tmp/up.txt -comm -23 <(sort /tmp/fork.txt) <(sort /tmp/up.txt) # fork-only files -grep -n "price_per_ticket\|amount_tickets" $(comm -23 ...) -``` - -### 4. Prove each finding before fixing it - -Both examples above were confirmed by reading the code path end to end, -not inferred from the diff. A wrong theory costs more than the check: -during this rebase an inference that `amount_tickets` "goes stale on -sale" was wrong — `sync_event_ticket_waves` also runs on _reads_, which -only the call-site list showed. - -### 5. Write the reason at the site - -Every fix from this procedure gets a comment saying **why upstream's diff -missed it**. That is what stops the next rebase re-dropping it, and it is -the only durable record that the omission was considered rather than -overlooked. - -## Checklist - -- [ ] `migrations.py` still byte-identical to upstream -- [ ] New upstream symbols enumerated; each call-site category audited -- [ ] Redefined fields enumerated; every fork-only reader checked -- [ ] Fork-only files listed and grepped for both modes -- [ ] Every resolved file checked for duplicate `def`s (mode C) — as it is - resolved, since mypy cannot run until the whole package is clean -- [ ] Same-named functions on both sides compared by arity and return type - before being treated as one function (mode D) -- [ ] Publishing paths specifically audited — they fail silently and - externally, so they are the worst place for either mode to land -- [ ] Every fix carries a comment explaining the omission -- [ ] Deviations recorded in `docs/upstream-candidates.md` diff --git a/docs/upstream-candidates.md b/docs/upstream-candidates.md deleted file mode 100644 index f2ceb98..0000000 --- a/docs/upstream-candidates.md +++ /dev/null @@ -1,21 +0,0 @@ -# Upstream PR candidates - -Running log of fork features that are shaped so they could be offered to -`lnbits/events` (or `lnbits/lnbits`). Add a row whenever a change lands here -in an upstream-compatible form; strike it when the PR merges upstream. - -| Feature | Where | Upstream target | Readiness | -| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR | -| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above | -| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch | -| `extra.payment_methods` per event + `tickets_payment_methods` NIP-52 tag | `models.py`, `nostr_publisher.py` | lnbits/events (v2 PR #64 introduces the same field) | offer as review input on #64 | -| `asyncio.to_thread` around the smtplib send | `services.py` `_send_ticket_email_notification` | lnbits/events | trivial, standalone | -| QR logo overlay in `make_qr_png` (instance `lnbits_qr_logo`) | `views_api.py` | lnbits/events | standalone | -| Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 | -| Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone | -| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 | -| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 | -| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) | -| Promo `max_uses` + derived `used_count` (per ticket; v2 counts per basket), `POST /promo/validate/{event_id}` with `quantity` instead of `items`, `PublicEventExtra` projection (v2 still exposes `extra` fully) | `promo.py`, `models.py`, `views_api.py` | lnbits/events (v2 PR #64) | review input on #64 | -| NIP-52 tags describe the **active** ticket wave (cheapest open wave; `tickets_available: 0` when none is open), plus `nostr_published_wave_id` so the reconciliation sweep republishes at wave boundaries | `nostr_publisher.py`, `crud.py` `flag_wave_transitions`, `migrations_fork.py` m004 | lnbits/events #46 (rides with the NIP-52 publishing PR) | **deviation, deliberate** — upstream has waves but publishes no calendar event, so there is nothing upstream to match. Several waves can be open at once and a publisher cannot ask which one the buyer wants (upstream's purchase path errors with "Please select a ticket wave"), so it advertises the cheapest — the price a buyer can actually obtain. Rationale and the rejected alternatives: aiolabs/events#61 | diff --git a/migrations_fork.py b/migrations_fork.py index cfab24d..864cbb8 100644 --- a/migrations_fork.py +++ b/migrations_fork.py @@ -128,60 +128,3 @@ async def m002_ticket_payment_hash(db): "WHERE payment_hash IS NULL OR payment_hash = ''" ) - -async def m003_event_nostr_publish_pending(db): - """ - Add `events.nostr_publish_pending` — the marker that makes NIP-52 - publish drift queryable instead of invisible. - - Inventory reaches clients only through the republished calendar - event. When that publish doesn't land, the relay keeps serving the - counts it last saw and nothing anywhere records the divergence; it - has twice been caught only by a human reading a public page - (aiolabs/events#35, #51). - - The flag is set before each publish attempt and cleared only on a - confirmed success, so it covers *both* observed failure shapes: - an attempt that raised (a signer outage) and an attempt that was - never made at all (no signer resolved, no NostrClient). A periodic - sweep republishes whatever is still marked. - - Existing rows default to FALSE rather than TRUE: on upgrade we have - no evidence they're stale, and marking the whole table pending would - stampede the signer with a full-table republish on first boot. - `/republish-all` is the deliberate way to force that. - """ - await _alter_add_column_safe( - db, - "ALTER TABLE events.events " - "ADD COLUMN nostr_publish_pending BOOLEAN NOT NULL DEFAULT FALSE", - ) - - -async def m004_event_nostr_published_wave(db): - """ - Add `events.nostr_published_wave_id` — which ticket wave the last - successful NIP-52 publish advertised. - - Since upstream v1.6.8 price and inventory live on time-boxed waves, so - what a calendar event should advertise changes at a *date* boundary. - Every republish we have is sale-driven, and no sale happens at - midnight when early bird ends — so without this the relay keeps - serving the closed wave's price until the next ticket sells - (aiolabs/events#61). - - Recording the advertised wave makes that drift detectable with the - machinery already in place: the reconciliation sweep compares this - against the wave that would be advertised now and sets - `nostr_publish_pending`, reusing the existing retry path rather than - adding a scheduler. - - NULL on existing rows means "never published, or published before this - column existed". The sweep treats NULL as "no evidence of drift" and - leaves it alone, so an upgrade does not stampede the signer with a - full-table republish; the first ordinary publish fills it in. - """ - await _alter_add_column_safe( - db, - "ALTER TABLE events.events ADD COLUMN nostr_published_wave_id TEXT", - ) diff --git a/models.py b/models.py index c5a1131..04520a0 100644 --- a/models.py +++ b/models.py @@ -1,114 +1,33 @@ import json -from datetime import date, datetime -from urllib.parse import urlsplit -from uuid import uuid4 +from datetime import datetime -from lnbits.db import FilterModel from pydantic import BaseModel, EmailStr, Field, root_validator, validator -PAYMENT_METHODS = ("lightning", "fiat") - class PromoCode(BaseModel): code: str discount_percent: float = 0.0 active: bool = True - # Redemption cap; None / 0 = unlimited. Field names follow upstream v2. - max_uses: int | None = None - # Derived on read from PAID tickets whose extra.applied_promo_code matches - # (see promo.promo_usage / services.hydrate_promo_usage). Whatever a - # client sends back here is ignored — it is never the source of truth. - used_count: int = 0 - # stored form: stripped + upper-case, never empty + # make the promo code uppercase @validator("code") def uppercase_code(cls, v): - v = (v or "").strip().upper() - if not v: - raise ValueError("Promo code cannot be empty.") - return v + return v.upper() @validator("discount_percent") def validate_discount_percent(cls, v): assert 0 <= v <= 100, "Discount must be between 0 and 100." return v - @validator("max_uses", pre=True) - def normalize_max_uses(cls, v): - if v in (None, "", 0, "0"): - return None - v = int(v) - if v < 1: - raise ValueError("max_uses must be at least 1.") - return v - - -class TicketWave(BaseModel): - id: str = Field(default_factory=lambda: uuid4().hex[:8]) - title: str = "Primary wave" - opening_date: str - closing_date: str - currency: str = "sat" - use_ticket_image: bool = False - ticket_image_id: str | None = None - allow_fiat: bool = False - fiat_currency: str = "GBP" - amount_tickets: int = Field(default=0, ge=0) - price_per_ticket: float = Field(default=0, ge=0) - - -class EventExtraBase(BaseModel): - """Everything in `extra` that is safe to show anyone — ticket waves - included, since a buyer needs a wave id to choose one. `EventExtra` adds - the organizer-only promo codes on top; `PublicEventExtra` is this base, - so anonymous responses can never carry them.""" +class EventExtra(BaseModel): + promo_codes: list[PromoCode] = Field(default_factory=list) conditional: bool = False min_tickets: int = 1 email_notifications: bool = False nostr_notifications: bool = False notification_subject: str = "" notification_body: str = "" - # Rails the organizer accepts for this event. Empty = legacy rule - # ("lightning" always, "fiat" when allow_fiat) — see - # `effective_payment_methods`. Same field name/shape as upstream v2 so the - # eventual rebase (#33) merges cleanly. - payment_methods: list[str] = Field(default_factory=list) - # Upstream v1.6.8 ticket waves — time-boxed pricing tiers. The - # event-level `currency` / `allow_fiat` / `amount_tickets` / - # `price_per_ticket` fields become derived values (see - # `sync_event_ticket_waves`), which is why fork code that reads them - # needs auditing — aiolabs/events#61. - # - # Public, not organizer-only: a buyer cannot choose a wave without its - # id, and neither the public event response nor the NIP-52 tags carried - # one before. A wave holds price, dates and remaining stock — the sales - # information a buyer needs — so the only thing exposing it reveals is - # the upcoming price schedule, which is what #61 regretted giving up - # when it settled on flat Nostr tags. - ticket_waves: list[TicketWave] = Field(default_factory=list) - - @validator("payment_methods", pre=True) - def normalize_payment_methods(cls, v): - if not v: - return [] - if isinstance(v, str): - v = v.split(",") - seen: list[str] = [] - for method in v: - method = str(method).strip().lower() - if method not in PAYMENT_METHODS: - raise ValueError(f"Unsupported payment method: {method}") - if method not in seen: - seen.append(method) - return seen - - -class EventExtra(EventExtraBase): - promo_codes: list[PromoCode] = Field(default_factory=list) - - -PublicEventExtra = EventExtraBase class CreateEvent(BaseModel): @@ -123,12 +42,7 @@ class CreateEvent(BaseModel): currency: str = "sat" allow_fiat: bool = False fiat_currency: str = "GBP" - # Capacity is always required and there is no unlimited (#34): a zero - # here means sold out / not sellable, which `api_get_event` and - # `api_ticket_create` both enforce with a 410. Under v1.6.8 waves this - # is only the seed for the primary wave — `sync_event_ticket_waves` - # recomputes it as the sum of every wave's remaining stock. - amount_tickets: int = 0 + amount_tickets: int = 0 # 0 = unlimited / not ticketed price_per_ticket: float = 0 # 0 = free banner: str | None = None location: str | None = None # venue/address (NIP-52 'location' tag) @@ -160,16 +74,6 @@ class Event(BaseModel): status: str = "approved" nostr_event_id: str | None = None nostr_event_created_at: int | None = None - # Set before every publish attempt, cleared on confirmed success. - # True means the relay's copy may be behind this row — see - # migrations_fork.m003 and the sweep in __init__.events_start. - nostr_publish_pending: bool = False - # Which wave the last successful publish advertised (see - # `advertised_wave_key`). NULL = never published; "" = published while - # nothing was on sale. The sweep compares this against the current key - # to catch wave boundaries, which are time-driven and so fire no - # sale-triggered republish (aiolabs/events#61). - nostr_published_wave_id: str | None = None @validator("categories", pre=True) def parse_categories(cls, v): @@ -193,9 +97,7 @@ class PublicEvent(BaseModel): banner: str | None location: str | None = None categories: list[str] = Field(default_factory=list) - # PublicEventExtra: promo codes are organizer-only (a buyer who can read - # every code can mint every discount). - extra: PublicEventExtra = Field(default_factory=PublicEventExtra) + extra: EventExtra = Field(default_factory=EventExtra) status: str = "approved" # surfaces "proposed"/"rejected" so SFC can render banner @validator("categories", pre=True) @@ -205,69 +107,6 @@ class PublicEvent(BaseModel): return v or [] -def effective_payment_methods( - event: "Event | PublicEvent | CreateEvent", - wave: "TicketWave | None" = None, -) -> list[str]: - """Rails a buyer may pick for `event`. - - Explicit `extra.payment_methods` wins; an empty list falls back to the - pre-#payment-methods rule so events created before the field existed - keep behaving the same (Lightning always, fiat iff `allow_fiat`). - - Pass `wave` when the answer is about one specific ticket wave. Fiat is a - per-wave opt-in since v1.6.8 and `event.allow_fiat` is only the PRIMARY - wave's, so without it a publisher can advertise a fiat rail for an - advertised wave that does not accept fiat — which the purchase endpoint - then rejects (aiolabs/events#61). Callers asking the event-level - question ("which rails did the organiser enable at all") leave it unset. - """ - explicit = list(getattr(event.extra, "payment_methods", []) or []) - if explicit: - # The organiser's rail list is event-level, but fiat is a per-wave - # opt-in. Asking about a specific wave means asking what a buyer can - # actually use for it, so drop a rail that wave cannot honour — - # otherwise the NIP-52 tag advertises fiat and the checkout offers a - # card button that `api_ticket_create` then refuses with "Fiat - # payments are not enabled for this ticket wave." - if wave is not None and not wave.allow_fiat: - return [method for method in explicit if method != "fiat"] - return explicit - methods = ["lightning"] - if wave.allow_fiat if wave is not None else event.allow_fiat: - methods.append("fiat") - return methods - - -class PromoValidateRequest(BaseModel): - """Upstream v2 shape. v2 sends `items` (ticket types); this fork prices a - plain `quantity` against one ticket wave. - - `ticket_wave_id` may be omitted when exactly one wave is open, matching - how the purchase endpoint resolves it — the preview has to price the same - wave the invoice will, and since v1.6.8 price and currency are per-wave. - """ - - codes: list[str] = Field(default_factory=list) - quantity: int = Field(default=1, ge=1, le=10) - ticket_wave_id: str | None = None - - -class BasketDiscount(BaseModel): - code: str - discount_percent: float | None = None - discount_fixed: int | None = None # always None here (percent-only); v2 shape - amount_saved: float = 0 - - -class BasketTotals(BaseModel): - subtotal: float = 0 - discount: float = 0 - total: float = 0 - discounts_applied: list[BasketDiscount] = Field(default_factory=list) - currency: str = "sat" # fork addition so a client can format the numbers - - class EventsSettings(BaseModel): """Extension-level settings for the events extension.""" @@ -276,8 +115,6 @@ class EventsSettings(BaseModel): class TicketExtra(BaseModel): applied_promo_code: str | None = None - ticket_wave_id: str | None = None - ticket_wave_title: str | None = None sats_paid: int | None = None refund_address: str | None = None nostr_identifier: str | None = None @@ -285,18 +122,12 @@ class TicketExtra(BaseModel): email_notification_sent: bool = False nostr_notification_sent: bool = False refunded: bool = False - # On-chain vocabulary, populated once native lnbits on-chain lands - # (aiolabs/events#41). Upstream's field names, minus the SatsPay charge - # id — SatsPay is the implementation we are not adopting. - onchain: bool = False - onchain_address: str | None = None class CreateTicket(BaseModel): name: str | None = None email: EmailStr | None = None user_id: str | None = None # LNbits user id (alternative to name+email) - ticket_wave_id: str | None = None promo_code: str | None = None refund_address: str | None = None nostr_identifier: str | None = None @@ -305,37 +136,16 @@ class CreateTicket(BaseModel): # Number of tickets to buy on this single invoice. Bounded so a # bad client can't run away with the organizer's capacity. quantity: int = Field(default=1, ge=1, le=10) - # App root of the client that is buying (e.g. https://app.example/events). - # The extension builds the Stripe success/cancel URLs and the emailed - # ticket link under it, so the buyer lands back in the app they came - # from. Origin is allow-listed server-side (see `_resolve_frontend_root`); - # absent = today's behaviour (the LNbits host). - frontend_url: str | None = Field(default=None, max_length=512) - - @validator("frontend_url") - def validate_frontend_url(cls, v): - if v is None: - return None - v = v.strip() - if not v: - return None - parts = urlsplit(v) - if parts.scheme not in ("http", "https") or not parts.netloc: - raise ValueError("frontend_url must be an absolute http(s) URL") - if parts.query or parts.fragment or ".." in parts.path: - raise ValueError("frontend_url must not contain a query, fragment or '..'") - return v.rstrip("/") @root_validator def validate_identifiers(cls, values): - """A ticket needs an identity: an LNbits `user_id`, or `name` + - `email` for guests. A logged-in buyer may add `email` (and `name`) - on top of `user_id` so the ticket can be emailed to them.""" 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 @@ -358,22 +168,6 @@ class Ticket(BaseModel): payment_hash: str | None = None -class NotificationDeliveryResult(BaseModel): - attempted: bool = False - sent: bool = False - error: str | None = None - - -class TicketResendResult(BaseModel): - ticket: Ticket - email: NotificationDeliveryResult = Field( - default_factory=NotificationDeliveryResult - ) - nostr: NotificationDeliveryResult = Field( - default_factory=NotificationDeliveryResult - ) - - class PublicTicket(BaseModel): event: str name: str | None = None @@ -389,133 +183,8 @@ class TicketPaymentRequest(BaseModel): fiat_payment_request: str | None = None fiat_provider: str | None = None is_fiat: bool = False - # True when the tickets are already issued + paid with no invoice to - # settle — free events (price 0) or a 100%-off promo. The client skips - # the QR / payment-poll step and goes straight to the ticket QRs. - paid: bool = False # Row ids created on this invoice — one for single-ticket # purchases, N for multi-ticket (each independently scannable at # the door). Buyers fetch these after payment to render N QRs in # My Tickets. ticket_ids: list[str] = Field(default_factory=list) - onchain_amount_sat: int | None = None - - -class TicketFilters(FilterModel): - __search_fields__ = ["event", "name", "email", "id"] # noqa: RUF012 - __sort_fields__ = [ # noqa: RUF012 - "time", - "event", - "name", - "email", - "registered", - "id", - ] - - event: str | None = None - name: str | None = None - email: str | None = None - registered: bool | None = None - paid: bool | None = None - id: str | None = None - - -def _parse_date(value: str) -> date: - """Date component of `value`. - - Upstream only ever produces bare `YYYY-MM-DD` here, so its version is a - plain `strptime(value, "%Y-%m-%d")`. In this fork `event_end_date` may - carry a time (start/end times, v1.3.0-aio.3) and `create_event` defaults - `closing_date` to it, so a wave derived from an event inherits the full - ISO datetime and upstream's parser raises - `ValueError: unconverted data remains: T18:00:00` — on the purchase path, - the public event gate, and the promo preview. - """ - return date.fromisoformat(value[:10]) - - -def ensure_ticket_waves(event: Event | PublicEvent | CreateEvent) -> list[TicketWave]: - ticket_waves = list(getattr(event.extra, "ticket_waves", []) or []) - if ticket_waves: - return ticket_waves - - # `TicketWave` requires both dates; `Event.closing_date` is Optional in - # this fork (it defaults from event_end_date at create time), so fall - # back the same way `create_event` does rather than handing None to a - # required field. - closing_date = event.closing_date or event.event_end_date or event.event_start_date - - fallback_opening_date = None - event_time = getattr(event, "time", None) - if event_time: - fallback_opening_date = event_time.date().isoformat() - if not fallback_opening_date: - fallback_opening_date = closing_date - - return [ - TicketWave( - id="primary", - title="Primary wave", - opening_date=fallback_opening_date, - closing_date=closing_date, - currency=event.currency, - allow_fiat=event.allow_fiat, - fiat_currency=event.fiat_currency, - amount_tickets=getattr(event, "amount_tickets", 0), - price_per_ticket=event.price_per_ticket, - ) - ] - - -def advertised_ticket_wave(event: "Event | PublicEvent") -> "TicketWave | None": - """The wave a public listing should describe, or None when nothing is - on sale (sold out, between waves, or not yet open). - - Several waves can be open at once. The purchase endpoint refuses to - guess; a publisher has no one to ask, so it advertises the CHEAPEST - open wave — the price a buyer is actually able to obtain. - - Shared by the NIP-52 publisher and the wave-transition detector so the - two cannot disagree about which wave is currently being advertised. - """ - active = get_active_ticket_waves(event) - if not active: - return None - return min(active, key=lambda wave: wave.price_per_ticket) - - -def advertised_wave_key(event: "Event | PublicEvent") -> str: - """Stable key for what a publish advertised. Empty string means "nothing - on sale", which is a real published state and distinct from NULL in - `nostr_published_wave_id` (never published).""" - wave = advertised_ticket_wave(event) - return wave.id if wave else "" - - -def sync_event_ticket_waves(event: Event | CreateEvent) -> Event | CreateEvent: - ticket_waves = ensure_ticket_waves(event) - event.extra.ticket_waves = ticket_waves - - primary_wave = ticket_waves[0] - event.closing_date = max(wave.closing_date for wave in ticket_waves) - event.currency = primary_wave.currency - event.allow_fiat = primary_wave.allow_fiat - event.fiat_currency = primary_wave.fiat_currency - event.amount_tickets = sum(wave.amount_tickets for wave in ticket_waves) - event.price_per_ticket = primary_wave.price_per_ticket - - return event - - -def get_active_ticket_waves( - event: Event | PublicEvent, today: date | None = None -) -> list[TicketWave]: - current_day = today or datetime.utcnow().date() - return [ - wave - for wave in ensure_ticket_waves(event) - if _parse_date(wave.opening_date) - <= current_day - <= _parse_date(wave.closing_date) - and wave.amount_tickets > 0 - ] diff --git a/nostr/nostr_client.py b/nostr/nostr_client.py index 9afa0be..4de332f 100644 --- a/nostr/nostr_client.py +++ b/nostr/nostr_client.py @@ -19,14 +19,6 @@ from websocket import WebSocketApp from .event import NostrEvent MAX_SEEN_EVENTS = 500 -# How many times a dequeued req is retried before it is dropped. Bounded -# so one unsendable message can't block every later publish behind it. -MAX_SEND_ATTEMPTS = 3 -# How long to wait for the relay's `OK` before treating a publish as -# unconfirmed. nostrclient's router answers within its own -# PUBLISH_TIMEOUT_SECONDS (10s) even when every relay stays silent, so -# this only needs headroom over that. -PUBLISH_OK_TIMEOUT_SECONDS = 12 class NostrClient: @@ -37,10 +29,6 @@ class NostrClient: self.subscription_id = "events-" + urlsafe_short_hash()[:32] self.running = False self._seen_events: OrderedDict[str, None] = OrderedDict() - # event id -> future awaiting that publish's `OK`. Resolved in - # `get_event`, which is the single point where relay messages - # cross into the event loop. - self._pending_oks: dict[str, asyncio.Future] = {} @property def is_websocket_connected(self): @@ -90,37 +78,17 @@ class NostrClient: async def run_forever(self): self.running = True - # A req that was dequeued but whose send raised. It is already - # off the queue, so dropping it loses the publish outright and - # the caller has long since been told it succeeded (the queue - # put returns immediately). Hold it across the reconnect and - # retry instead. - held_req: list | None = None - held_attempts = 0 while self.running: try: if not self.is_websocket_connected: self.ws = await self.connect() await asyncio.sleep(5) - if held_req is not None: - req = held_req - else: - req = await self.send_req_queue.get() - held_req, held_attempts = req, held_attempts + 1 + req = await self.send_req_queue.get() assert self.ws self.ws.send(json.dumps(req)) - held_req, held_attempts = None, 0 except Exception as ex: logger.warning(f"[EVENTS] NostrClient error: {ex}") - if held_req is not None and held_attempts >= MAX_SEND_ATTEMPTS: - # Bounded: a req the relay or the socket will never - # accept must not wedge the queue behind it forever. - logger.error( - f"[EVENTS] Dropping req after {held_attempts} " - f"failed sends: {held_req[0]}" - ) - held_req, held_attempts = None, 0 await asyncio.sleep(60) def is_duplicate_event(self, event_id: str) -> bool: @@ -133,82 +101,14 @@ class NostrClient: return False async def get_event(self): - """Get the next relay message, consuming `OK` frames on the way. + """Get next event from the receive queue.""" + value = await self.receive_event_queue.get() + if isinstance(value, ValueError): + raise value + return value - This is the only place relay messages cross from the websocket - thread into the event loop, which makes it the natural place to - settle publish confirmations — no cross-thread future juggling. - `OK` frames are swallowed rather than forwarded; the sync loop - never handled them. - """ - while True: - value = await self.receive_event_queue.get() - if isinstance(value, ValueError): - self._fail_pending_oks("connection closed") - raise value - if self._settle_ok(value): - continue - return value - - def _settle_ok(self, message) -> bool: - """Resolve the future for an `["OK", , , ]` frame. - - Returns True when `message` was an OK frame (and so should not - be forwarded), False otherwise. An OK for a publish we are not - waiting on — a retry whose original already timed out, say — is - still consumed; it has nowhere useful to go. - """ - try: - data = json.loads(message) - except (json.JSONDecodeError, TypeError): - return False - if not isinstance(data, list) or len(data) < 3 or data[0] != "OK": - return False - - event_id = data[1] - accepted = bool(data[2]) - detail = data[3] if len(data) > 3 and isinstance(data[3], str) else "" - future = self._pending_oks.get(event_id) - if future and not future.done(): - future.set_result((accepted, detail)) - return True - - def _fail_pending_oks(self, reason: str) -> None: - """Settle every in-flight publish as unconfirmed. - - Without this a disconnect leaves callers waiting the full - timeout for an `OK` that can no longer arrive. - """ - for future in self._pending_oks.values(): - if not future.done(): - future.set_result((False, f"error: {reason}")) - - async def publish_nostr_event(self, e: NostrEvent) -> bool: - """Publish and wait for the relay's `OK`. True only when accepted. - - Queueing is not delivery: nostrclient drops an EVENT outright - when no relay is connected, answering `OK false`. Reporting - success on the queue put let a stale ticket count survive a - republish that never left the building (aiolabs/events#56). - """ - future: asyncio.Future = asyncio.get_running_loop().create_future() - self._pending_oks[e.id] = future - try: - await self.send_req_queue.put(["EVENT", e.dict()]) - accepted, detail = await asyncio.wait_for( - future, PUBLISH_OK_TIMEOUT_SECONDS - ) - if not accepted: - logger.warning(f"[EVENTS] Relay rejected event {e.id[:12]}…: {detail}") - return accepted - except asyncio.TimeoutError: - logger.warning( - f"[EVENTS] No OK for event {e.id[:12]}… within " - f"{PUBLISH_OK_TIMEOUT_SECONDS}s — treating as unconfirmed" - ) - return False - finally: - self._pending_oks.pop(e.id, None) + async def publish_nostr_event(self, e: NostrEvent): + await self.send_req_queue.put(["EVENT", e.dict()]) async def subscribe(self, filters: list[dict]): """Subscribe to events matching the given filters.""" diff --git a/nostr_hooks.py b/nostr_hooks.py index e0b0379..3211b24 100644 --- a/nostr_hooks.py +++ b/nostr_hooks.py @@ -8,79 +8,36 @@ import cycle (views_api -> nostr_hooks -> nostr_publisher -> models). from loguru import logger from .crud import update_event -from .models import Event, advertised_wave_key +from .models import Event from .nostr_publisher import publish_event_to_nostr -async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -> bool: +async def publish_or_delete_nostr_event(event: Event, *, delete: bool = False) -> None: """Publish or delete the NIP-52 calendar event for `event`. - Resolves a `NostrSigner` for the wallet owner — backend-agnostic - (LocalSigner / RemoteBunkerSigner / ClientSideOnlySigner). The - signer abstraction handles the actual key material; this hook - only needs `signer.pubkey` for event construction and - `await signer.sign_event(...)` for signing. Failures are logged - and swallowed so a Nostr outage doesn't break the HTTP flow that - triggered the publish. - - Returns True when the event was signed and handed to the client, - False on any skip or failure. Callers are free to ignore it — the - `nostr_publish_pending` flag is the durable record, and the sweep - retries from that rather than from a return value. + Pulls the wallet owner's pubkey/prvkey to sign with the user's identity. + Failures are logged and swallowed so a Nostr outage doesn't break the + HTTP flow that triggered the publish. """ - # Mark before attempting, clear only on confirmed success. Doing it - # in this order is what makes "the attempt was never made" — no - # signer, no NostrClient, process died mid-flight — as visible as - # "the attempt raised". Cheap guard so a re-publish of an already - # pending row doesn't write twice; `set_ticket_paid` sets the flag - # inside its own update so the sale path adds no extra write. - if not event.nostr_publish_pending: - event.nostr_publish_pending = True - await update_event(event) - try: - from lnbits.core.signers import resolve_for_wallet + from lnbits.core.crud.users import get_account + from lnbits.core.crud.wallets import get_wallet from . import nostr_client - signer = await resolve_for_wallet(event.wallet) - if signer is None: - # Wallet missing, account missing, unclassified row, or - # ClientSideOnlySigner account (server can't sign for them). - # Soft-fail: the HTTP / payment flow that triggered this must - # not break. The user can still publish kind-31922/31923 - # events client-side once we have that path. - # - # Logged at WARNING, not debug: skipping the publish means the - # relay keeps serving whatever inventory it last saw, so the - # public ticket count silently stops tracking the DB. That has - # twice been discovered only by a human noticing a wrong number - # on a public page (aiolabs/events#35, #51). - logger.warning( - f"[EVENTS] No signer for wallet {event.wallet}, skipping " - f"NIP-52 {'delete' if delete else 'publish'} for event {event.id}" - ) - return False + wallet_obj = await get_wallet(event.wallet) + if not wallet_obj: + return + account = await get_account(wallet_obj.user) + if not account or not account.pubkey or not account.prvkey: + return nostr_event = await publish_event_to_nostr( - nostr_client, event, signer, delete=delete + nostr_client, event, account.pubkey, account.prvkey, delete=delete ) - if nostr_event is None: - return False - - event.nostr_publish_pending = False - if not delete: + if nostr_event and not delete: event.nostr_event_id = nostr_event.id event.nostr_event_created_at = nostr_event.created_at - # Record which wave this publish advertised so the sweep can - # notice a wave boundary later (aiolabs/events#61). Written in - # the same update as the cleared flag, so the two can never - # disagree about what is on the relay. - event.nostr_published_wave_id = advertised_wave_key(event) - await update_event(event) - return True + await update_event(event) except Exception as exc: - # ERROR, not warning: the row stays flagged and its published - # counts stay behind until the sweep or a later edit succeeds. - logger.error(f"[EVENTS] Nostr publish failed for event {event.id}: {exc}") - return False + logger.warning(f"[EVENTS] Nostr publish failed: {exc}") diff --git a/nostr_publisher.py b/nostr_publisher.py index 0bf5ab6..6867041 100644 --- a/nostr_publisher.py +++ b/nostr_publisher.py @@ -1,9 +1,8 @@ """ NIP-52 calendar event publishing for the events extension. -Builds NIP-52 calendar events from the Event model, signs them via the -core `NostrSigner` abstraction (backend-agnostic: LocalSigner, -RemoteBunkerSigner, etc.), and publishes via the NostrClient. +Builds NIP-52 calendar events from the Event model, signs them with the +creator's Account keypair, and publishes via the NostrClient. Kind 31922 is used for date-only events; kind 31923 (time-based) is used when event_start_date / event_end_date include a time component. @@ -14,17 +13,11 @@ Reference: https://github.com/nostr-protocol/nips/blob/master/52.md import time from datetime import datetime, timezone -from lnbits.core.signers import NostrSigner +import coincurve from loguru import logger -from .models import ( - Event, - advertised_ticket_wave, - effective_payment_methods, - ensure_ticket_waves, -) +from .models import Event from .nostr.event import NostrEvent -from .nostr_timestamp import monotonic_created_at def _has_time(value: str | None) -> bool: @@ -51,13 +44,11 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent: start - unix timestamp (31923) or YYYY-MM-DD (31922) end - same encoding (optional) image, location, t (categories) - optional - tickets_available - remaining capacity of the advertised wave - (always emitted; 0 = nothing on sale now) - tickets_sold - running paid-count across all waves (always - emitted) - tickets_price - the advertised wave's price (always emitted; - 0 means free) - tickets_currency - the advertised wave's currency + tickets_available - current remaining capacity (omitted when unlimited) + tickets_sold - running paid-count (always emitted; clients can + derive original_capacity = available + sold) + tickets_price - price_per_ticket (always emitted; 0 means free) + tickets_currency - the currency string tickets_allow_fiat - "true" when fiat checkout is enabled (omitted otherwise) tickets_fiat_currency - the fiat settle currency (only when allow_fiat) Content: event.info @@ -103,69 +94,24 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent: for cat in event.categories or []: tags.append(["t", cat]) - # Always emitted, including zero. Omitting it used to mean "unlimited", - # which contradicted every other reader: `api_get_event` and - # `api_ticket_create` both treat `amount_tickets < 1` as sold out, so a - # zero-capacity event advertised "Unlimited tickets" on the card while - # the detail page and the purchase both returned 410 (aiolabs/events#34). - # Clients that must still handle an absent tag — a NIP-52 event from - # some other publisher — are unaffected; we simply never omit it. - # - # Since v1.6.8 price, currency and inventory belong to a ticket WAVE. - # The event-level fields `sync_event_ticket_waves` derives are the - # PRIMARY wave's price/currency and the SUM of every wave's stock, so - # publishing them would keep advertising the early-bird price after - # early bird closed, and count stock in waves that have not opened yet - # (aiolabs/events#61). Advertise the wave a buyer can actually buy from. - # - # Several waves can be open at once. The purchase endpoint refuses to - # guess ("Please select a ticket wave"); a publisher has no one to ask, - # so it advertises the CHEAPEST open wave — the price a buyer is able to - # obtain right now. Deviation recorded in docs/upstream-candidates.md. - open_wave = advertised_ticket_wave(event) - # Nothing open: sold out, between waves, or not yet on sale. Fall back - # to the primary wave so price/currency still describe the event, - # paired with the zero availability below. - advertised = open_wave or ensure_ticket_waves(event)[0] - - # Always emitted, including zero — see #34 above. With no open wave - # there is nothing to sell regardless of what the waves hold, so this - # is 0 rather than the wave's stock. - available = advertised.amount_tickets if open_wave else 0 - tags.append(["tickets_available", str(available)]) - # Event-level: total paid across every wave, which is what "sold" means - # to a reader of the card. + # `amount_tickets == 0` means unlimited capacity in this extension's + # schema. Omitting the tag is how clients distinguish unlimited from + # "0 left" (sold out). + if event.amount_tickets > 0: + tags.append(["tickets_available", str(event.amount_tickets)]) tags.append(["tickets_sold", str(event.sold)]) - tags.append(["tickets_price", str(advertised.price_per_ticket)]) - tags.append(["tickets_currency", advertised.currency]) + tags.append(["tickets_price", str(event.price_per_ticket)]) + tags.append(["tickets_currency", event.currency]) # Fiat-checkout config — only emitted when allow_fiat is on so # clients can branch the buy UI without re-reading the schema. - if advertised.allow_fiat: + if event.allow_fiat: tags.append(["tickets_allow_fiat", "true"]) - if advertised.fiat_currency: - tags.append(["tickets_fiat_currency", advertised.fiat_currency]) - # Rails the organizer accepts, resolved through the same helper the - # ticket endpoint enforces with, so a client can render exactly the - # buttons that will be accepted (e.g. a card-only event) without a REST - # round-trip. Comma-separated, lowercase. - tags.append( - [ - "tickets_payment_methods", - # Scoped to the advertised wave so this cannot contradict - # `tickets_allow_fiat` above — they are the same fact. - ",".join(effective_payment_methods(event, advertised)), - ] - ) + if event.fiat_currency: + tags.append(["tickets_fiat_currency", event.fiat_currency]) - # NIP-52 calendar events are replaceable: this d-tag is republished - # whenever inventory changes (a ticket sells). Use a strictly-monotonic - # created_at anchored on the last published value so a same-second - # republish still outranks the prior version and relays push it to open - # subscriptions — a bare int(time.time()) can tie and be silently - # dropped, stalling clients' live "tickets remaining" badge. nostr_event = NostrEvent( pubkey=pubkey, - created_at=monotonic_created_at(event.nostr_event_created_at), + created_at=int(time.time()), kind=kind, tags=tags, content=event.info or "", @@ -196,64 +142,37 @@ def build_nip52_delete_event(event: Event, pubkey: str) -> NostrEvent: return nostr_event +def sign_nostr_event(nostr_event: NostrEvent, private_key_hex: str) -> None: + """Sign a NostrEvent in-place using Schnorr signature.""" + privkey = coincurve.PrivateKey(bytes.fromhex(private_key_hex)) + sig = privkey.sign_schnorr(bytes.fromhex(nostr_event.id)) + nostr_event.sig = sig.hex() + + async def publish_event_to_nostr( nostr_client, event: Event, - signer: NostrSigner, + account_pubkey: str, + account_prvkey: str, delete: bool = False, ) -> NostrEvent | None: """ Build, sign, and publish a NIP-52 calendar event (or delete event). - Signing routes through the core `NostrSigner` abstraction — - `signer.pubkey` for the event identity, `await signer.sign_event(...)` - for the Schnorr signature. The signer backend (LocalSigner / - RemoteBunkerSigner) is transparent to this function. - Returns the published NostrEvent for metadata storage, or None on failure. """ if not nostr_client: - # WARNING, not debug: with no client the event is never queued, so - # the relay keeps serving stale inventory and nothing downstream - # can tell. At debug this skip is invisible at the INFO level - # instances actually run at (aiolabs/events#35, #51). - logger.warning( - "[EVENTS] No NostrClient, skipping NIP-52 " - f"{'delete' if delete else 'publish'} for event {event.id}" - ) + logger.debug("[EVENTS] No NostrClient available, skipping publish") return None try: if delete: - nostr_event = build_nip52_delete_event(event, signer.pubkey) + nostr_event = build_nip52_delete_event(event, account_pubkey) else: - nostr_event = build_nip52_event(event, signer.pubkey) + nostr_event = build_nip52_event(event, account_pubkey) - # Hand the unsigned event to the signer — it fills in `id`, - # `pubkey`, and `sig`. The signer's serialization rules match - # NIP-01 (same as the local `event_id` property uses), so the - # returned id matches what we'd have computed locally. - unsigned = { - "kind": nostr_event.kind, - "created_at": nostr_event.created_at, - "tags": nostr_event.tags, - "content": nostr_event.content, - } - signed = await signer.sign_event(unsigned) - nostr_event.id = signed["id"] - nostr_event.pubkey = signed["pubkey"] - nostr_event.sig = signed["sig"] - - accepted = await nostr_client.publish_nostr_event(nostr_event) - if not accepted: - # Returning None keeps `nostr_publish_pending` set, so the - # sweep retries instead of recording a delivery that never - # happened (aiolabs/events#56). - logger.warning( - f"[EVENTS] Relay did not confirm NIP-52 " - f"{'delete' if delete else 'calendar'} event for {event.id}" - ) - return None + sign_nostr_event(nostr_event, account_prvkey) + await nostr_client.publish_nostr_event(nostr_event) logger.info( f"[EVENTS] Published NIP-52 {'delete' if delete else 'calendar'} " @@ -262,8 +181,5 @@ async def publish_event_to_nostr( return nostr_event except Exception as e: - # ERROR, not warning: this is the signer-outage shape of - # aiolabs/events#35 — the calendar event never reaches the relay - # and the published ticket counts stop tracking the DB. - logger.error(f"[EVENTS] Failed to publish event {event.id} to Nostr: {e}") + logger.warning(f"[EVENTS] Failed to publish to Nostr: {e}") return None diff --git a/nostr_timestamp.py b/nostr_timestamp.py deleted file mode 100644 index 625b21c..0000000 --- a/nostr_timestamp.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Monotonic ``created_at`` for replaceable / addressable Nostr events. - -Relays only push a replaceable update to OPEN subscriptions when its -``created_at`` is strictly newer than the version they already hold. -``created_at`` is integer seconds, so a publisher that stamps -``int(time.time())`` can emit two versions within the same wall-clock -second (e.g. two ticket sales republishing the NIP-52 calendar event) — -the relay treats the second as not-newer and never propagates it to live -subscribers (it only surfaces on a reload / fresh REQ). - -Returning ``max(now, last_created_at + 1)`` guarantees a strictly -increasing timestamp across successive publishes of the same replaceable -event. When enough real seconds have elapsed it tracks wall-clock; only -same-second (or clock-skewed) republishes get nudged forward. - -Mirrors the webapp's ``monotonicCreatedAt`` (src/lib/nostr/timestamp.ts) -and ``docs/nostr-patterns/replaceable-events.md``. -""" - -import time - - -def monotonic_created_at(last_created_at: int | None, now: int | None = None) -> int: - """Strictly-newer ``created_at`` for the next publish of a coord. - - :param last_created_at: ``created_at`` of the previously published - version (seconds), or ``None`` if none has been published yet. - :param now: Current time in seconds — injectable for tests; defaults - to ``int(time.time())``. - """ - base = int(time.time()) if now is None else now - if last_created_at is None: - return base - return max(base, last_created_at + 1) diff --git a/promo.py b/promo.py deleted file mode 100644 index d35dff2..0000000 --- a/promo.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Promo-code arithmetic shared by the validate endpoint and the purchase path. - -Pure functions (no DB, no settings) so the number a buyer sees in the -"Apply" preview is exactly the number the invoice / Stripe session charges. -Field names and the `BasketTotals` shape follow upstream lnbits/events v2 -(PR #64) so the eventual rebase (#33) merges cleanly; deviations are noted -inline. -""" - -from __future__ import annotations - -from collections import Counter - -from .models import BasketDiscount, BasketTotals, Event, PromoCode, Ticket, TicketWave - -SAT_UNITS = ("sat", "sats") - - -def normalize_code(raw: str | None) -> str | None: - """Buyer input → stored form (stripped, upper-cased); empty → None.""" - if raw is None: - return None - code = raw.strip().upper() - return code or None - - -def find_promo(event: Event, code: str) -> PromoCode | None: - return next((pc for pc in event.extra.promo_codes if pc.code == code), None) - - -def promo_usage(tickets: list[Ticket]) -> dict[str, int]: - """Redemptions per code = PAID tickets carrying it in - `extra.applied_promo_code`. Every row counts, so a multi-ticket - purchase consumes `quantity` uses (upstream v2 counts one per basket). - - Paid only: pending rows live up to 24 h (`purge_unpaid_tickets`), so - counting them would let an abandoned Stripe session lock out the last - uses of a limited code for a day. The cost is a bounded overshoot when - several buyers pass the check before any of them pays — accepted. - """ - counter: Counter[str] = Counter() - for ticket in tickets: - code = ticket.extra.applied_promo_code - if ticket.paid and code: - counter[code] += 1 - return dict(counter) - - -def remaining_uses(promo: PromoCode, used: int) -> int | None: - """None = unlimited (`max_uses` unset / 0).""" - if not promo.max_uses: - return None - return max(promo.max_uses - used, 0) - - -def round_amount(amount: float, currency: str | None) -> float: - """Sats are integers; fiat is 2 dp. Applied once, at the end, so - subtotal - total == discount holds for what is actually charged.""" - if (currency or "sat").lower() in SAT_UNITS: - return float(int(amount)) - return round(amount, 2) - - -def basket_totals( - event: Event, - codes: list[str], - quantity: int, - usage: dict[str, int], - wave: TicketWave, -) -> BasketTotals: - """Price `quantity` tickets from `wave` with the first applicable code. - - A code is applicable when it exists, is active, has enough uses left - for the whole quantity, and actually saves something. Anything else is - simply absent from `discounts_applied` (upstream v2 semantics — the - purchase endpoint is where hard errors are raised). Only one code is - applied; v2's `combinable` stacking is out of scope here. - - `wave` is the pricing authority, not `event`. Since v1.6.8 a ticket's - price and currency belong to the wave it is bought from, and the - event-level fields are a derived roll-up of the PRIMARY wave - (`sync_event_ticket_waves`) — pricing off `event` would quote and charge - the first wave's price to a buyer who picked a later one. It is a - required argument rather than an optional override precisely because - that failure is silent: both call sites have to name the wave. - """ - currency = wave.currency or "sat" - subtotal = round_amount(wave.price_per_ticket * quantity, currency) - totals = BasketTotals( - subtotal=subtotal, discount=0, total=subtotal, currency=currency - ) - for raw in codes: - code = normalize_code(raw) - if not code: - continue - promo = find_promo(event, code) - if not promo or not promo.active: - continue - remaining = remaining_uses(promo, usage.get(promo.code, 0)) - if remaining is not None and remaining < quantity: - continue - total = round_amount(subtotal * (1 - promo.discount_percent / 100), currency) - saved = round_amount(subtotal - total, currency) - if saved <= 0: - continue - totals.total = total - totals.discount = saved - totals.discounts_applied = [ - BasketDiscount( - code=promo.code, - discount_percent=promo.discount_percent, - discount_fixed=None, - amount_saved=saved, - ) - ] - break - return totals diff --git a/qr.py b/qr.py deleted file mode 100644 index 79a693f..0000000 --- a/qr.py +++ /dev/null @@ -1,254 +0,0 @@ -"""QR + ticket-card rendering shared by the API and the mailer. - -`make_qr_png` is upstream v1.6.8's helper (pyqrcode + Pillow) with the -instance QR logo pasted in the centre; `render_ticket_card` wraps it in a -self-describing card (event, when, where, name, ticket id) so the PNG a -buyer saves from the email still says what it is for. -""" - -from __future__ import annotations - -import re -from datetime import datetime -from io import BytesIO -from pathlib import Path - -import httpx -import pyqrcode # type: ignore[import-untyped] -from lnbits.settings import settings -from loguru import logger -from PIL import Image, ImageDraw, ImageFont - -from .models import Event, Ticket - -_qr_logo_cache: dict[str, Image.Image | None] = {} - - -async def load_qr_logo() -> Image.Image | None: - """LNbits' "QR Code/Favicon Logo" setting, as a Pillow image (cached). - - Local `/static/...` values resolve inside the LNbits package; absolute - URLs are fetched once. Any failure just yields a plain QR. - """ - source = (settings.lnbits_qr_logo or "").strip() - if not source: - return None - if source in _qr_logo_cache: - return _qr_logo_cache[source] - logo: Image.Image | None = None - try: - if source.startswith(("http://", "https://")): - async with httpx.AsyncClient(timeout=5) as client: - resp = await client.get(source) - resp.raise_for_status() - logo = Image.open(BytesIO(resp.content)).convert("RGBA") - else: - local = Path(settings.lnbits_path) / source.lstrip("/") - if local.is_file(): - logo = Image.open(local).convert("RGBA") - except Exception as exc: - logger.warning(f"QR logo '{source}' unavailable: {exc}") - logo = None - _qr_logo_cache[source] = logo - return logo - - -def make_qr_png( - data: str, - size: int = 235, - border: int = 4, - logo: Image.Image | None = None, -) -> Image.Image: - """Render `data` as a QR image. With `logo`, the code is built at - error-correction level H and the logo is pasted in the centre on a white - pad at ≤ 20 % of the width — the same look LNbits' client-side - `lnbits-qrcode` component produces.""" - qr = pyqrcode.create(data, error="H" if logo is not None else "M") - matrix = qr.code - modules = len(matrix) - - total_modules = modules + border * 2 - box_size = max(1, size // total_modules) - img_size = total_modules * box_size - - img = Image.new("RGBA", (img_size, img_size), "white") - draw = ImageDraw.Draw(img) - - for y, row in enumerate(matrix): - for x, cell in enumerate(row): - if cell: - x0 = (x + border) * box_size - y0 = (y + border) * box_size - draw.rectangle( - [x0, y0, x0 + box_size - 1, y0 + box_size - 1], - fill="black", - ) - - if img_size != size: - img = img.resize((size, size), Image.Resampling.NEAREST) - - if logo is not None: - logo_size = max(8, int(size * 0.2)) - pad = max(2, logo_size // 8) - scaled = logo.copy() - scaled.thumbnail((logo_size, logo_size), Image.Resampling.LANCZOS) - plate = Image.new( - "RGBA", (scaled.width + 2 * pad, scaled.height + 2 * pad), "white" - ) - plate.paste(scaled, (pad, pad), scaled) - img.paste( - plate, - ((size - plate.width) // 2, (size - plate.height) // 2), - plate, - ) - - return img - - -def _parse_iso(value: str | None) -> datetime | None: - if not value: - return None - try: - return datetime.fromisoformat(value) - except ValueError: - try: - return datetime.strptime(value[:10], "%Y-%m-%d") - except ValueError: - return None - - -def format_event_when(event: Event) -> str: - """'Fri 19 Feb 2027, 16:00 - 20:00' (same day) or a full range; the raw - strings when they do not parse.""" - start = _parse_iso(event.event_start_date) - end = _parse_iso(event.event_end_date) - if not start: - return event.event_start_date or "" - has_time = "T" in (event.event_start_date or "") - day = start.strftime("%a %d %b %Y") - if not end or end == start: - return f"{day}, {start.strftime('%H:%M')}" if has_time else day - if end.date() == start.date(): - if has_time: - return f"{day}, {start.strftime('%H:%M')} - {end.strftime('%H:%M')}" - return day - end_day = end.strftime("%a %d %b %Y") - if has_time: - return f"{day} {start.strftime('%H:%M')} - {end_day} {end.strftime('%H:%M')}" - return f"{day} - {end_day}" - - -def ticket_card_filename(ticket: Ticket, event: Event) -> str: - slug = re.sub(r"[^a-z0-9]+", "-", event.name.lower()).strip("-")[:40] or "event" - return f"ticket-{slug}-{ticket.id[:8]}.png" - - -_FONT_DIR = Path(__file__).resolve().parent / "static" / "fonts" - - -def _font( - size: int, bold: bool = False -) -> ImageFont.ImageFont | ImageFont.FreeTypeFont: - """DejaVu Sans shipped with the extension (full Latin coverage — the - Pillow-bundled default lacks accented glyphs, so 'Château' would render - as tofu); Pillow's default is the fallback.""" - path = _FONT_DIR / ("DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf") - try: - return ImageFont.truetype(str(path), size) - except OSError: - try: - return ImageFont.load_default(size=size) - except Exception: # very old Pillow: bitmap default only - return ImageFont.load_default() - - -def _wrap(draw: ImageDraw.ImageDraw, text: str, font, max_width: int) -> list[str]: - lines: list[str] = [] - for paragraph in text.split("\n"): - words = paragraph.split() - line = "" - for word in words: - candidate = f"{line} {word}".strip() - if draw.textlength(candidate, font=font) <= max_width or not line: - line = candidate - else: - lines.append(line) - line = word - lines.append(line) - return lines - - -def render_ticket_card( - ticket: Ticket, - event: Event, - *, - logo: Image.Image | None = None, - site_title: str | None = None, - width: int = 800, -) -> Image.Image: - """A self-describing ticket: header (site), event name, when/where, the - QR, then name on ticket + ticket id + door instruction.""" - pad = 48 - inner = width - 2 * pad - title_font = _font(40, bold=True) - body_font = _font(28) - small_font = _font(22) - mono_font = _font(24) - - # Measure first: the card grows with the wrapped title. - probe = ImageDraw.Draw(Image.new("RGB", (width, 10), "white")) - title_lines = _wrap(probe, event.name, title_font, inner) - when = format_event_when(event) - meta_lines = [when] if when else [] - if event.location: - meta_lines += _wrap(probe, event.location, body_font, inner) - qr_size = min(inner, 560) - detail_lines = [] - if ticket.name: - detail_lines.append(f"Name: {ticket.name}") - detail_lines.append(f"Ticket {ticket.id}") - - y = pad - y += 30 + 16 # site title line - y += len(title_lines) * 52 + 12 - y += len(meta_lines) * 36 + 28 - qr_y = y - y += qr_size + 28 - y += len(detail_lines) * 36 + 12 - y += 30 + pad # footer - height = y - - img = Image.new("RGB", (width, height), "white") - draw = ImageDraw.Draw(img) - grey = (110, 110, 110) - black = (20, 20, 20) - - y = pad - draw.text((pad, y), (site_title or "Ticket").upper(), fill=grey, font=small_font) - y += 30 + 16 - for line in title_lines: - draw.text((pad, y), line, fill=black, font=title_font) - y += 52 - y += 12 - for line in meta_lines: - draw.text((pad, y), line, fill=black, font=body_font) - y += 36 - y += 28 - - qr = make_qr_png(f"ticket://{ticket.id}", size=qr_size, logo=logo).convert("RGB") - img.paste(qr, ((width - qr_size) // 2, qr_y)) - y = qr_y + qr_size + 28 - - for line in detail_lines: - font = mono_font if line.startswith("Ticket ") else body_font - draw.text((pad, y), line, fill=black, font=font) - y += 36 - y += 12 - draw.text((pad, y), "Show this QR code at the door.", fill=grey, font=small_font) - return img - - -def image_png_bytes(img: Image.Image) -> bytes: - out = BytesIO() - img.save(out, format="PNG") - return out.getvalue() diff --git a/services.py b/services.py index 84d5798..0a2de28 100644 --- a/services.py +++ b/services.py @@ -1,19 +1,14 @@ from __future__ import annotations import asyncio -import re -import smtplib from asyncio.tasks import create_task -from email.mime.image import MIMEImage -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText -from email.utils import formataddr, formatdate, make_msgid -from html import escape from lnbits.core.models.users import UserNotifications from lnbits.core.services.nostr import send_nostr_dm -from lnbits.core.services.notifications import send_user_notification -from lnbits.helpers import is_valid_email_address +from lnbits.core.services.notifications import ( + send_email_notification, + send_user_notification, +) from lnbits.settings import settings from lnbits.utils.nostr import normalize_private_key, normalize_public_key from lnurl import execute @@ -26,22 +21,8 @@ from .crud import ( update_event, update_ticket, ) -from .models import ( - Event, - NotificationDeliveryResult, - Ticket, - TicketResendResult, - ensure_ticket_waves, -) +from .models import Event, Ticket from .nostr_hooks import publish_or_delete_nostr_event -from .promo import promo_usage -from .qr import ( - format_event_when, - image_png_bytes, - load_qr_logo, - render_ticket_card, - ticket_card_filename, -) DEFAULT_NOSTR_RELAYS = [ "wss://relay.damus.io", @@ -76,29 +57,7 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket: event = await get_event(ticket.event) assert event, "Couldn't get event from ticket being paid" event.sold += 1 - # Debit the wave the buyer actually bought from. v1.6.8 moved - # inventory onto waves; the event-level counter is only the - # fallback for events that predate them, and is itself a derived - # roll-up (`sync_event_ticket_waves`). Upstream's `> 0` guards are - # kept — ours decremented unconditionally and could go negative. - ticket_waves = event.extra.ticket_waves or [] - if ticket_waves: - selected_wave = next( - ( - wave - for wave in ticket_waves - if wave.id == ticket.extra.ticket_wave_id - ), - ticket_waves[0], - ) - if selected_wave.amount_tickets > 0: - selected_wave.amount_tickets -= 1 - elif event.amount_tickets > 0: - event.amount_tickets -= 1 - # Flag inside this same write: the counters and "the relay does - # not know about them yet" land atomically, so a crash between - # here and the publish still leaves the drift discoverable. - event.nostr_publish_pending = True + event.amount_tickets -= 1 await update_event(event) # Republish the NIP-52 calendar event so connected clients see @@ -111,22 +70,6 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket: return ticket -async def event_promo_usage(event_id: str) -> dict[str, int]: - """Paid redemptions per promo code for one event (see promo.promo_usage).""" - return promo_usage(await get_event_tickets(event_id)) - - -async def hydrate_promo_usage(event: Event) -> Event: - """Fill `used_count` on each of the event's promo codes. No query when - the event has no codes, so listing stays cheap.""" - if not event.extra.promo_codes: - return event - usage = await event_promo_usage(event.id) - for promo in event.extra.promo_codes: - promo.used_count = usage.get(promo.code, 0) - return event - - def send_ticket_notification_in_background(ticket: Ticket) -> None: create_task(_send_ticket_notification(ticket)) @@ -137,20 +80,40 @@ async def _send_ticket_notification(ticket: Ticket) -> None: logger.warning(f"Event {ticket.event} not found for ticket notification.") return - await _deliver_ticket_notifications(ticket, event) + subject, message = _ticket_notification_message(ticket, event) + updated = False + + if ( + event.extra.email_notifications + and settings.lnbits_email_notifications_enabled + and ticket.email + ): + try: + await send_email_notification([ticket.email], message, subject) + ticket.extra.email_notification_sent = True + updated = True + except Exception as exc: + logger.warning(f"Failed to email ticket {ticket.id}: {exc}") + + if ( + event.extra.nostr_notifications + and settings.is_nostr_notifications_configured() + and ticket.extra.nostr_identifier + ): + try: + await _send_nostr_ticket_notification( + ticket.extra.nostr_identifier, message + ) + ticket.extra.nostr_notification_sent = True + updated = True + except Exception as exc: + logger.warning(f"Failed to send nostr DM for ticket {ticket.id}: {exc}") + + if updated: + await update_ticket(ticket) -async def resend_ticket_email_notification( - ticket: Ticket, base_url: str | None = None -) -> TicketResendResult: - """Organizer-triggered re-delivery of the ticket email. Bypasses the - per-event `email_notifications` opt-in (the organizer asked explicitly) - but still needs the instance mailer and an address on the ticket. - - `base_url` is upstream v1.6.8's: it re-points the ticket link at the - caller's frontend, which matters for us specifically because our - `ticket_base_url` can differ from `lnbits_baseurl` (separate web app). - """ +async def resend_ticket_email_notification(ticket: Ticket) -> Ticket: event = await get_event(ticket.event) if not event: raise ValueError("Event does not exist.") @@ -158,13 +121,11 @@ async def resend_ticket_email_notification( raise ValueError("Email notifications are not enabled.") if not ticket.email: raise ValueError("Ticket does not have an email address.") - if base_url: - ticket.extra.ticket_base_url = base_url.rstrip("/") - # email-only: this is the *email* resend endpoint. Upstream calls - # `_deliver_ticket_notifications(ticket, event)` unqualified, which in - # our fork would also fire a Nostr DM the organiser did not ask for. - return await _deliver_ticket_notifications(ticket, event, email=True, nostr=False) + subject, message = _ticket_notification_message(ticket, event) + await send_email_notification([ticket.email], message, subject) + ticket.extra.email_notification_sent = True + return await update_ticket(ticket) def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str]: @@ -181,255 +142,6 @@ def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str return subject, f"{body}\n\nOpen it here: {ticket_url}" -def _ticket_details(ticket: Ticket, event: Event) -> str: - """Human-readable ticket facts for the email body. Also what keeps the - mail from being an image with no words (SpamAssassin HTML_IMAGE_ONLY).""" - lines = [f"Event: {event.name}", f"When: {format_event_when(event)}"] - if event.location: - lines.append(f"Where: {event.location}") - if ticket.name: - lines.append(f"Name on ticket: {ticket.name}") - lines.append(f"Ticket ID: {ticket.id}") - lines.append( - "Your ticket (with its QR code) is attached to this email — save it " - "or open the link above on your phone, and show the QR code at the " - "door to be scanned in." - ) - return "\n".join(lines) - - -def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) -> str: - """Text part of the ticket mail. - - Carries up to two images, which are NOT the same thing (see the note on - `_ticket_card_url` vs `_ticket_image_url`): our rendered QR card, always - present, and the organiser's uploaded template, only when the buyer's - wave opted into it. They had the same label before the v1.6.8 merge - because only one of them existed; now that both can appear the labels - have to distinguish them. - """ - message = ( - f"{base_message}\n\n{_ticket_details(ticket, event)}" - f"\n\nTicket card: {_ticket_card_url(ticket)}" - ) - ticket_image_url = _ticket_image_url(ticket, event) - if ticket_image_url: - message = f"{message}\n\nTicket image: {ticket_image_url}" - return message - - -def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) -> str: - """HTML twin of the text part. - - Deliberately no for our own ticket card: it travels as an - attachment (renders inline in most clients, works offline, and keeps - SpamAssassin's HTML_IMAGE_ONLY rules quiet), and its URL becomes a link. - The organiser's uploaded ticket image is a remote URL with no attachment - to fall back on, so that one does get upstream's — the surrounding - ticket details keep the mail from being image-only either way. - """ - text_message = _ticket_delivery_message(ticket, event, base_message) - html = escape(text_message) - html = re.sub( - r"(https?://[^\s<]+)", - lambda m: f'{m.group(1)}', - html, - ) - html_message = f"

{html.replace(chr(10), '
')}

" - - ticket_image_url = _ticket_image_url(ticket, event) - if not ticket_image_url: - return html_message - - return ( - f"{html_message}" - f'

Ticket image

' - ) - - -def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]: - subject, base_message = _ticket_notification_message(ticket, event) - text_message = _ticket_delivery_message(ticket, event, base_message) - html_message = _ticket_email_html_message(ticket, event, base_message) - return subject, text_message, html_message - - -async def _deliver_ticket_notifications( - ticket: Ticket, - event: Event, - *, - email: bool | None = None, - nostr: bool | None = None, -) -> TicketResendResult: - """Send the ticket by every configured channel and report per-channel - outcome (upstream v1.6.8 shape). `email` / `nostr` override the event's - opt-ins when not None; the instance-level prerequisites always apply. - - Upstream v1.6.8 guards the nostr branch with a `_supports_nostr_delivery` - check that errors with "Only NIP-05 Nostr identifiers are supported." - That guard is NOT taken here: it encodes upstream's limitation, not ours. - `_send_nostr_ticket_notification` below dispatches NIP-05 identifiers to - core's notifier and bare npubs to `send_nostr_dm`, so adopting the guard - would silently refuse identifiers we deliver to today — and say so with - a message that would be false for this fork. - """ - subject, text_message, html_message = _ticket_notification_payload(ticket, event) - updated = False - - email_wanted = event.extra.email_notifications if email is None else email - nostr_wanted = event.extra.nostr_notifications if nostr is None else nostr - result = TicketResendResult( - ticket=ticket, - email=NotificationDeliveryResult( - attempted=bool( - email_wanted - and settings.lnbits_email_notifications_enabled - and ticket.email - ) - ), - nostr=NotificationDeliveryResult( - attempted=bool( - nostr_wanted - and settings.is_nostr_notifications_configured() - and ticket.extra.nostr_identifier - ) - ), - ) - - if result.email.attempted: - try: - assert ticket.email - card = render_ticket_card( - ticket, - event, - logo=await load_qr_logo(), - site_title=settings.lnbits_site_title, - ) - await _send_ticket_email_notification( - [ticket.email], - text_message, - subject, - html_message, - attachments=[ - (ticket_card_filename(ticket, event), image_png_bytes(card)) - ], - ) - ticket.extra.email_notification_sent = True - result.email.sent = True - updated = True - except Exception as exc: - logger.warning(f"Failed to email ticket {ticket.id}: {exc}") - result.email.error = str(exc) - - if result.nostr.attempted: - try: - identifier = ticket.extra.nostr_identifier - assert identifier - await _send_nostr_ticket_notification(identifier, text_message) - ticket.extra.nostr_notification_sent = True - result.nostr.sent = True - updated = True - except Exception as exc: - logger.warning(f"Failed to send nostr DM for ticket {ticket.id}: {exc}") - result.nostr.error = str(exc) - - if updated: - result.ticket = await update_ticket(ticket) - return result - - -async def _send_ticket_email_notification( - to_emails: list[str], - message: str, - subject: str, - html_message: str | None = None, - attachments: list[tuple[str, bytes]] | None = None, -) -> None: - """Multipart (text + HTML) ticket email through the instance SMTP - settings. Core's `send_email_notification` is plain-text only, which is - why this lives here (ported from upstream v1.6.8). The blocking smtplib - session runs in a worker thread so a slow relay cannot stall the event - loop while a batch of tickets settles.""" - if not settings.lnbits_email_notifications_enabled: - raise ValueError("Email notifications are disabled") - from_email = settings.lnbits_email_notifications_email - if not is_valid_email_address(from_email): - raise ValueError(f"Invalid from email address: {from_email}") - if not to_emails: - raise ValueError("No email addresses provided") - for address in to_emails: - if not is_valid_email_address(address): - raise ValueError(f"Invalid email address: {address}") - - msg = build_ticket_email( - from_email, to_emails, subject, message, html_message, attachments - ) - username = settings.lnbits_email_notifications_username or from_email - await asyncio.to_thread( - _smtp_send, - settings.lnbits_email_notifications_server, - settings.lnbits_email_notifications_port, - username, - settings.lnbits_email_notifications_password, - from_email, - to_emails, - msg.as_string(), - ) - - -def build_ticket_email( - from_email: str, - to_emails: list[str], - subject: str, - message: str, - html_message: str | None = None, - attachments: list[tuple[str, bytes]] | None = None, -) -> MIMEMultipart: - """Assemble the ticket email: text + HTML alternatives, PNG attachments - (the ticket card), and the headers receivers score on — a Date and a - Message-ID (their absence is what SpamAssassin's MISSING_DATE / - MISSING_MID flag, and what Gmail/Outlook read as machine-generated) and - a display name on From so the sender is not a bare address.""" - body = MIMEMultipart("alternative") - body.attach(MIMEText(message, "plain")) - if html_message: - body.attach(MIMEText(html_message, "html")) - - if attachments: - msg = MIMEMultipart("mixed") - msg.attach(body) - for filename, data in attachments: - part = MIMEImage(data, _subtype="png") - part.add_header("Content-Disposition", "attachment", filename=filename) - msg.attach(part) - else: - msg = body - - sender_name = (settings.lnbits_site_title or "").strip() or "Tickets" - msg["From"] = formataddr((sender_name, from_email)) - msg["To"] = ", ".join(to_emails) - msg["Subject"] = subject - msg["Date"] = formatdate(localtime=True) - msg["Message-ID"] = make_msgid(domain=from_email.rsplit("@", 1)[-1]) - return msg - - -def _smtp_send( - server: str, - port: int, - username: str, - password: str, - from_email: str, - to_emails: list[str], - payload: str, -) -> None: - with smtplib.SMTP(server, port, timeout=30) as smtp_server: - smtp_server.starttls() - smtp_server.login(username, password) - smtp_server.sendmail(from_email, to_emails, payload) - - async def _send_nostr_ticket_notification(identifier: str, message: str) -> None: if "@" in identifier: await send_user_notification( @@ -449,36 +161,6 @@ def _ticket_url(ticket: Ticket) -> str: return f"{base_url}/events/ticket/{ticket.id}" -def _ticket_card_url(ticket: Ticket) -> str: - """Our rendered ticket-card PNG — always available, and served by THIS - extension on the LNbits host, so it is built from `lnbits_baseurl` even - when `ticket_base_url` points at a separate web app (deviation from - upstream, which assumes both are the same host).""" - return ( - f"{settings.lnbits_baseurl.rstrip('/')}/events/api/v1/ticket-card/{ticket.id}" - ) - - -def _ticket_image_url(ticket: Ticket, event: Event) -> str | None: - """Upstream's organiser-uploaded ticket template, opted into per wave. - - Distinct from `_ticket_card_url`: that is our own rendered card and is - always attached, this is a template the organiser uploads and enables - on a specific wave. They shared a name before the v1.6.8 merge, which - made them look like one feature. - """ - waves = ensure_ticket_waves(event) - wave = next( - (wave for wave in waves if wave.id == ticket.extra.ticket_wave_id), - waves[0], - ) - if not wave.use_ticket_image: - return None - - base_url = (ticket.extra.ticket_base_url or settings.lnbits_baseurl).rstrip("/") - return f"{base_url}/events/api/v1/qr/{ticket.id}" - - async def refund_tickets(event_id: str): """ Refund tickets for an event that has not met the minimum ticket requirement. diff --git a/static/fonts/DejaVuSans-Bold.ttf b/static/fonts/DejaVuSans-Bold.ttf deleted file mode 100644 index 3710f89..0000000 Binary files a/static/fonts/DejaVuSans-Bold.ttf and /dev/null differ diff --git a/static/fonts/DejaVuSans.ttf b/static/fonts/DejaVuSans.ttf deleted file mode 100644 index 1a12606..0000000 Binary files a/static/fonts/DejaVuSans.ttf and /dev/null differ diff --git a/static/fonts/LICENSE-DejaVu.txt b/static/fonts/LICENSE-DejaVu.txt deleted file mode 100644 index df52c17..0000000 --- a/static/fonts/LICENSE-DejaVu.txt +++ /dev/null @@ -1,187 +0,0 @@ -Fonts are (c) Bitstream (see below). DejaVu changes are in public domain. -Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below) - - -Bitstream Vera Fonts Copyright ------------------------------- - -Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is -a trademark of Bitstream, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of the fonts accompanying this license ("Fonts") and associated -documentation files (the "Font Software"), to reproduce and distribute the -Font Software, including without limitation the rights to use, copy, merge, -publish, distribute, and/or sell copies of the Font Software, and to permit -persons to whom the Font Software is furnished to do so, subject to the -following conditions: - -The above copyright and trademark notices and this permission notice shall -be included in all copies of one or more of the Font Software typefaces. - -The Font Software may be modified, altered, or added to, and in particular -the designs of glyphs or characters in the Fonts may be modified and -additional glyphs or characters may be added to the Fonts, only if the fonts -are renamed to names not containing either the words "Bitstream" or the word -"Vera". - -This License becomes null and void to the extent applicable to Fonts or Font -Software that has been modified and is distributed under the "Bitstream -Vera" names. - -The Font Software may be sold as part of a larger software package but no -copy of one or more of the Font Software typefaces may be sold by itself. - -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, -TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME -FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING -ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF -THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE -FONT SOFTWARE. - -Except as contained in this notice, the names of Gnome, the Gnome -Foundation, and Bitstream Inc., shall not be used in advertising or -otherwise to promote the sale, use or other dealings in this Font Software -without prior written authorization from the Gnome Foundation or Bitstream -Inc., respectively. For further information, contact: fonts at gnome dot -org. - -Arev Fonts Copyright ------------------------------- - -Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. - -Permission is hereby granted, free of charge, to any person obtaining -a copy of the fonts accompanying this license ("Fonts") and -associated documentation files (the "Font Software"), to reproduce -and distribute the modifications to the Bitstream Vera Font Software, -including without limitation the rights to use, copy, merge, publish, -distribute, and/or sell copies of the Font Software, and to permit -persons to whom the Font Software is furnished to do so, subject to -the following conditions: - -The above copyright and trademark notices and this permission notice -shall be included in all copies of one or more of the Font Software -typefaces. - -The Font Software may be modified, altered, or added to, and in -particular the designs of glyphs or characters in the Fonts may be -modified and additional glyphs or characters may be added to the -Fonts, only if the fonts are renamed to names not containing either -the words "Tavmjong Bah" or the word "Arev". - -This License becomes null and void to the extent applicable to Fonts -or Font Software that has been modified and is distributed under the -"Tavmjong Bah Arev" names. - -The Font Software may be sold as part of a larger software package but -no copy of one or more of the Font Software typefaces may be sold by -itself. - -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL -TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. - -Except as contained in this notice, the name of Tavmjong Bah shall not -be used in advertising or otherwise to promote the sale, use or other -dealings in this Font Software without prior written authorization -from Tavmjong Bah. For further information, contact: tavmjong @ free -. fr. - -TeX Gyre DJV Math ------------------ -Fonts are (c) Bitstream (see below). DejaVu changes are in public domain. - -Math extensions done by B. Jackowski, P. Strzelczyk and P. Pianowski -(on behalf of TeX users groups) are in public domain. - -Letters imported from Euler Fraktur from AMSfonts are (c) American -Mathematical Society (see below). -Bitstream Vera Fonts Copyright -Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera -is a trademark of Bitstream, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of the fonts accompanying this license (“Fonts”) and associated -documentation -files (the “Font Software”), to reproduce and distribute the Font Software, -including without limitation the rights to use, copy, merge, publish, -distribute, -and/or sell copies of the Font Software, and to permit persons to whom -the Font Software is furnished to do so, subject to the following -conditions: - -The above copyright and trademark notices and this permission notice -shall be -included in all copies of one or more of the Font Software typefaces. - -The Font Software may be modified, altered, or added to, and in particular -the designs of glyphs or characters in the Fonts may be modified and -additional -glyphs or characters may be added to the Fonts, only if the fonts are -renamed -to names not containing either the words “Bitstream” or the word “Vera”. - -This License becomes null and void to the extent applicable to Fonts or -Font Software -that has been modified and is distributed under the “Bitstream Vera” -names. - -The Font Software may be sold as part of a larger software package but -no copy -of one or more of the Font Software typefaces may be sold by itself. - -THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, -TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME -FOUNDATION -BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, -SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN -ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR -INABILITY TO USE -THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. -Except as contained in this notice, the names of GNOME, the GNOME -Foundation, -and Bitstream Inc., shall not be used in advertising or otherwise to promote -the sale, use or other dealings in this Font Software without prior written -authorization from the GNOME Foundation or Bitstream Inc., respectively. -For further information, contact: fonts at gnome dot org. - -AMSFonts (v. 2.2) copyright - -The PostScript Type 1 implementation of the AMSFonts produced by and -previously distributed by Blue Sky Research and Y&Y, Inc. are now freely -available for general use. This has been accomplished through the -cooperation -of a consortium of scientific publishers with Blue Sky Research and Y&Y. -Members of this consortium include: - -Elsevier Science IBM Corporation Society for Industrial and Applied -Mathematics (SIAM) Springer-Verlag American Mathematical Society (AMS) - -In order to assure the authenticity of these fonts, copyright will be -held by -the American Mathematical Society. This is not meant to restrict in any way -the legitimate use of the fonts, such as (but not limited to) electronic -distribution of documents containing these fonts, inclusion of these fonts -into other public domain or commercial font collections or computer -applications, use of the outline data to create derivative fonts and/or -faces, etc. However, the AMS does require that the AMS copyright notice be -removed from any derivative versions of the fonts which have been altered in -any way. In addition, to ensure the fidelity of TeX documents using Computer -Modern fonts, Professor Donald Knuth, creator of the Computer Modern faces, -has requested that any alterations which yield different font metrics be -given a different name. - -$Id$ diff --git a/static/image/ticket.jpg b/static/image/ticket.jpg deleted file mode 100644 index e05d931..0000000 Binary files a/static/image/ticket.jpg and /dev/null differ diff --git a/static/js/display.js b/static/js/display.js index 022f051..d8be8e9 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -13,7 +13,6 @@ window.PageEventsDisplay = { email: '', refund: '', nostr_identifier: '', - ticket_wave_id: null, payment_method: 'lightning' } }, @@ -36,73 +35,21 @@ window.PageEventsDisplay = { async created() { this.eventId = this.$route.params.id this.event = await this.getEvent() - // Default to the first rail the organizer accepts (a card-only event - // must not submit "lightning"). - this.formDialog.data.payment_method = this.paymentMethods[0] || 'lightning' }, computed: { formatDescription() { return LNbits.utils.convertMarkdown(this.event?.info || '') }, - paymentMethods() { - // Mirrors `effective_payment_methods` on the backend: an explicit - // extra.payment_methods list wins, else Lightning + fiat-if-allow_fiat. - // Since v1.6.8 `allow_fiat` is a per-wave flag and `event.allow_fiat` - // is only the PRIMARY wave's, so prefer the active wave when there is - // one — otherwise a later fiat-enabled wave would not offer fiat. - const explicit = this.event?.extra?.payment_methods || [] - if (explicit.length) return explicit - const allowFiat = this.selectedTicketWave - ? this.selectedTicketWave.allow_fiat - : this.event?.allow_fiat - return ['lightning', ...(allowFiat ? ['fiat'] : [])] - }, - activeTicketWaves() { - const today = new Date().toISOString().slice(0, 10) - return (this.event?.extra?.ticket_waves || []).filter( - wave => - wave.amount_tickets > 0 && - wave.opening_date <= today && - wave.closing_date >= today - ) - }, - selectedTicketWave() { - return ( - this.activeTicketWaves.find( - wave => wave.id === this.formDialog.data.ticket_wave_id - ) || - this.activeTicketWaves[0] || - null - ) - }, - showTicketWaveSelector() { - return this.activeTicketWaves.length > 1 - }, allowFiatCheckout() { - return this.paymentMethods.includes('fiat') - }, - paymentMethodOptions() { - // Options come from `paymentMethods` — the same list the backend - // validates against in `effective_payment_methods` — rather than - // being re-derived from per-method booleans, so a rail the server - // would reject can never be offered. The `|| method` fallback covers - // a method with no label yet (on-chain, once #41 lands). - const labels = { - lightning: 'Lightning', - fiat: this.fiatCheckoutLabel - } - return this.paymentMethods.map(method => ({ - value: method, - label: labels[method] || method - })) + return Boolean(this.event?.allow_fiat) }, fiatCheckoutLabel() { if (!this.allowFiatCheckout) return 'Fiat' const unit = ['sat', 'sats'].includes( - (this.selectedTicketWave?.currency || '').toLowerCase() + (this.event?.currency || '').toLowerCase() ) - ? this.selectedTicketWave?.fiat_currency - : this.selectedTicketWave?.currency + ? this.event?.fiat_currency + : this.event?.currency return `Fiat (${(unit || 'GBP').toUpperCase()})` }, allowEmailNotifications() { @@ -119,16 +66,6 @@ window.PageEventsDisplay = { 'GET', `/events/api/v1/events/${this.eventId}` ) - const activeWaves = (data.extra?.ticket_waves || []).filter(wave => { - const today = new Date().toISOString().slice(0, 10) - return ( - wave.amount_tickets > 0 && - wave.opening_date <= today && - wave.closing_date >= today - ) - }) - this.formDialog.data.ticket_wave_id = - activeWaves.length === 1 ? activeWaves[0].id : null return data } catch (error) { this.eventErrorLabel = 'Event unavailable.' @@ -141,13 +78,7 @@ window.PageEventsDisplay = { this.formDialog.data.email = '' this.formDialog.data.refund = '' this.formDialog.data.nostr_identifier = '' - this.formDialog.data.ticket_wave_id = - this.activeTicketWaves.length === 1 - ? this.activeTicketWaves[0].id - : null - this.formDialog.data.promo_code = '' - this.formDialog.data.payment_method = - this.paymentMethods[0] || 'lightning' + this.formDialog.data.payment_method = 'lightning' }, closeReceiveDialog() { @@ -159,13 +90,6 @@ window.PageEventsDisplay = { this.paymentWebsocket.close() this.paymentWebsocket = null } - this.paymentReq = null - this.receive = { - show: false, - status: 'pending', - paymentReq: null, - isFiat: false - } }, nameValidation(val) { const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g @@ -188,12 +112,7 @@ window.PageEventsDisplay = { this.formDialog.data.email = '' this.formDialog.data.refund = '' this.formDialog.data.nostr_identifier = '' - this.formDialog.data.ticket_wave_id = - this.activeTicketWaves.length === 1 - ? this.activeTicketWaves[0].id - : null - this.formDialog.data.payment_method = - this.paymentMethods[0] || 'lightning' + this.formDialog.data.payment_method = 'lightning' Quasar.Notify.create({ type: 'positive', message: 'Sent, thank you!', @@ -222,19 +141,10 @@ window.PageEventsDisplay = { { name: this.formDialog.data.name, email: this.formDialog.data.email, - ticket_wave_id: this.formDialog.data.ticket_wave_id || null, promo_code: this.formDialog.data.promo_code || null, refund_address: this.formDialog.data.refund || null, nostr_identifier: this.formDialog.data.nostr_identifier || null, - // Gate matches the template's (`paymentMethods.length > 1`). - // v1.6.8 gated on `showPaymentMethodSelector` - // (`allowFiatCheckout || allowOnchain`), a different condition: - // where the two disagreed the buyer's visible choice was - // silently replaced with 'lightning'. - payment_method: - this.paymentMethods.length > 1 - ? this.formDialog.data.payment_method - : this.paymentMethods[0] || 'lightning' + payment_method: this.formDialog.data.payment_method } ) const isFiat = Boolean(data.is_fiat) @@ -268,7 +178,7 @@ window.PageEventsDisplay = { const url = new URL(window.location) url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - url.pathname = `/events/api/v1/tickets/ws/${paymentHash}` + url.pathname = `/api/v1/ws/${paymentHash}` url.search = '' url.hash = '' @@ -277,7 +187,7 @@ window.PageEventsDisplay = { ws.onmessage = event => { const data = JSON.parse(event.data) - if (data.paid === true) { + if (data.pending === false) { this.paymentSuccess(paymentHash) ws.close() } @@ -286,12 +196,8 @@ window.PageEventsDisplay = { console.error('WebSocket error:', error) } ws.onclose = () => { - if (this.paymentWebsocket !== ws) return - this.paymentWebsocket = null - if (this.receive.show) { - setTimeout(() => { - if (this.receive.show) this.paymentWatcher(paymentHash) - }, 3000) + if (this.paymentWebsocket === ws) { + this.paymentWebsocket = null } } } diff --git a/static/js/display.vue b/static/js/display.vue index 8388997..9b27783 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -74,7 +74,7 @@ filled dense v-model.trim="formDialog.data.nostr_identifier" - label="(optional) Nostr NIP-05" + label="(optional) Nostr NIP-05 or npub" hint="If provided, we'll DM your ticket link after payment." > @@ -89,34 +89,21 @@ lazy-rules :hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`" > -
-
+
-
+
Submit this.shortenId(row.id) - }, + {name: 'id', align: 'left', label: 'ID', field: 'id'}, {name: 'name', align: 'left', label: 'Name', field: 'name'}, { name: 'event_start_date', @@ -47,6 +39,12 @@ window.PageEvents = { label: 'End date', field: 'event_end_date' }, + { + name: 'closing_date', + align: 'left', + label: 'Ticket close', + field: 'closing_date' + }, { name: 'canceled', align: 'left', @@ -69,15 +67,13 @@ window.PageEvents = { name: 'event_start_date', align: 'left', label: 'Start date', - field: 'event_start_date', - format: val => this.formatEventDate(val) + field: 'event_start_date' }, { name: 'event_end_date', align: 'left', label: 'End date', - field: 'event_end_date', - format: val => this.formatEventDate(val) + field: 'event_end_date' }, { name: 'closing_date', @@ -131,41 +127,16 @@ window.PageEvents = { } }, ticketsTable: { - loading: false, columns: [ - { - name: 'event', - align: 'left', - label: 'Event', - field: row => this.shortenId(row.event) - }, + {name: 'event', align: 'left', label: 'Event', field: 'event'}, {name: 'name', align: 'left', label: 'Name', field: 'name'}, {name: 'email', align: 'left', label: 'Email', field: 'email'}, - { - name: 'email_sent', - align: 'left', - label: 'Email sent', - field: row => - !row.email - ? 'no email' - : row.extra?.email_notification_sent - ? '\u2713 sent' - : row.paid - ? 'not sent' - : 'unpaid' - }, { name: 'registered', align: 'left', label: 'Registered', field: 'registered' }, - { - name: 'nostr', - align: 'left', - label: 'Nostr', - field: row => row.extra?.nostr_identifier || '' - }, { name: 'promo_code', align: 'left', @@ -175,302 +146,38 @@ window.PageEvents = { {name: 'id', align: 'left', label: 'ID', field: 'id'} ], pagination: { - sortBy: 'time', - descending: true, - page: 1, - rowsPerPage: 10, - rowsNumber: 10 + rowsPerPage: 10 } }, - // Rails an organizer can enable per event. Mirrors the webapp's - // CreateEventDialog; `fiat` is rendered disabled when the LNbits user - // has no fiat provider (see `hasFiatProvider`). - paymentMethodOptions: [ - { - value: 'lightning', - label: 'Lightning', - hint: 'Pay with any Lightning wallet' - }, - { - value: 'fiat', - label: 'Card', - hint: 'Card or bank through your configured fiat provider' - } - ], - // Same list the webapp offers (src/modules/events/types/category.ts); - // published as NIP-52 `t` tags so both clients filter on one vocabulary. - categoryOptions: [ - 'concert', - 'workshop', - 'market', - 'festival', - 'exhibition', - 'sport', - 'theater', - 'cinema', - 'party', - 'talk', - 'conference', - 'meetup', - 'food', - 'outdoor', - 'kids', - 'wellness', - 'technology', - 'art', - 'music', - 'dance', - 'literature', - 'comedy', - 'charity', - 'tradition', - 'other' - ].map(c => ({label: c.charAt(0).toUpperCase() + c.slice(1), value: c})), formDialog: { show: false, data: { currency: 'sats', allow_fiat: false, fiat_currency: 'GBP', - location: '', - categories: [], extra: { - payment_methods: ['lightning'], - ticket_waves: [], promo_codes: [], notification_subject: '', notification_body: '' } } - }, - ticketWaveDialog: { - show: false, - eventId: null, - wallet: null, - editingWaveId: null, - data: { - id: null, - title: '', - opening_date: '', - closing_date: '', - currency: 'sats', - use_ticket_image: false, - ticket_image_id: null, - allow_fiat: false, - fiat_currency: 'GBP', - amount_tickets: 0, - price_per_ticket: 0 - } - }, - promoCodesDialog: { - show: false, - data: { - id: null, - wallet: null, - name: '', - extra: { - promo_codes: [] - } - } } } }, - computed: { - hasFiatProvider() { - return (this.g.user?.fiat_providers || []).length > 0 - }, - fiatProviderNames() { - return (this.g.user?.fiat_providers || []) - .map(p => p.charAt(0).toUpperCase() + p.slice(1)) - .join(', ') - }, - acceptsFiat() { - return (this.formDialog.data.extra?.payment_methods || []).includes( - 'fiat' - ) - }, - isSatPrice() { - return !this.isFiatCurrency(this.formDialog.data.currency) - }, - fiatCurrencyOptions() { - return this.currencies.filter(c => this.isFiatCurrency(c)) - } - }, methods: { - shortenId(value) { - if (!value) return '' - return value.length > 4 ? `${value.slice(0, 4)}...` : value - }, - primaryTicketWave(data = this.formDialog.data) { - if (!data.extra) data.extra = {} - if (!data.extra.ticket_waves || data.extra.ticket_waves.length === 0) { - data.extra.ticket_waves = [ - { - id: 'primary', - title: 'Primary wave', - opening_date: data.closing_date || '', - closing_date: data.closing_date || '', - currency: data.currency || 'sats', - use_ticket_image: false, - ticket_image_id: null, - allow_fiat: Boolean(data.allow_fiat), - fiat_currency: data.fiat_currency || 'GBP', - amount_tickets: data.amount_tickets || 0, - price_per_ticket: data.price_per_ticket || 0 - } - ] - } - return data.extra.ticket_waves[0] - }, - syncPrimaryWaveFromForm(data = this.formDialog.data) { - const primaryWave = this.primaryTicketWave(data) - primaryWave.title = primaryWave.title || 'Primary wave' - primaryWave.opening_date = primaryWave.opening_date || '' - primaryWave.closing_date = data.closing_date || '' - primaryWave.currency = data.currency || 'sats' - primaryWave.use_ticket_image = Boolean(primaryWave.use_ticket_image) - primaryWave.ticket_image_id = primaryWave.ticket_image_id || null - primaryWave.allow_fiat = Boolean(data.allow_fiat) - primaryWave.fiat_currency = data.fiat_currency || 'GBP' - primaryWave.amount_tickets = Number(data.amount_tickets || 0) - primaryWave.price_per_ticket = Number(data.price_per_ticket || 0) - return primaryWave - }, - hydrateEventForm(data) { - const formData = { - ...data, - extra: { - ...(data.extra || {}), - ticket_waves: [...((data.extra && data.extra.ticket_waves) || [])] - } - } - const primaryWave = this.primaryTicketWave(formData) - formData.currency = primaryWave.currency || formData.currency || 'sats' - formData.allow_fiat = Boolean(primaryWave.allow_fiat) - formData.fiat_currency = primaryWave.fiat_currency || 'GBP' - formData.amount_tickets = primaryWave.amount_tickets - formData.price_per_ticket = primaryWave.price_per_ticket - formData.closing_date = - primaryWave.closing_date || formData.closing_date || '' - return formData - }, isFiatCurrency(currency) { return !['sat', 'sats'].includes((currency || '').toLowerCase()) }, - normalizePromoCodes(promoCodes = []) { - return promoCodes - .filter(code => code.code?.trim() !== '') - .map(code => ({ - ...code, - code: code.code.trim().toUpperCase(), - // fork-only: blank / 0 = unlimited; used_count is derived server-side - max_uses: code.max_uses ? Number(code.max_uses) : null - })) - }, - templateDownloadUrl() { - return '/events/static/image/ticket.jpg' - }, - async uploadAssetFile(file) { - const form = new FormData() - form.append('file', file) - form.append('public_asset', 'true') - const {data} = await LNbits.api.request( - 'POST', - '/api/v1/assets?public_asset=true', - null, - form - ) - return data.id - }, - triggerTicketImageUpload(target) { - this.ticketImageUploadTarget = target - this.$refs.ticketImageUpload.value = null - this.$refs.ticketImageUpload.click() - }, - async handleTicketImageSelected(event) { - const file = event.target.files?.[0] - if (!file || !this.ticketImageUploadTarget) return - - this.isUploadingTicketTemplate = true - try { - const assetId = await this.uploadAssetFile(file) - if (this.ticketImageUploadTarget === 'primary') { - const wave = this.primaryTicketWave() - wave.use_ticket_image = true - wave.ticket_image_id = assetId - } else if (this.ticketImageUploadTarget === 'dialog') { - this.ticketWaveDialog.data.use_ticket_image = true - this.ticketWaveDialog.data.ticket_image_id = assetId - } - Quasar.Notify.create({ - type: 'positive', - message: 'Ticket template uploaded.', - icon: null - }) - } catch (error) { - LNbits.utils.notifyApiError(error) - } finally { - this.isUploadingTicketTemplate = false - this.ticketImageUploadTarget = null - } - }, - waveSummary(eventId, wave) { - // Built here, not in the template. Vue resolves template expressions - // against the component instance, where the `LNbits` global is NOT - // in scope — upstream's v1.6.8 chip called `LNbits.utils` inline and - // threw "Cannot read properties of undefined (reading 'utils')", - // which killed the whole v-for and left the wave list looking empty. - // No other template in this extension touches `LNbits` directly. - const price = this.isFiatCurrency(wave.currency) - ? LNbits.utils.formatCurrency( - Number(wave.price_per_ticket || 0).toFixed(2), - wave.currency - ) - : `${wave.price_per_ticket} sats` - // Wave dates can carry a time (closing_date defaults from - // event_end_date); show the day only. - const opens = String(wave.opening_date || '').slice(0, 10) - const closes = String(wave.closing_date || '').slice(0, 10) - const sold = this.soldTicketsForWave(eventId, wave.id) - return `${wave.title} - ${opens} to ${closes} - ${price} - ${wave.amount_tickets} tickets - ${sold} sold` - }, - soldTicketsForWave(eventId, waveId) { - return this.allPaidTickets.filter( - ticket => - ticket.event === eventId && - ticket.paid && - (ticket.extra?.ticket_wave_id === waveId || - (!ticket.extra?.ticket_wave_id && waveId === 'primary')) - ).length - }, - async getAllTickets() { - try { - const {data} = await LNbits.api.request( + getTickets() { + LNbits.api + .request( 'GET', '/events/api/v1/tickets?all_wallets=true', this.g.user.wallets[0].adminkey ) - this.allPaidTickets = data.filter(ticket => ticket.paid) - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - async getTickets(props) { - try { - this.ticketsTable.loading = true - const params = LNbits.utils.prepareFilterQuery(this.ticketsTable, props) - const {data} = await LNbits.api.request( - 'GET', - `/events/api/v1/tickets/paginated?all_wallets=true&${params}`, - this.g.user.wallets[0].adminkey - ) - this.tickets = data.data - this.ticketsTable.pagination.rowsNumber = data.total - } catch (error) { - LNbits.utils.notifyApiError(error) - } finally { - this.ticketsTable.loading = false - } + .then(response => { + this.tickets = response.data.filter(e => e.paid) + }) }, deleteTicket(ticketId) { const tickets = _.findWhere(this.tickets, {id: ticketId}) @@ -485,9 +192,10 @@ window.PageEvents = { '/events/api/v1/tickets/' + ticketId, wallet.adminkey ) - .then(async () => { - await this.getTickets() - await this.getAllTickets() + .then(response => { + this.tickets = _.reject(this.tickets, function (obj) { + return obj.id == ticketId + }) }) .catch(LNbits.utils.notifyApiError) }) @@ -505,30 +213,14 @@ window.PageEvents = { wallet.adminkey ) .then(response => { - const result = response.data this.tickets = this.tickets.map(obj => - obj.id === ticket.id ? result.ticket : obj + obj.id === ticket.id ? response.data : obj ) - - if (result.email?.attempted) { - Quasar.Notify.create({ - type: result.email.sent ? 'positive' : 'negative', - message: result.email.sent - ? 'Ticket email resent.' - : `Ticket email failed: ${result.email.error || 'Unknown error.'}`, - icon: null - }) - } - - if (result.nostr?.attempted) { - Quasar.Notify.create({ - type: result.nostr.sent ? 'positive' : 'negative', - message: result.nostr.sent - ? 'Ticket Nostr DM resent.' - : `Ticket Nostr DM failed: ${result.nostr.error || 'Unknown error.'}`, - icon: null - }) - } + Quasar.Notify.create({ + type: 'positive', + message: 'Ticket email resent.', + icon: null + }) }) .catch(LNbits.utils.notifyApiError) .finally(() => { @@ -538,7 +230,7 @@ window.PageEvents = { }) }, exportticketsCSV() { - LNbits.utils.exportCSV(this.ticketsTable.columns, this.allPaidTickets) + LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets) }, getEvents() { LNbits.api @@ -579,7 +271,12 @@ window.PageEvents = { }, saveSettings() { LNbits.api - .request('PUT', '/events/api/v1/events/settings', null, this.settings) + .request( + 'PUT', + '/events/api/v1/events/settings', + null, + this.settings + ) .then(() => { Quasar.Notify.create({type: 'positive', message: 'Settings saved'}) }) @@ -629,7 +326,7 @@ window.PageEvents = { LNbits.utils .confirmDialog( 'Re-emit every approved event to Nostr relays? This is safe ' + - 'to run multiple times but generates one event per approved row.' + 'to run multiple times but generates one event per approved row.' ) .onOk(() => { this.republishing = true @@ -654,7 +351,9 @@ window.PageEvents = { }, republishMyEvents() { LNbits.utils - .confirmDialog('Re-emit your approved events to Nostr relays?') + .confirmDialog( + 'Re-emit your approved events to Nostr relays?' + ) .onOk(() => { this.republishingMine = true LNbits.api @@ -689,90 +388,43 @@ window.PageEvents = { }, splitDateTime(value) { // Inverse of foldDateTime: split a stored string back into the - // day/time pieces the form inputs bind to. Slicing to HH:MM also - // drops the seconds + offset suffix withLocalTzOffset stamps on - // submit, so the organizer sees the wall-clock they entered. + // day/time pieces the form inputs bind to. if (!value) return {day: '', time: ''} const [day, time = ''] = value.split('T') // Time inputs only accept HH:MM, drop any seconds we stored. return {day, time: time.slice(0, 5)} }, - withLocalTzOffset(value) { - // Stamp the browser's UTC offset on a "YYYY-MM-DDTHH:MM" value. - // The publisher's `_to_unix` treats a naive datetime as UTC, so an - // event entered as 18:00 in CEST would otherwise go out on Nostr - // as 18:00 UTC. Same transform the webapp applies; date-only - // values pass through unchanged (they map to NIP-52 kind 31922). - if (!value || !value.includes('T')) return value - const offMin = -new Date(value).getTimezoneOffset() - const sign = offMin >= 0 ? '+' : '-' - const abs = Math.abs(offMin) - const hh = String(Math.floor(abs / 60)).padStart(2, '0') - const mm = String(abs % 60).padStart(2, '0') - return `${value}:00${sign}${hh}:${mm}` - }, - formatEventDate(value) { - // Table display: "YYYY-MM-DD" or "YYYY-MM-DD HH:MM". - if (!value) return '' - const {day, time} = this.splitDateTime(value) - return time ? `${day} ${time}` : day - }, - validateEndDate() { - // Cross-field rule for the end-day input: end >= start, compared - // on the folded date+time so an equal-day earlier time is caught. - const d = this.formDialog.data - const start = this.foldDateTime(d.event_start_day, d.event_start_time) - const end = this.foldDateTime(d.event_end_day, d.event_end_time) - if (!start || !end) return true - return end >= start || 'End must be on or after start' - }, sendEventData() { const wallet = _.findWhere(this.g.user.wallets, { id: this.formDialog.data.wallet }) const data = {...this.formDialog.data} - data.event_start_date = this.withLocalTzOffset( - this.foldDateTime(data.event_start_day, data.event_start_time) + data.event_start_date = this.foldDateTime( + data.event_start_day, + data.event_start_time ) - data.event_end_date = this.withLocalTzOffset( - this.foldDateTime(data.event_end_day, data.event_end_time) + data.event_end_date = this.foldDateTime( + data.event_end_day, + data.event_end_time ) delete data.event_start_day delete data.event_start_time delete data.event_end_day delete data.event_end_time - // Optional NIP-52 fields: blank location is "unset", not "". - data.location = (data.location || '').trim() || null - data.categories = data.categories || [] - data.closing_date = data.closing_date || null - // Fold the form's day+time pairs first, then let upstream's helper - // mirror the form fields onto the primary wave — order matters, the - // sync reads closing_date/currency/amount_tickets off `data`. - this.syncPrimaryWaveFromForm(data) + if (data.extra?.promo_codes) { - data.extra.promo_codes = this.normalizePromoCodes( - data.extra.promo_codes - ) + data.extra.promo_codes = data.extra.promo_codes + .filter(code => code.code?.trim() !== '') + .map(code => ({ + ...code, + code: code.code.trim().toUpperCase() + })) } - const methods = data.extra?.payment_methods || [] - if (methods.length === 0) { - Quasar.Notify.create({ - type: 'warning', - message: 'Select at least one payment method.' - }) - return + if (!this.isFiatCurrency(data.currency)) { + if (!data.allow_fiat) { + data.fiat_currency = 'GBP' + } } - // allow_fiat stays the fiat-currency carrier the backend and the - // NIP-52 tags read; keep it in lockstep with the checkbox list. - data.allow_fiat = methods.includes('fiat') - if (this.isFiatCurrency(data.currency)) { - // A fiat-priced event settles in its price currency; mirror it so - // the payload (and the tickets_fiat_currency tag) stay coherent. - data.fiat_currency = data.currency - } else if (!data.allow_fiat) { - data.fiat_currency = 'GBP' - } - this.syncPrimaryWaveFromForm(data) if (data.id) { this.updateEvent(wallet, data) @@ -785,23 +437,8 @@ window.PageEvents = { if (data && data.id) { const start = this.splitDateTime(data.event_start_date) const end = this.splitDateTime(data.event_end_date) - // Seed from upstream's hydrator (it materialises ticket_waves and - // mirrors the primary wave onto the flat form fields), then layer - // our fork-only fields on top. - const hydrated = this.hydrateEventForm(data) this.formDialog.data = { - ...hydrated, - extra: { - ...(hydrated.extra || {}), - // Events created before extra.payment_methods existed carry an - // empty list; show the rails the backend actually accepts for - // them (Lightning always, fiat when allow_fiat). - payment_methods: data.extra?.payment_methods?.length - ? data.extra.payment_methods - : ['lightning', ...(data.allow_fiat ? ['fiat'] : [])] - }, - location: data.location || '', - categories: [...(data.categories || [])], + ...data, event_start_day: start.day, event_start_time: start.time, event_end_day: end.day, @@ -812,33 +449,15 @@ window.PageEvents = { currency: 'sats', allow_fiat: false, fiat_currency: 'GBP', - location: '', - categories: [], event_start_day: '', event_start_time: '', event_end_day: '', event_end_time: '', extra: { - payment_methods: ['lightning'], conditional: false, min_tickets: 1, email_notifications: false, nostr_notifications: false, - ticket_waves: [ - { - id: 'primary', - title: 'Primary wave', - opening_date: '', - closing_date: '', - currency: 'sats', - use_ticket_image: false, - ticket_image_id: null, - allow_fiat: false, - fiat_currency: 'GBP', - amount_tickets: 0, - price_per_ticket: 0 - } - ], promo_codes: [], notification_subject: '', notification_body: '' @@ -853,29 +472,9 @@ window.PageEvents = { currency: 'sats', allow_fiat: false, fiat_currency: 'GBP', - location: '', - categories: [], extra: { - payment_methods: ['lightning'], - conditional: false, - min_tickets: 1, email_notifications: false, nostr_notifications: false, - ticket_waves: [ - { - id: 'primary', - title: 'Primary wave', - opening_date: '', - closing_date: '', - currency: 'sats', - use_ticket_image: false, - ticket_image_id: null, - allow_fiat: false, - fiat_currency: 'GBP', - amount_tickets: 0, - price_per_ticket: 0 - } - ], promo_codes: [], notification_subject: '', notification_body: '' @@ -896,183 +495,6 @@ window.PageEvents = { const link = _.findWhere(this.events, {id: formId}) this.openEventDialog(link) }, - openTicketWaveDialog(event, wave = null) { - const primaryWave = (event.extra?.ticket_waves || [])[0] || {} - const isEditing = Boolean(wave) - this.ticketWaveDialog = { - show: true, - eventId: event.id, - wallet: event.wallet, - editingWaveId: wave?.id || null, - data: { - id: wave?.id || null, - title: wave?.title || '', - opening_date: wave?.opening_date || '', - closing_date: wave?.closing_date || '', - currency: - wave?.currency || primaryWave.currency || event.currency || 'sats', - use_ticket_image: Boolean(wave?.use_ticket_image), - ticket_image_id: wave?.ticket_image_id || null, - allow_fiat: isEditing - ? Boolean(wave?.allow_fiat) - : Boolean(primaryWave.allow_fiat ?? event.allow_fiat), - fiat_currency: - wave?.fiat_currency || - primaryWave.fiat_currency || - event.fiat_currency || - 'GBP', - // `??` not `||`: a sold-out wave legitimately holds 0 and must - // show it rather than silently regaining stock on save. A NEW - // wave opens at 1, the smallest capacity the backend accepts — - // there is no unlimited (#34). - amount_tickets: isEditing ? (wave?.amount_tickets ?? 0) : 1, - price_per_ticket: - wave?.price_per_ticket || - primaryWave.price_per_ticket || - event.price_per_ticket || - 0 - } - } - }, - resetTicketWaveDialog() { - this.ticketWaveDialog = { - show: false, - eventId: null, - wallet: null, - editingWaveId: null, - data: { - id: null, - title: '', - opening_date: '', - closing_date: '', - currency: 'sats', - use_ticket_image: false, - ticket_image_id: null, - allow_fiat: false, - fiat_currency: 'GBP', - amount_tickets: 0, - price_per_ticket: 0 - } - } - }, - saveTicketWave() { - const event = _.findWhere(this.events, { - id: this.ticketWaveDialog.eventId - }) - const wallet = _.findWhere(this.g.user.wallets, { - id: this.ticketWaveDialog.wallet - }) - if (!event || !wallet) return - - const payload = { - ...event, - extra: { - ...event.extra, - ticket_waves: (event.extra?.ticket_waves || []).map(existingWave => - existingWave.id === this.ticketWaveDialog.editingWaveId - ? {...this.ticketWaveDialog.data} - : existingWave - ) - } - } - - if (!this.ticketWaveDialog.editingWaveId) { - payload.extra.ticket_waves.push({...this.ticketWaveDialog.data}) - } - - if (payload.extra?.promo_codes) { - payload.extra.promo_codes = this.normalizePromoCodes( - payload.extra.promo_codes - ) - } - - LNbits.api - .request( - 'PUT', - '/events/api/v1/events/' + payload.id, - wallet.adminkey, - payload - ) - .then(response => { - this.events = this.events.map(item => - item.id === payload.id ? response.data : item - ) - Quasar.Notify.create({ - type: 'positive', - message: this.ticketWaveDialog.editingWaveId - ? 'Ticket wave updated.' - : 'Ticket wave added.', - icon: null - }) - this.resetTicketWaveDialog() - }) - .catch(LNbits.utils.notifyApiError) - }, - openPromoCodesDialog(event) { - this.promoCodesDialog.data = { - ...event, - extra: { - ...event.extra, - promo_codes: [...(event.extra?.promo_codes || [])] - } - } - this.promoCodesDialog.show = true - }, - resetPromoCodesDialog() { - this.promoCodesDialog.show = false - this.promoCodesDialog.data = { - id: null, - wallet: null, - name: '', - extra: { - promo_codes: [] - } - } - }, - addPromoCodeToDialog() { - this.promoCodesDialog.data.extra.promo_codes.push({ - code: '', - discount_percent: 0, - active: true, - // fork-only: null = unlimited uses - max_uses: null - }) - }, - savePromoCodes() { - const data = this.promoCodesDialog.data - const wallet = _.findWhere(this.g.user.wallets, { - id: data.wallet - }) - if (!wallet) return - - const payload = { - ...data, - extra: { - ...data.extra, - promo_codes: this.normalizePromoCodes(data.extra?.promo_codes || []) - } - } - - LNbits.api - .request( - 'PUT', - '/events/api/v1/events/' + data.id, - wallet.adminkey, - payload - ) - .then(response => { - this.events = this.events.map(event => - event.id === data.id ? response.data : event - ) - Quasar.Notify.create({ - type: 'positive', - message: 'Promo codes updated.', - icon: null - }) - this.resetPromoCodesDialog() - }) - .catch(LNbits.utils.notifyApiError) - }, updateEvent(wallet, data) { LNbits.api .request( @@ -1139,7 +561,6 @@ window.PageEvents = { async created() { if (this.g.user.wallets.length) { this.getTickets() - this.getAllTickets() this.getEvents() this.getSettings() this.getPendingEvents() diff --git a/static/js/index.vue b/static/js/index.vue index 4795a3c..6e6891f 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -20,10 +20,10 @@
Republish to Nostr
- Re-emit every approved event so connected clients pick up the - latest tag set. Useful after the extension publisher changes - (e.g. new tickets_* tags) so existing events don't need a - per-event edit. + Re-emit every approved event so connected clients pick + up the latest tag set. Useful after the extension + publisher changes (e.g. new tickets_* tags) so existing + events don't need a per-event edit.
@@ -56,8 +56,8 @@ >
- Re-emit your approved events to Nostr relays. Useful after a - publisher upgrade or if a relay dropped your events. + Re-emit your approved events to Nostr relays. Useful after + a publisher upgrade or if a relay dropped your events.
@@ -228,13 +228,7 @@ @@ -243,86 +237,45 @@
-
-
Ticket waves
- -
-
-
-
- - - -
-
-
- -
-
Promo codes
- -
+
Promo codes
- No active promo codes for this event. + No promo codes for this event.
-
-
+
+
- + +
+
+ Discount: + % +
+
+ Status: + +
@@ -349,11 +302,9 @@ dense flat :rows="tickets" - :loading="ticketsTable.loading" row-key="id" :columns="ticketsTable.columns" v-model:pagination="ticketsTable.pagination" - @request="getTickets" > diff --git a/static/js/ticket.js b/static/js/ticket.js index 6ff55b6..82fbd6d 100644 --- a/static/js/ticket.js +++ b/static/js/ticket.js @@ -3,36 +3,16 @@ window.PageEventsTicket = { data() { return { ticketId: null, - ticket: null, - printMode: false, - qrSrc: '' + ticket: null } }, methods: { - async printWindow() { - this.printMode = true - await this.$nextTick() - await this.waitForPrintAssets() - setTimeout(() => window.print(), 50) - }, - async waitForPrintAssets() { - await this.$nextTick() - const img = document.querySelector('.ticket-print-qr') - if (!img) return - if (img.complete && img.naturalWidth > 0) return - await new Promise(resolve => { - const done = () => resolve() - img.addEventListener('load', done, {once: true}) - img.addEventListener('error', done, {once: true}) - setTimeout(done, 500) - }) + printWindow() { + window.print() } }, async created() { this.ticketId = this.$route.params.id - this.qrSrc = `/api/v1/qrcode?data=${encodeURIComponent( - `ticket://${this.ticketId}` - )}` try { const {data} = await LNbits.api.request( 'GET', @@ -42,8 +22,5 @@ window.PageEventsTicket = { } catch (error) { LNbits.utils.notifyApiError(error) } - window.addEventListener('afterprint', () => { - this.printMode = false - }) } } diff --git a/static/js/ticket.vue b/static/js/ticket.vue index 23a8dc4..3c932e1 100644 --- a/static/js/ticket.vue +++ b/static/js/ticket.vue @@ -36,53 +36,4 @@
- - -
- Ticket QR -
-
- - diff --git a/tests/test_capacity_guard.py b/tests/test_capacity_guard.py deleted file mode 100644 index 4125278..0000000 --- a/tests/test_capacity_guard.py +++ /dev/null @@ -1,112 +0,0 @@ -"""`amount_tickets` is the remaining count (aiolabs/events#34). - -`set_ticket_paid` decrements it on every sale and `sold` increments, so -subtracting `sold` from it removes each sale twice. That made the buyer -cap under-report and, once an event passed half its capacity, turned -`sold >= amount_tickets` true and declared it sold out with stock left. -Measured on aio-demo before the fix: 16 of 24 live events affected, -3 already refusing sales while tickets remained. -""" - -from datetime import datetime, timezone -from types import SimpleNamespace -from unittest.mock import AsyncMock - -import pytest -from fastapi import HTTPException - -from .. import views_api -from ..models import CreateTicket, Event - -SENTINEL = object() - - -def _event(amount_tickets: int, sold: int) -> Event: - return Event( - id="evt", - wallet="w", - name="Capacity", - info="", - closing_date="2030-01-01", - event_start_date="2030-01-01", - event_end_date="2030-01-02", - currency="sat", - price_per_ticket=0, # free path, so the guard is all that gates us - amount_tickets=amount_tickets, - sold=sold, - time=datetime.now(timezone.utc), - status="approved", - ) - - -@pytest.fixture -def issued(monkeypatch): - """Past the capacity guard the free path short-circuits to a sentinel.""" - monkeypatch.setattr( - views_api, "_issue_free_tickets", AsyncMock(return_value=SENTINEL) - ) - monkeypatch.setattr( - views_api, "_resolve_frontend_root", lambda data, req: "http://x" - ) - return SENTINEL - - -async def _buy(amount_tickets: int, sold: int, quantity: int, monkeypatch): - monkeypatch.setattr( - views_api, "get_event", AsyncMock(return_value=_event(amount_tickets, sold)) - ) - return await views_api.api_ticket_create( - "evt", CreateTicket(user_id="u1", quantity=quantity), SimpleNamespace() - ) - - -@pytest.mark.asyncio -async def test_last_ticket_still_sells(monkeypatch, issued): - """One left, one wanted. Previously refused once sold >= remaining.""" - assert await _buy(1, 99, 1, monkeypatch) is issued - - -@pytest.mark.asyncio -async def test_sold_out_only_when_actually_empty(monkeypatch, issued): - with pytest.raises(HTTPException) as exc: - await _buy(0, 100, 1, monkeypatch) - assert exc.value.detail == "Event is sold out." - - -@pytest.mark.asyncio -@pytest.mark.parametrize("sold", [0, 49, 50, 51, 500]) -async def test_remaining_alone_decides_availability(monkeypatch, issued, sold): - """The regression proper: with 50 left the event sells, whatever - `sold` says. Because remaining + sold is the original capacity, - `sold >= amount_tickets` first flips true at the halfway point — - sold=50 here — so an event locked itself once half its seats went. - The boundary cases (49/50/51) are the ones that matter.""" - assert await _buy(50, sold, 1, monkeypatch) is issued - - -@pytest.mark.asyncio -async def test_bulk_order_may_take_everything_left(monkeypatch, issued): - assert await _buy(10, 40, 10, monkeypatch) is issued - - -@pytest.mark.asyncio -async def test_bulk_order_over_capacity_reports_the_true_remainder(monkeypatch, issued): - """3 left, 5 wanted. The message must name the real remainder — it - used to say `3 - 40 = -37`. (`CreateTicket.quantity` is capped at 10 - by the model, so the overshoot is tested within that bound.)""" - with pytest.raises(HTTPException) as exc: - await _buy(3, 40, 5, monkeypatch) - assert exc.value.detail == "Only 3 ticket(s) remaining for this event." - - -@pytest.mark.asyncio -async def test_walk_an_event_to_capacity(monkeypatch, issued): - """50-seat event, one sale at a time, mirroring set_ticket_paid.""" - remaining, sold = 50, 0 - for _ in range(50): - assert await _buy(remaining, sold, 1, monkeypatch) is issued - remaining, sold = remaining - 1, sold + 1 - assert (remaining, sold) == (0, 50) - with pytest.raises(HTTPException) as exc: - await _buy(remaining, sold, 1, monkeypatch) - assert exc.value.detail == "Event is sold out." diff --git a/tests/test_crud_ticket_email.py b/tests/test_crud_ticket_email.py deleted file mode 100644 index de1d6ef..0000000 --- a/tests/test_crud_ticket_email.py +++ /dev/null @@ -1,48 +0,0 @@ -from unittest.mock import AsyncMock - -import pytest - -from .. import crud - - -@pytest.mark.asyncio -async def test_create_ticket_keeps_email_alongside_user_id(monkeypatch): - inserted = {} - - async def fake_insert(table, model): - inserted["table"] = table - inserted["model"] = model - - monkeypatch.setattr(crud.db, "insert", AsyncMock(side_effect=fake_insert)) - - ticket = await crud.create_ticket( - payment_hash="hash", - wallet="w", - event="e", - name="Ada", - email="ada@example.com", - user_id="u1", - ticket_id="t1", - ) - - assert inserted["table"] == "events.ticket" - assert inserted["model"].user_id == "u1" - assert inserted["model"].email == "ada@example.com" - assert inserted["model"].name == "Ada" - assert ticket.email == "ada@example.com" - - -@pytest.mark.asyncio -async def test_create_ticket_stores_empty_string_sentinels(monkeypatch): - inserted = {} - - async def fake_insert(table, model): - inserted["model"] = model - - monkeypatch.setattr(crud.db, "insert", AsyncMock(side_effect=fake_insert)) - - await crud.create_ticket( - payment_hash="hash", wallet="w", event="e", user_id="u1", ticket_id="t2" - ) - assert inserted["model"].email == "" - assert inserted["model"].name == "" diff --git a/tests/test_frontend_root.py b/tests/test_frontend_root.py deleted file mode 100644 index e725de7..0000000 --- a/tests/test_frontend_root.py +++ /dev/null @@ -1,52 +0,0 @@ -from types import SimpleNamespace - -import pytest -from fastapi import HTTPException -from lnbits.settings import settings - -from ..models import CreateTicket -from ..views_api import _allowed_frontend_origins, _resolve_frontend_root - - -@pytest.fixture -def lnbits_settings(monkeypatch): - monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/") - monkeypatch.setattr( - settings, "lnbits_cors_allowed_origins", ["https://app.example"], raising=False - ) - monkeypatch.setattr( - settings, - "lnbits_custom_frontend_url", - "https://Front.Example/login", - raising=False, - ) - - -def _request(base_url: str = "https://lnbits.example/"): - return SimpleNamespace(base_url=base_url) - - -def test_allowlist_collects_every_configured_origin(lnbits_settings): - assert _allowed_frontend_origins() == { - "https://lnbits.example", - "https://app.example", - "https://front.example", - } - - -def test_absent_frontend_url_falls_back_to_the_request_host(lnbits_settings): - data = CreateTicket(user_id="u1") - assert _resolve_frontend_root(data, _request()) == "https://lnbits.example" - - -def test_allowed_origin_is_returned_without_trailing_slash(lnbits_settings): - data = CreateTicket(user_id="u1", frontend_url="https://app.example/events/") - assert _resolve_frontend_root(data, _request()) == "https://app.example/events" - - -def test_unlisted_origin_is_rejected_loudly(lnbits_settings): - data = CreateTicket(user_id="u1", frontend_url="https://evil.example/events") - with pytest.raises(HTTPException) as exc: - _resolve_frontend_root(data, _request()) - assert exc.value.status_code == 400 - assert "frontend_url" in exc.value.detail diff --git a/tests/test_nostr_publish_pending.py b/tests/test_nostr_publish_pending.py deleted file mode 100644 index 3caace0..0000000 --- a/tests/test_nostr_publish_pending.py +++ /dev/null @@ -1,171 +0,0 @@ -"""The `nostr_publish_pending` marker and its lifecycle. - -Inventory reaches clients only through the republished NIP-52 calendar -event. These tests pin the invariant that makes drift recoverable: the -flag goes up before every attempt and comes down only on a confirmed -success, so every shape of failure — raised, skipped, never attempted — -leaves the row queryable by the sweep. -""" - -from datetime import datetime, timezone -from types import SimpleNamespace -from unittest.mock import AsyncMock - -import pytest - -from .. import nostr_hooks, services -from ..models import Event, Ticket - - -def _event(**kwargs) -> Event: - defaults = { - "id": "evt", - "wallet": "w", - "name": "Test", - "info": "", - "closing_date": "2030-01-01", - "event_start_date": "2030-01-01", - "event_end_date": "2030-01-02", - "currency": "sat", - "price_per_ticket": 1000, - "amount_tickets": 10, - "time": datetime.now(timezone.utc), - "status": "approved", - } - defaults.update(kwargs) - return Event(**defaults) - - -@pytest.fixture -def saved(monkeypatch): - """Capture every update_event write so ordering can be asserted.""" - writes: list[bool] = [] - - async def _update(event): - writes.append(event.nostr_publish_pending) - return event - - monkeypatch.setattr(nostr_hooks, "update_event", _update) - return writes - - -def _signer(monkeypatch, signer): - monkeypatch.setattr( - "lnbits.core.signers.resolve_for_wallet", AsyncMock(return_value=signer) - ) - - -def _publisher(monkeypatch, result): - monkeypatch.setattr( - nostr_hooks, "publish_event_to_nostr", AsyncMock(return_value=result) - ) - - -@pytest.mark.asyncio -async def test_success_raises_then_clears_the_flag(monkeypatch, saved): - event = _event() - _signer(monkeypatch, SimpleNamespace(pubkey="pk")) - _publisher(monkeypatch, SimpleNamespace(id="nid", created_at=123)) - - assert await nostr_hooks.publish_or_delete_nostr_event(event) is True - # Flagged before the attempt, cleared after it — in that order. - assert saved == [True, False] - assert event.nostr_publish_pending is False - assert event.nostr_event_id == "nid" - assert event.nostr_event_created_at == 123 - - -@pytest.mark.asyncio -async def test_missing_signer_leaves_the_flag_up(monkeypatch, saved): - event = _event() - _signer(monkeypatch, None) - - assert await nostr_hooks.publish_or_delete_nostr_event(event) is False - assert saved == [True] - assert event.nostr_publish_pending is True - - -@pytest.mark.asyncio -async def test_publisher_returning_none_leaves_the_flag_up(monkeypatch, saved): - """The no-NostrClient shape: nothing raised, nothing published.""" - event = _event() - _signer(monkeypatch, SimpleNamespace(pubkey="pk")) - _publisher(monkeypatch, None) - - assert await nostr_hooks.publish_or_delete_nostr_event(event) is False - assert saved == [True] - assert event.nostr_publish_pending is True - - -@pytest.mark.asyncio -async def test_raised_publish_leaves_the_flag_up(monkeypatch, saved): - event = _event() - _signer(monkeypatch, SimpleNamespace(pubkey="pk")) - monkeypatch.setattr( - nostr_hooks, - "publish_event_to_nostr", - AsyncMock(side_effect=RuntimeError("signer timeout")), - ) - - assert await nostr_hooks.publish_or_delete_nostr_event(event) is False - assert saved == [True] - assert event.nostr_publish_pending is True - - -@pytest.mark.asyncio -async def test_already_pending_row_is_not_re_flagged(monkeypatch, saved): - """The sweep re-publishing a flagged row writes once, not twice.""" - event = _event(nostr_publish_pending=True) - _signer(monkeypatch, SimpleNamespace(pubkey="pk")) - _publisher(monkeypatch, SimpleNamespace(id="nid", created_at=123)) - - assert await nostr_hooks.publish_or_delete_nostr_event(event) is True - assert saved == [False] - - -@pytest.mark.asyncio -async def test_delete_clears_the_flag_without_touching_the_coordinate( - monkeypatch, saved -): - """A take-down must not overwrite the id/created_at of the event it - just deleted — the kind-5 has its own.""" - event = _event(nostr_event_id="old", nostr_event_created_at=100) - _signer(monkeypatch, SimpleNamespace(pubkey="pk")) - _publisher(monkeypatch, SimpleNamespace(id="del", created_at=999)) - - assert await nostr_hooks.publish_or_delete_nostr_event(event, delete=True) is True - assert event.nostr_publish_pending is False - assert event.nostr_event_id == "old" - assert event.nostr_event_created_at == 100 - - -@pytest.mark.asyncio -async def test_sale_flags_the_event_in_the_same_write(monkeypatch): - """`set_ticket_paid` must flag inside its own update, so the counters - and "the relay doesn't know yet" land atomically.""" - event = _event(sold=4, amount_tickets=6) - seen: list[tuple[int, int, bool]] = [] - - async def _update_event(ev): - seen.append((ev.sold, ev.amount_tickets, ev.nostr_publish_pending)) - return ev - - monkeypatch.setattr(services, "update_ticket", AsyncMock()) - monkeypatch.setattr(services, "get_event", AsyncMock(return_value=event)) - monkeypatch.setattr(services, "update_event", _update_event) - monkeypatch.setattr(services, "publish_or_delete_nostr_event", AsyncMock()) - - ticket = Ticket( - id="t1", - wallet="w", - event="evt", - name="A", - email="a@example.com", - registered=False, - paid=False, - time=datetime.now(timezone.utc), - reg_timestamp=datetime.now(timezone.utc), - ) - await services.set_ticket_paid(ticket) - - assert seen == [(5, 5, True)] diff --git a/tests/test_nostr_timestamp.py b/tests/test_nostr_timestamp.py deleted file mode 100644 index 693a997..0000000 --- a/tests/test_nostr_timestamp.py +++ /dev/null @@ -1,32 +0,0 @@ -from itertools import pairwise - -from ..nostr_timestamp import monotonic_created_at - - -def test_no_prior_uses_now(): - assert monotonic_created_at(None, now=1000) == 1000 - - -def test_same_second_bumps_past_prior(): - # now == last: a naive int(time.time()) would tie and the relay would - # drop the update; we must produce a strictly newer stamp. - assert monotonic_created_at(1000, now=1000) == 1001 - - -def test_tracks_wallclock_once_seconds_elapse(): - assert monotonic_created_at(1000, now=1005) == 1005 - - -def test_steps_past_future_dated_prior(): - # clock skew / rapid bursts left the stored value ahead of now - assert monotonic_created_at(2000, now=1000) == 2001 - - -def test_strictly_increasing_same_second_burst(): - last = None - stamps = [] - for _ in range(5): - last = monotonic_created_at(last, now=1000) # clock frozen at 1000 - stamps.append(last) - assert stamps == [1000, 1001, 1002, 1003, 1004] - assert all(b > a for a, b in pairwise(stamps)) diff --git a/tests/test_promo.py b/tests/test_promo.py deleted file mode 100644 index b3bb77e..0000000 --- a/tests/test_promo.py +++ /dev/null @@ -1,218 +0,0 @@ -from datetime import datetime, timezone - -import pytest -from pydantic import ValidationError - -from ..models import ( - Event, - EventExtra, - PromoCode, - PublicEvent, - Ticket, - TicketWave, - ensure_ticket_waves, -) -from ..promo import basket_totals, normalize_code, promo_usage, remaining_uses - - -def _event(currency="sat", price=1000.0, codes=None) -> Event: - return Event( - id="evt", - wallet="w", - name="Test", - info="", - closing_date="2030-01-01", - event_start_date="2030-01-01", - event_end_date="2030-01-02", - currency=currency, - price_per_ticket=price, - amount_tickets=10, - time=datetime.now(timezone.utc), - extra=EventExtra(promo_codes=codes or []), - ) - - -def _wave(event: Event) -> TicketWave: - """Primary wave synthesized from the event's own price/currency. - - These tests exercise promo arithmetic rather than wave selection, so - pricing against the primary wave keeps their original meaning. - """ - return ensure_ticket_waves(event)[0] - - -def _ticket(code, paid=True) -> Ticket: - now = datetime.now(timezone.utc) - return Ticket( - id=f"t-{code}-{paid}", - wallet="w", - event="evt", - registered=False, - paid=paid, - time=now, - reg_timestamp=now, - extra={"applied_promo_code": code}, - ) - - -# --- model ----------------------------------------------------------------- - - -def test_code_is_stripped_and_uppercased(): - assert PromoCode(code=" half ", discount_percent=50).code == "HALF" - - -def test_empty_code_is_rejected(): - with pytest.raises(ValidationError): - PromoCode(code=" ", discount_percent=10) - - -@pytest.mark.parametrize( - "raw,expected", [(None, None), ("", None), (0, None), ("0", None), (3, 3), ("7", 7)] -) -def test_max_uses_normalisation(raw, expected): - assert PromoCode(code="X", max_uses=raw).max_uses == expected - - -def test_max_uses_below_one_rejected(): - with pytest.raises(ValidationError): - PromoCode(code="X", max_uses=-1) - - -def test_discount_bounds(): - with pytest.raises(ValidationError): - PromoCode(code="X", discount_percent=101) - - -def test_public_event_projection_drops_promo_codes(): - event = _event(codes=[PromoCode(code="SECRET", discount_percent=100)]) - public = PublicEvent.parse_obj(event.dict()).dict() - assert "promo_codes" not in public["extra"] - assert public["extra"]["payment_methods"] == [] - # the full model keeps them - assert Event.parse_obj(event.dict()).extra.promo_codes[0].code == "SECRET" - - -# --- helpers --------------------------------------------------------------- - - -def test_normalize_code(): - assert normalize_code(" save20 ") == "SAVE20" - assert normalize_code("") is None - assert normalize_code(None) is None - - -def test_promo_usage_counts_paid_rows_only_per_ticket(): - usage = promo_usage( - [_ticket("HALF"), _ticket("HALF"), _ticket("HALF", paid=False), _ticket(None)] - ) - assert usage == {"HALF": 2} - - -def test_remaining_uses(): - assert remaining_uses(PromoCode(code="X"), 5) is None - assert remaining_uses(PromoCode(code="X", max_uses=3), 1) == 2 - assert remaining_uses(PromoCode(code="X", max_uses=3), 9) == 0 - - -# --- basket_totals ----------------------------------------------------------- - - -def test_sat_totals_round_to_whole_sats(): - event = _event(price=333, codes=[PromoCode(code="OFF15", discount_percent=15)]) - totals = basket_totals(event, ["off15"], 1, {}, _wave(event)) - assert (totals.subtotal, totals.total, totals.discount) == (333, 283, 50) - assert totals.currency == "sat" - assert totals.discounts_applied[0].dict() == { - "code": "OFF15", - "discount_percent": 15, - "discount_fixed": None, - "amount_saved": 50, - } - - -def test_fiat_totals_round_to_cents_and_scale_by_quantity(): - event = _event( - currency="EUR", - price=19.99, - codes=[PromoCode(code="THIRD", discount_percent=33)], - ) - totals = basket_totals(event, ["THIRD"], 3, {}, _wave(event)) - assert totals.subtotal == 59.97 - assert totals.total == 40.18 - assert totals.discount == 19.79 - assert totals.discount + totals.total == totals.subtotal - - -def test_first_applicable_code_wins(): - event = _event( - codes=[ - PromoCode(code="A", discount_percent=10), - PromoCode(code="B", discount_percent=50), - ] - ) - assert ( - basket_totals(event, ["NOPE", "B", "A"], 1, {}, _wave(event)) - .discounts_applied[0] - .code - == "B" - ) - - -def test_inactive_unknown_zero_and_exhausted_codes_are_absent(): - event = _event( - codes=[ - PromoCode(code="OLD", discount_percent=20, active=False), - PromoCode(code="ZERO", discount_percent=0), - PromoCode(code="TWO", discount_percent=50, max_uses=2), - ] - ) - for codes, usage, qty in ( - (["OLD"], {}, 1), - (["ZERO"], {}, 1), - (["NOPE"], {}, 1), - (["TWO"], {"TWO": 2}, 1), - (["TWO"], {"TWO": 1}, 2), # not enough left for the whole quantity - ): - totals = basket_totals(event, codes, qty, usage, _wave(event)) - assert totals.discounts_applied == [] - assert totals.total == totals.subtotal and totals.discount == 0 - - -def test_unlimited_and_partially_used_codes_apply(): - event = _event( - codes=[ - PromoCode(code="TWO", discount_percent=50, max_uses=2), - PromoCode(code="INF", discount_percent=10), - ] - ) - assert basket_totals(event, ["TWO"], 1, {"TWO": 1}, _wave(event)).total == 500 - assert basket_totals(event, ["INF"], 10, {"INF": 999}, _wave(event)).total == 9000 - - -def test_full_discount_prices_to_zero(): - event = _event(codes=[PromoCode(code="FREE", discount_percent=100)]) - assert basket_totals(event, ["FREE"], 2, {}, _wave(event)).total == 0 - - -def test_price_comes_from_the_selected_wave_not_the_event(): - """Regression guard for the v1.6.8 merge. - - `sync_event_ticket_waves` makes `event.price_per_ticket` a roll-up of the - PRIMARY wave, so pricing off the event charged every buyer the first - wave's price no matter which wave they picked. - """ - event = _event(price=1000.0, codes=[PromoCode(code="HALF", discount_percent=50)]) - late = TicketWave( - id="late", - title="Late", - opening_date="2030-01-01", - closing_date="2030-02-01", - currency="sat", - price_per_ticket=2500.0, - amount_tickets=10, - ) - - assert basket_totals(event, [], 2, {}, _wave(event)).total == 2000 - assert basket_totals(event, [], 2, {}, late).total == 5000 - assert basket_totals(event, ["HALF"], 2, {}, late).total == 2500 diff --git a/tests/test_promo_api.py b/tests/test_promo_api.py deleted file mode 100644 index 65d1c62..0000000 --- a/tests/test_promo_api.py +++ /dev/null @@ -1,178 +0,0 @@ -from datetime import datetime, timezone -from types import SimpleNamespace -from unittest.mock import AsyncMock - -import pytest -from fastapi import HTTPException - -from .. import views_api -from ..models import CreateTicket, Event, EventExtra, PromoCode, PromoValidateRequest - - -def _event(codes) -> Event: - return Event( - id="evt", - wallet="w", - name="Test", - info="", - closing_date="2030-01-01", - event_start_date="2030-01-01", - event_end_date="2030-01-02", - currency="sat", - price_per_ticket=1000, - amount_tickets=10, - time=datetime.now(timezone.utc), - extra=EventExtra(promo_codes=codes), - status="approved", - ) - - -@pytest.fixture -def event(monkeypatch): - ev = _event( - [ - PromoCode(code="HALF", discount_percent=50, max_uses=2), - PromoCode(code="OLD", discount_percent=20, active=False), - ] - ) - monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=ev)) - monkeypatch.setattr( - views_api, "event_promo_usage", AsyncMock(return_value={"HALF": 1}) - ) - return ev - - -@pytest.mark.asyncio -async def test_validate_returns_v2_shaped_totals(event): - totals = await views_api.api_validate_promo_codes( - "evt", PromoValidateRequest(codes=["half"], quantity=1) - ) - assert totals.dict() == { - "subtotal": 1000, - "discount": 500, - "total": 500, - "currency": "sat", - "discounts_applied": [ - { - "code": "HALF", - "discount_percent": 50, - "discount_fixed": None, - "amount_saved": 500, - } - ], - } - - -@pytest.mark.asyncio -async def test_validate_is_advisory_for_bad_codes(event): - for codes, qty in ((["OLD"], 1), (["NOPE"], 1), (["HALF"], 2)): - totals = await views_api.api_validate_promo_codes( - "evt", PromoValidateRequest(codes=codes, quantity=qty) - ) - assert totals.discounts_applied == [] and totals.total == totals.subtotal - - -@pytest.mark.asyncio -async def test_validate_unknown_event_is_404(monkeypatch): - monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=None)) - with pytest.raises(HTTPException) as exc: - await views_api.api_validate_promo_codes( - "nope", PromoValidateRequest(codes=["X"]) - ) - assert exc.value.status_code == 404 - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "code,quantity,detail", - [ - ("NOPE", 1, "Invalid promo code."), - ("old", 1, "Promo code is not active."), - ("HALF", 2, "Only 1 use(s) left on this promo code."), - ], -) -async def test_purchase_rejects_bad_codes_before_any_invoice( - event, monkeypatch, code, quantity, detail -): - monkeypatch.setattr( - views_api, - "create_payment_request", - AsyncMock(side_effect=AssertionError("must not be called")), - ) - data = CreateTicket(user_id="u1", promo_code=code, quantity=quantity) - with pytest.raises(HTTPException) as exc: - await views_api.api_ticket_create( - "evt", data, SimpleNamespace(base_url="http://lnbits.local/") - ) - assert exc.value.status_code == 400 - assert exc.value.detail == detail - - -@pytest.mark.asyncio -async def test_purchase_reports_fully_redeemed(event, monkeypatch): - monkeypatch.setattr( - views_api, "event_promo_usage", AsyncMock(return_value={"HALF": 2}) - ) - monkeypatch.setattr( - views_api, - "create_payment_request", - AsyncMock(side_effect=AssertionError("must not be called")), - ) - with pytest.raises(HTTPException) as exc: - await views_api.api_ticket_create( - "evt", - CreateTicket(user_id="u1", promo_code="HALF"), - SimpleNamespace(base_url="http://lnbits.local/"), - ) - assert exc.value.detail == "Promo code has been fully redeemed." - - -@pytest.fixture -def update_env(monkeypatch): - stored = _event([PromoCode(code="KEEP", discount_percent=10)]) - monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=stored)) - monkeypatch.setattr( - views_api, - "get_settings", - AsyncMock(return_value=SimpleNamespace(auto_approve=True)), - ) - monkeypatch.setattr(views_api, "update_event", AsyncMock(side_effect=lambda e: e)) - monkeypatch.setattr(views_api, "publish_or_delete_nostr_event", AsyncMock()) - return stored - - -def _update_payload(extra: dict) -> dict: - return { - "wallet": "w", - "name": "Test", - "info": "", - "closing_date": "2030-01-01", - "event_start_date": "2030-01-01", - "event_end_date": "2030-01-02", - "amount_tickets": 10, - "price_per_ticket": 1000, - "extra": extra, - } - - -def _wallet(): - return SimpleNamespace(wallet=SimpleNamespace(id="w", user="u1")) - - -@pytest.mark.asyncio -async def test_update_without_promo_key_keeps_stored_codes(update_env): - from ..models import CreateEvent - - data = CreateEvent.parse_obj(_update_payload({"email_notifications": True})) - event = await views_api.api_event_update("evt", data, _wallet()) - assert [pc.code for pc in event.extra.promo_codes] == ["KEEP"] - assert event.extra.email_notifications is True - - -@pytest.mark.asyncio -async def test_update_with_empty_promo_list_clears(update_env): - from ..models import CreateEvent - - data = CreateEvent.parse_obj(_update_payload({"promo_codes": []})) - event = await views_api.api_event_update("evt", data, _wallet()) - assert event.extra.promo_codes == [] diff --git a/tests/test_publish_active_wave.py b/tests/test_publish_active_wave.py deleted file mode 100644 index 964a0c4..0000000 --- a/tests/test_publish_active_wave.py +++ /dev/null @@ -1,195 +0,0 @@ -"""The NIP-52 tags describe the ACTIVE ticket wave, not the roll-up (#61). - -Upstream v1.6.8 moved price, currency and inventory onto time-boxed waves -and made the event-level fields derived: `price_per_ticket` and `currency` -become the PRIMARY (first) wave's, `amount_tickets` the SUM across every -wave. Publishing those would advertise the early-bird price after early -bird closed and count stock in waves that have not opened yet. -""" - -from datetime import datetime, timedelta, timezone -from typing import cast - -import pytest - -from ..models import ( - Event, - EventExtra, - TicketWave, - advertised_wave_key, - sync_event_ticket_waves, -) -from ..nostr_publisher import build_nip52_event - -PUBKEY = "a" * 64 -TODAY = datetime.now(timezone.utc).date() - - -def _day(offset: int) -> str: - return (TODAY + timedelta(days=offset)).isoformat() - - -def _wave( - wave_id: str, - price: float, - stock: int, - opens: int, - closes: int, - *, - currency: str = "EUR", - allow_fiat: bool = False, -) -> TicketWave: - return TicketWave( - id=wave_id, - title=wave_id, - opening_date=_day(opens), - closing_date=_day(closes), - currency=currency, - price_per_ticket=price, - amount_tickets=stock, - allow_fiat=allow_fiat, - fiat_currency="GBP", - ) - - -def _event(waves: list[TicketWave], sold: int = 0) -> Event: - event = Event( - id="evt", - wallet="w", - name="Waves", - info="", - closing_date=_day(30), - event_start_date=_day(30), - event_end_date=_day(30), - currency="sat", - price_per_ticket=0, - amount_tickets=0, - sold=sold, - time=datetime.now(timezone.utc), - status="approved", - extra=EventExtra(ticket_waves=waves), - ) - # Mirrors the CRUD layer, which syncs on every read and write. - return cast(Event, sync_event_ticket_waves(event)) - - -def _tags(event: Event) -> dict[str, str]: - return {t[0]: t[1] for t in build_nip52_event(event, PUBKEY).tags if len(t) > 1} - - -CLOSED_EARLY_BIRD = _wave("early", 10.0, 5, -10, -1) -OPEN_REGULAR = _wave("regular", 25.0, 40, 0, 20) -UNOPENED_VIP = _wave("vip", 50.0, 10, 5, 25) - - -def test_closed_wave_price_is_not_advertised(): - """The defect this fixes: early bird closed yesterday.""" - event = _event([CLOSED_EARLY_BIRD, OPEN_REGULAR, UNOPENED_VIP]) - - # What the event-level roll-up would have published. - assert event.price_per_ticket == 10.0 - assert event.amount_tickets == 55 - - tags = _tags(event) - assert tags["tickets_price"] == "25.0" - assert tags["tickets_available"] == "40" - - -def test_unopened_wave_stock_is_not_counted(): - event = _event([OPEN_REGULAR, UNOPENED_VIP]) - assert _tags(event)["tickets_available"] == "40" - - -def test_cheapest_open_wave_wins_when_several_are_open(): - """A publisher cannot ask which wave the buyer wants, so it advertises - the price a buyer is actually able to obtain.""" - cheaper = _wave("cheap", 15.0, 3, 0, 10) - tags = _tags(_event([OPEN_REGULAR, cheaper])) - assert tags["tickets_price"] == "15.0" - assert tags["tickets_available"] == "3" - - -@pytest.mark.parametrize( - "waves", - [ - pytest.param([CLOSED_EARLY_BIRD], id="all-closed"), - pytest.param([UNOPENED_VIP], id="not-yet-open"), - pytest.param([_wave("only", 25.0, 0, 0, 20)], id="sold-out"), - ], -) -def test_no_open_wave_publishes_zero_availability(waves): - """Never omit the tag: omission meant "unlimited" (#34, #62).""" - tags = _tags(_event(waves)) - assert tags["tickets_available"] == "0" - - -def test_currency_and_fiat_follow_the_advertised_wave(): - fiat_primary = _wave("a", 10.0, 0, -10, -1, currency="GBP", allow_fiat=True) - sats_open = _wave("b", 2500.0, 9, 0, 20, currency="sat", allow_fiat=False) - tags = _tags(_event([fiat_primary, sats_open])) - - assert tags["tickets_currency"] == "sat" - assert "tickets_allow_fiat" not in tags - # Must agree with tickets_allow_fiat — they are the same fact, and a - # client rendering a fiat button here would hit a purchase-time refusal. - assert tags["tickets_payment_methods"] == "lightning" - - -def test_payment_methods_offer_fiat_when_the_advertised_wave_accepts_it(): - tags = _tags( - _event( - [ - _wave("a", 10.0, 0, -10, -1, allow_fiat=False), - _wave("b", 25.0, 9, 0, 20, allow_fiat=True), - ] - ) - ) - assert tags["tickets_allow_fiat"] == "true" - assert tags["tickets_payment_methods"] == "lightning,fiat" - - -def test_sold_stays_event_level(): - """`tickets_sold` is the total paid across all waves.""" - assert _tags(_event([OPEN_REGULAR], sold=7))["tickets_sold"] == "7" - - -def test_advertised_wave_key_tracks_the_published_wave(): - assert advertised_wave_key(_event([CLOSED_EARLY_BIRD, OPEN_REGULAR])) == "regular" - # Published while nothing is on sale — a real state, distinct from the - # NULL that means "never published". - assert advertised_wave_key(_event([CLOSED_EARLY_BIRD])) == "" - - -def test_published_rails_drop_fiat_when_the_advertised_wave_cannot_take_it(): - """The webapp always sets `extra.payment_methods`, so the explicit-list - path is the normal one — and it used to ignore the wave entirely. - - That published `tickets_payment_methods: lightning,fiat` beside an - absent `tickets_allow_fiat`, and a card button the purchase endpoint - then refused ("Fiat payments are not enabled for this ticket wave"). - """ - event = _event( - [ - _wave("a", 10.0, 0, -10, -1, allow_fiat=True), - _wave("b", 25.0, 9, 0, 20, allow_fiat=False), - ] - ) - event.extra.payment_methods = ["lightning", "fiat"] - tags = _tags(event) - - assert "tickets_allow_fiat" not in tags - assert tags["tickets_payment_methods"] == "lightning" - - -def test_published_rails_keep_fiat_when_the_advertised_wave_takes_it(): - event = _event( - [ - _wave("a", 10.0, 0, -10, -1, allow_fiat=False), - _wave("b", 25.0, 9, 0, 20, allow_fiat=True), - ] - ) - event.extra.payment_methods = ["lightning", "fiat"] - tags = _tags(event) - - assert tags["tickets_allow_fiat"] == "true" - assert tags["tickets_payment_methods"] == "lightning,fiat" diff --git a/tests/test_publish_availability.py b/tests/test_publish_availability.py deleted file mode 100644 index 0ef88a5..0000000 --- a/tests/test_publish_availability.py +++ /dev/null @@ -1,56 +0,0 @@ -"""`tickets_available` is always published, including zero (#34). - -Omitting the tag used to mean "unlimited", which contradicted every other -reader: `api_get_event` and `api_ticket_create` both treat -`amount_tickets < 1` as sold out. On aio-demo three zero-capacity events -advertised "Unlimited tickets" on the card while the detail endpoint and -the purchase both returned 410. -""" - -from datetime import datetime, timezone - -import pytest - -from ..models import Event -from ..nostr_publisher import build_nip52_event - -PUBKEY = "a" * 64 - - -def _event(amount_tickets: int, sold: int = 0) -> Event: - return Event( - id="evt", - wallet="w", - name="Availability", - info="", - closing_date="2030-01-01", - event_start_date="2030-01-01T18:00", - event_end_date="2030-01-01T22:00", - currency="sat", - price_per_ticket=0, - amount_tickets=amount_tickets, - sold=sold, - time=datetime.now(timezone.utc), - status="approved", - ) - - -def _tags(event: Event) -> dict[str, str]: - return {t[0]: t[1] for t in build_nip52_event(event, PUBKEY).tags if len(t) > 1} - - -@pytest.mark.parametrize("amount", [0, 1, 50]) -def test_tickets_available_is_always_present(amount): - assert _tags(_event(amount))["tickets_available"] == str(amount) - - -def test_zero_capacity_reads_as_sold_out_not_unlimited(): - """The regression: an absent tag is what clients render as unlimited.""" - tags = _tags(_event(0, sold=0)) - assert "tickets_available" in tags - assert tags["tickets_available"] == "0" - - -def test_sold_out_after_selling_through_still_publishes_zero(): - assert _tags(_event(0, sold=25))["tickets_available"] == "0" - assert _tags(_event(0, sold=25))["tickets_sold"] == "25" diff --git a/tests/test_publish_confirmation.py b/tests/test_publish_confirmation.py deleted file mode 100644 index 9615209..0000000 --- a/tests/test_publish_confirmation.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Publish confirmation against the relay's `OK` (aiolabs/events#56). - -Queueing is not delivery. nostrclient drops an EVENT outright when no -relay is connected and answers `OK false`; before this, that reply was -discarded and the publish reported success, which once cleared the -`nostr_publish_pending` flag on a republish that never left the -building. -""" - -import asyncio -import json - -import pytest - -from ..nostr import nostr_client as nc -from ..nostr.event import NostrEvent - - -def _event(event_id: str = "a" * 64) -> NostrEvent: - e = NostrEvent(pubkey="b" * 64, created_at=0, kind=31923) - e.id = event_id - return e - - -async def _publish_and_reply(client, event, reply, delay=0.01): - """Start a publish, then feed `reply` through the receive path.""" - task = asyncio.create_task(client.publish_nostr_event(event)) - await asyncio.sleep(delay) # let the future register - if reply is not None: - client.receive_event_queue.put_nowait(reply) - consumer = asyncio.create_task(client.get_event()) - await asyncio.sleep(delay) - consumer.cancel() - return await task - - -@pytest.mark.asyncio -async def test_accepted_publish_returns_true(): - client = nc.NostrClient() - event = _event() - ok = json.dumps(["OK", event.id, True, ""]) - assert await _publish_and_reply(client, event, ok) is True - assert client._pending_oks == {} - - -@pytest.mark.asyncio -async def test_rejected_publish_returns_false(): - """The shape that bit us: no relay connected, so nostrclient's - router answers `OK false` without the event ever being sent.""" - client = nc.NostrClient() - event = _event() - ok = json.dumps(["OK", event.id, False, "error: no relays connected"]) - assert await _publish_and_reply(client, event, ok) is False - assert client._pending_oks == {} - - -@pytest.mark.asyncio -async def test_missing_ok_times_out_as_unconfirmed(monkeypatch): - monkeypatch.setattr(nc, "PUBLISH_OK_TIMEOUT_SECONDS", 0.05) - client = nc.NostrClient() - assert await _publish_and_reply(client, _event(), None) is False - assert client._pending_oks == {} - - -@pytest.mark.asyncio -async def test_ok_for_a_different_event_does_not_settle_ours(monkeypatch): - monkeypatch.setattr(nc, "PUBLISH_OK_TIMEOUT_SECONDS", 0.05) - client = nc.NostrClient() - other = json.dumps(["OK", "c" * 64, True, ""]) - assert await _publish_and_reply(client, _event(), other) is False - - -@pytest.mark.asyncio -async def test_get_event_swallows_ok_and_forwards_everything_else(): - client = nc.NostrClient() - client.receive_event_queue.put_nowait(json.dumps(["OK", "d" * 64, True, ""])) - forwarded = json.dumps(["EVENT", "sub", {"id": "e" * 64}]) - client.receive_event_queue.put_nowait(forwarded) - assert await client.get_event() == forwarded - - -@pytest.mark.asyncio -async def test_disconnect_settles_inflight_publishes_immediately(monkeypatch): - """A dropped socket must not leave the caller waiting the full - timeout for an OK that can no longer arrive.""" - monkeypatch.setattr(nc, "PUBLISH_OK_TIMEOUT_SECONDS", 30) - client = nc.NostrClient() - event = _event() - task = asyncio.create_task(client.publish_nostr_event(event)) - await asyncio.sleep(0.01) - - client.receive_event_queue.put_nowait(ValueError("WebSocket closed")) - consumer = asyncio.create_task(client.get_event()) - await asyncio.sleep(0.01) - consumer.cancel() - - assert await asyncio.wait_for(task, 1) is False - - -def test_settle_ok_ignores_non_ok_frames(): - client = nc.NostrClient() - assert client._settle_ok(json.dumps(["EVENT", "sub", {}])) is False - assert client._settle_ok(json.dumps(["EOSE", "sub"])) is False - assert client._settle_ok("not json") is False - assert client._settle_ok(json.dumps(["OK", "f" * 64, True, ""])) is True diff --git a/tests/test_ticket_email.py b/tests/test_ticket_email.py deleted file mode 100644 index f9b6c96..0000000 --- a/tests/test_ticket_email.py +++ /dev/null @@ -1,89 +0,0 @@ -from datetime import datetime, timezone - -from lnbits.settings import settings - -from ..models import Event, Ticket -from ..services import _ticket_notification_payload, build_ticket_email - - -def _event(**overrides) -> Event: - data = { - "id": "evt1", - "wallet": "w", - "name": "Test Event", - "info": "", - "closing_date": "2030-01-01", - "event_start_date": "2030-01-01T16:00:00+01:00", - "event_end_date": "2030-01-01T20:00:00+01:00", - "location": "The Chateau", - "amount_tickets": 10, - "price_per_ticket": 5, - "time": datetime.now(timezone.utc), - } - data.update(overrides) - return Event(**data) - - -def _ticket(**overrides) -> Ticket: - now = datetime.now(timezone.utc) - data = { - "id": "tkt1", - "wallet": "w", - "event": "evt1", - "name": "Ada", - "email": "ada@example.com", - "registered": False, - "paid": True, - "time": now, - "reg_timestamp": now, - } - data.update(overrides) - return Ticket(**data) - - -def test_email_carries_date_message_id_and_display_name(monkeypatch): - monkeypatch.setattr(settings, "lnbits_site_title", "Oyez!") - msg = build_ticket_email( - "tickets@example.org", ["ada@example.com"], "Subj", "text", "

html

" - ) - assert msg["Date"] - assert msg["Message-ID"].endswith("@example.org>") - assert msg["From"] == "Oyez! " - parts = [p.get_content_type() for p in msg.get_payload()] - assert parts == ["text/plain", "text/html"] - - -def test_email_attaches_the_ticket_card(monkeypatch): - monkeypatch.setattr(settings, "lnbits_site_title", "Oyez!") - png = b"\x89PNG\r\n\x1a\n" + b"0" * 32 - msg = build_ticket_email( - "tickets@example.org", - ["ada@example.com"], - "Subj", - "text", - "

html

", - attachments=[("ticket-test-8auNuuaB.png", png)], - ) - assert msg.get_content_type() == "multipart/mixed" - body, attachment = msg.get_payload() - assert body.get_content_type() == "multipart/alternative" - assert attachment.get_content_type() == "image/png" - assert attachment.get_filename() == "ticket-test-8auNuuaB.png" - assert attachment.get_payload(decode=True) == png - - -def test_payload_includes_event_details_and_qr(monkeypatch): - monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/") - subject, text, html = _ticket_notification_payload(_ticket(), _event()) - assert "Test Event" in subject - for needle in ( - "Event: Test Event", - "Where: The Chateau", - "Name on ticket: Ada", - "Ticket ID: tkt1", - "at the door", - ): - assert needle in text - assert "/events/api/v1/ticket-card/tkt1" in text - assert "' in html diff --git a/tests/test_ticket_models.py b/tests/test_ticket_models.py deleted file mode 100644 index 6d782d2..0000000 --- a/tests/test_ticket_models.py +++ /dev/null @@ -1,219 +0,0 @@ -import pytest -from pydantic import ValidationError - -from ..models import ( - CreateEvent, - CreateTicket, - EventExtra, - PromoCode, - PublicEvent, - PublicEventExtra, - TicketWave, - effective_payment_methods, -) - - -def _ticket(**kwargs) -> CreateTicket: - return CreateTicket(**kwargs) - - -def test_user_id_only_is_a_valid_identity(): - assert _ticket(user_id="u1").user_id == "u1" - - -def test_name_and_email_is_a_valid_guest_identity(): - ticket = _ticket(name="Guest", email="guest@example.com") - assert ticket.user_id is None - assert ticket.email == "guest@example.com" - - -def test_user_id_may_carry_an_email_for_delivery(): - ticket = _ticket(user_id="u1", email="me@example.com") - assert ticket.user_id == "u1" - assert ticket.email == "me@example.com" - - -@pytest.mark.parametrize( - "kwargs", - [ - {}, - {"name": "Guest"}, - {"email": "guest@example.com"}, - ], -) -def test_missing_identity_is_rejected(kwargs): - with pytest.raises(ValidationError): - _ticket(**kwargs) - - -@pytest.mark.parametrize( - "url,expected", - [ - ("https://app.example/events", "https://app.example/events"), - ("https://app.example/events/", "https://app.example/events"), - ("http://localhost:5173/", "http://localhost:5173"), - (" ", None), - ], -) -def test_frontend_url_is_normalised(url, expected): - assert _ticket(user_id="u1", frontend_url=url).frontend_url == expected - - -@pytest.mark.parametrize( - "url", - [ - "/events", # relative - "ftp://app.example/events", - "https://app.example/events?x=1", - "https://app.example/events#top", - "https://app.example/../events", - "https://" + "a" * 520, - ], -) -def test_frontend_url_rejects_unsafe_values(url): - with pytest.raises(ValidationError): - _ticket(user_id="u1", frontend_url=url) - - -def _event(**overrides) -> CreateEvent: - data = { - "wallet": "w", - "name": "Test", - "info": "", - "closing_date": "2030-01-01", - "event_start_date": "2030-01-01", - "event_end_date": "2030-01-02", - "amount_tickets": 10, - "price_per_ticket": 5, - } - data.update(overrides) - return CreateEvent(**data) - - -def test_effective_payment_methods_legacy_rule(): - assert effective_payment_methods(_event()) == ["lightning"] - assert effective_payment_methods(_event(allow_fiat=True)) == ["lightning", "fiat"] - - -def test_effective_payment_methods_explicit_list_wins(): - event = _event(allow_fiat=True, extra=EventExtra(payment_methods=["fiat"])) - assert effective_payment_methods(event) == ["fiat"] - - -def test_payment_methods_are_normalised_and_deduplicated(): - extra = EventExtra(payment_methods=["Fiat", " lightning ", "fiat"]) - assert extra.payment_methods == ["fiat", "lightning"] - assert EventExtra(payment_methods="lightning,fiat").payment_methods == [ - "lightning", - "fiat", - ] - - -def test_unknown_payment_method_is_rejected(): - with pytest.raises(ValidationError): - EventExtra(payment_methods=["cash"]) - - -# --- public projection ------------------------------------------------------ - - -def test_public_extra_exposes_waves_but_never_promo_codes(): - """A buyer needs a wave id to choose a tier, so waves are public; promo - codes stay organizer-only (aiolabs/events#61, v1.6.1-aio.12).""" - organizer = EventExtra( - promo_codes=[PromoCode(code="SECRET", discount_percent=50)], - ticket_waves=[ - TicketWave( - id="early", - title="Early", - opening_date="2030-01-01", - closing_date="2030-02-01", - currency="sat", - price_per_ticket=10, - amount_tickets=5, - ) - ], - ) - - public = PublicEventExtra(**organizer.dict()) - - assert [wave.id for wave in public.ticket_waves] == ["early"] - assert public.ticket_waves[0].price_per_ticket == 10 - assert not hasattr(public, "promo_codes") - assert "promo_codes" not in public.dict() - - -def test_public_event_response_carries_waves(): - """End of the chain: what `GET /events/{id}` actually serialises.""" - wave = TicketWave( - id="regular", - title="Regular", - opening_date="2030-01-01", - closing_date="2030-02-01", - currency="sat", - price_per_ticket=25, - amount_tickets=40, - ) - organizer_extra = EventExtra( - ticket_waves=[wave], promo_codes=[PromoCode(code="SECRET")] - ) - public = PublicEvent( - id="evt", - name="Test", - info="", - canceled=False, - event_start_date="2030-01-01", - currency="sat", - price_per_ticket=25, - banner=None, - extra=PublicEventExtra(**organizer_extra.dict()), - ) - body = public.dict() - - assert [w["id"] for w in body["extra"]["ticket_waves"]] == ["regular"] - assert "promo_codes" not in body["extra"] - - -# --- rails vs per-wave fiat -------------------------------------------------- - - -def _fiat_wave(allow_fiat: bool): - from ..models import TicketWave - - return TicketWave( - id="w", - title="w", - opening_date="2030-01-01", - closing_date="2030-02-01", - currency="EUR", - price_per_ticket=10, - amount_tickets=5, - allow_fiat=allow_fiat, - ) - - -def test_explicit_rails_drop_fiat_for_a_wave_that_cannot_take_it(): - """The organiser's rail list is event-level; fiat is per-wave. - - Without this the NIP-52 tag advertises fiat and the checkout renders a - card button that `api_ticket_create` refuses with "Fiat payments are - not enabled for this ticket wave" (reported on aio-demo). - """ - event = _event() - event.extra.payment_methods = ["lightning", "fiat"] - - assert effective_payment_methods(event, _fiat_wave(True)) == ["lightning", "fiat"] - assert effective_payment_methods(event, _fiat_wave(False)) == ["lightning"] - - -def test_event_level_question_still_reports_every_rail(): - """No wave means "what did the organiser enable at all" — unfiltered.""" - event = _event() - event.extra.payment_methods = ["lightning", "fiat"] - assert effective_payment_methods(event) == ["lightning", "fiat"] - - -def test_fiat_only_rails_on_a_non_fiat_wave_leave_nothing_purchasable(): - event = _event() - event.extra.payment_methods = ["fiat"] - assert effective_payment_methods(event, _fiat_wave(False)) == [] diff --git a/tests/test_ticket_qr.py b/tests/test_ticket_qr.py deleted file mode 100644 index c65cb03..0000000 --- a/tests/test_ticket_qr.py +++ /dev/null @@ -1,64 +0,0 @@ -from io import BytesIO - -from PIL import Image - -from ..qr import make_qr_png - - -def test_make_qr_png_renders_requested_size(): - img = make_qr_png("ticket://abc123", size=200) - assert img.size == (200, 200) - - -def test_make_qr_png_pastes_a_centred_logo(): - logo = Image.new("RGBA", (64, 64), (255, 0, 0, 255)) - img = make_qr_png("ticket://abc123", size=300, logo=logo) - assert img.size == (300, 300) - # The centre pixel is inside the pasted logo, so it is red — a plain - # QR would only ever have black or white there. - assert img.getpixel((150, 150))[:3] == (255, 0, 0) - out = BytesIO() - img.save(out, format="PNG") - assert out.getvalue().startswith(b"\x89PNG") - - -def test_render_ticket_card_is_self_describing(): - from datetime import datetime, timezone - - from ..models import Event, Ticket - from ..qr import format_event_when, render_ticket_card, ticket_card_filename - - now = datetime.now(timezone.utc) - event = Event( - id="evt1", - wallet="w", - name="Château du Faune | Uru Ecstatic Dance", - info="", - closing_date="2027-02-19", - event_start_date="2027-02-19T16:00:00+01:00", - event_end_date="2027-02-19T20:00:00+01:00", - location="The Chateau", - amount_tickets=10, - price_per_ticket=15, - time=now, - ) - ticket = Ticket( - id="8auNuuaBv7TFGj7BYoVnTN", - wallet="w", - event="evt1", - name="Guest Test", - email="g@example.com", - registered=False, - paid=True, - time=now, - reg_timestamp=now, - ) - assert format_event_when(event) == "Fri 19 Feb 2027, 16:00 - 20:00" - assert ( - ticket_card_filename(ticket, event) - == "ticket-ch-teau-du-faune-uru-ecstatic-dance-8auNuuaB.png" - ) - card = render_ticket_card(ticket, event, site_title="Oyez!") - assert card.width == 800 and card.height > 800 - # QR area is centred; its finder pattern is black - assert card.getpixel((400, card.height // 2)) in ((0, 0, 0), (255, 255, 255)) diff --git a/tests/test_wave_capacity_required.py b/tests/test_wave_capacity_required.py deleted file mode 100644 index 5f40c4c..0000000 --- a/tests/test_wave_capacity_required.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Capacity is required per ticket wave (#34, #62). - -"Capacity is always required, there is no unlimited" was settled when -capacity was one number on the event. Since v1.6.8 it is per-wave and -`event.amount_tickets` is a derived roll-up, so the rule has to bite where -the organiser sets it. A zero-capacity wave can never be active -(`get_active_ticket_waves` requires `> 0`), so it is the wave-level form of -the trap #62 removed: an event that looks on sale but refuses every -purchase. -""" - -from datetime import datetime, timedelta, timezone -from http import HTTPStatus - -import pytest -from fastapi import HTTPException - -from ..models import CreateEvent, Event, EventExtra, TicketWave -from ..views_api import _validate_wave_capacity - -TODAY = datetime.now(timezone.utc).date() - - -def _day(offset: int) -> str: - return (TODAY + timedelta(days=offset)).isoformat() - - -def _wave(wave_id: str, stock: int) -> TicketWave: - return TicketWave( - id=wave_id, - title=wave_id, - opening_date=_day(0), - closing_date=_day(20), - currency="sat", - price_per_ticket=10, - amount_tickets=stock, - ) - - -def _create(waves=None, amount_tickets=10) -> CreateEvent: - return CreateEvent( - wallet="w", - name="Fete", - info="", - event_start_date=_day(30), - currency="sat", - price_per_ticket=10, - amount_tickets=amount_tickets, - extra=EventExtra(ticket_waves=waves or []), - ) - - -def _stored(waves) -> Event: - return Event( - id="evt", - wallet="w", - name="Fete", - info="", - closing_date=_day(30), - event_start_date=_day(30), - currency="sat", - price_per_ticket=10, - amount_tickets=sum(w.amount_tickets for w in waves), - time=datetime.now(timezone.utc), - extra=EventExtra(ticket_waves=waves), - ) - - -def _detail(exc_info) -> str: - assert exc_info.value.status_code == HTTPStatus.BAD_REQUEST - return exc_info.value.detail - - -def test_wave_with_capacity_is_accepted(): - _validate_wave_capacity(_create([_wave("early", 5), _wave("late", 10)])) - - -def test_zero_capacity_wave_is_rejected_on_create(): - with pytest.raises(HTTPException) as exc_info: - _validate_wave_capacity(_create([_wave("early", 5), _wave("late", 0)])) - assert "late" in _detail(exc_info) - - -def test_event_submitted_without_waves_is_checked_through_its_primary_wave(): - """No waves means the event gets a synthesized primary wave seeded from - `amount_tickets`, so the rule must see that rather than an empty list.""" - _validate_wave_capacity(_create(waves=None, amount_tickets=10)) - - with pytest.raises(HTTPException) as exc_info: - _validate_wave_capacity(_create(waves=None, amount_tickets=0)) - assert "Primary wave" in _detail(exc_info) - - -def test_selling_a_wave_out_does_not_block_later_edits(): - """Zero is where a wave legitimately ends up. Rejecting it on edit would - make a sold-out event uneditable.""" - sold_out = _wave("early", 0) - existing = _stored([sold_out, _wave("late", 10)]) - - _validate_wave_capacity(_create([sold_out, _wave("late", 10)]), existing) - - -def test_new_wave_added_by_an_edit_still_needs_capacity(): - existing = _stored([_wave("early", 5)]) - - with pytest.raises(HTTPException) as exc_info: - _validate_wave_capacity( - _create([_wave("early", 5), _wave("brand-new", 0)]), existing - ) - assert "brand-new" in _detail(exc_info) - - -def test_legacy_zero_capacity_event_stays_editable(): - """An event stored before the rule existed keeps its primary wave id, so - an edit is not blocked — otherwise the only way to fix it would be - barred.""" - existing = _stored([_wave("primary", 0)]) - _validate_wave_capacity(_create([_wave("primary", 0)]), existing) diff --git a/tests/test_wave_preservation_on_edit.py b/tests/test_wave_preservation_on_edit.py deleted file mode 100644 index 1ab1b96..0000000 --- a/tests/test_wave_preservation_on_edit.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Editing an event must not destroy its ticket waves. - -`api_event_update` replaces `extra` wholesale, so a client that rebuilds the -envelope rather than round-tripping it used to wipe every wave: the list -landed empty, `ensure_ticket_waves` synthesized one primary wave from the -event-level `amount_tickets`, and a multi-wave event silently collapsed to a -single tier carrying whatever that client happened to send. Same hazard the -`promo_codes` guard already covered. -""" - -from datetime import datetime, timedelta, timezone - -import pytest - -from ..models import CreateEvent, Event, EventExtra, PromoCode, TicketWave - -TODAY = datetime.now(timezone.utc).date() - - -def _day(offset: int) -> str: - return (TODAY + timedelta(days=offset)).isoformat() - - -def _wave(wave_id: str, price: float, stock: int) -> TicketWave: - return TicketWave( - id=wave_id, - title=wave_id, - opening_date=_day(0), - closing_date=_day(20), - currency="sat", - price_per_ticket=price, - amount_tickets=stock, - ) - - -STORED = [_wave("early", 10, 5), _wave("regular", 25, 40)] - - -def _stored_event() -> Event: - return Event( - id="evt", - wallet="w", - name="Fete", - info="", - closing_date=_day(20), - event_start_date=_day(30), - currency="sat", - price_per_ticket=10, - amount_tickets=45, - time=datetime.now(timezone.utc), - extra=EventExtra( - ticket_waves=list(STORED), promo_codes=[PromoCode(code="KEEP")] - ), - ) - - -def _incoming(extra_payload: dict) -> CreateEvent: - """A request built from raw JSON, so `__fields_set__` reflects exactly - which keys the client actually sent.""" - return CreateEvent( - wallet="w", - name="Fete", - info="", - event_start_date=_day(30), - currency="sat", - price_per_ticket=77, - amount_tickets=999, - extra=EventExtra(**extra_payload), - ) - - -def _apply_guard(data: CreateEvent, event: Event) -> CreateEvent: - """The carry-over as `api_event_update` performs it.""" - if "ticket_waves" not in data.extra.__fields_set__: - data.extra.ticket_waves = event.extra.ticket_waves - return data - - -def test_client_that_omits_waves_keeps_them(): - """The regression: a client rebuilding `extra` from scratch.""" - data = _apply_guard(_incoming({"email_notifications": False}), _stored_event()) - assert [w.id for w in data.extra.ticket_waves] == ["early", "regular"] - assert [w.price_per_ticket for w in data.extra.ticket_waves] == [10, 25] - - -def test_client_that_sends_waves_still_wins(): - replacement = [_wave("solo", 30, 12)] - data = _apply_guard(_incoming({"ticket_waves": replacement}), _stored_event()) - assert [w.id for w in data.extra.ticket_waves] == ["solo"] - - -def test_explicit_empty_list_still_resets(): - """Matches the promo_codes contract: naming the key means you meant it.""" - data = _apply_guard(_incoming({"ticket_waves": []}), _stored_event()) - assert data.extra.ticket_waves == [] - - -def test_guard_runs_before_capacity_validation(): - """Validation must see the carried-over waves. - - Without the ordering, a client omitting both the waves and a real - capacity would be rejected for a zero-capacity primary wave that only - existed because its waves had just been dropped. - """ - from ..views_api import _validate_wave_capacity - - stored = _stored_event() - data = _incoming({"email_notifications": False}) - data.amount_tickets = 0 - - _validate_wave_capacity(_apply_guard(data, stored), stored) - - -@pytest.mark.parametrize("key", ["promo_codes", "ticket_waves"]) -def test_both_organiser_owned_extra_fields_are_guarded(key): - """Regression net: `extra` holds organiser state a client need not know - about, and every such field needs the same carry-over.""" - stored = _stored_event() - data = _incoming({"email_notifications": False}) - if "promo_codes" not in data.extra.__fields_set__: - data.extra.promo_codes = stored.extra.promo_codes - _apply_guard(data, stored) - - assert getattr(data.extra, key), f"{key} was dropped on edit" diff --git a/tests/test_wave_transition_sweep.py b/tests/test_wave_transition_sweep.py deleted file mode 100644 index 0c57e13..0000000 --- a/tests/test_wave_transition_sweep.py +++ /dev/null @@ -1,159 +0,0 @@ -"""Wave boundaries trigger a republish (#61). - -Every republish this extension performs is sale-driven. A wave boundary is -a *date* boundary, so when early bird closes at midnight nothing fires and -the relay keeps serving the closed wave's price until the next ticket -happens to sell. `flag_wave_transitions` closes that gap by comparing the -wave a row would advertise now against the one its last successful publish -did, handing the work to the existing reconciliation sweep. -""" - -from datetime import datetime, timedelta, timezone -from types import SimpleNamespace -from unittest.mock import AsyncMock - -import pytest - -from .. import crud, nostr_hooks -from ..models import Event, EventExtra, TicketWave - -TODAY = datetime.now(timezone.utc).date() - - -def _day(offset: int) -> str: - return (TODAY + timedelta(days=offset)).isoformat() - - -def _wave(wave_id: str, price: float, stock: int, opens: int, closes: int): - return TicketWave( - id=wave_id, - title=wave_id, - opening_date=_day(opens), - closing_date=_day(closes), - currency="EUR", - price_per_ticket=price, - amount_tickets=stock, - ) - - -def _event(waves, published_wave_id, pending=False) -> Event: - return Event( - id="evt", - wallet="w", - name="Waves", - info="", - closing_date=_day(30), - event_start_date=_day(30), - event_end_date=_day(30), - currency="sat", - price_per_ticket=0, - amount_tickets=0, - time=datetime.now(timezone.utc), - status="approved", - nostr_publish_pending=pending, - nostr_published_wave_id=published_wave_id, - extra=EventExtra(ticket_waves=waves), - ) - - -@pytest.fixture -def swept(monkeypatch): - """Capture rows the detector writes back.""" - written: list[Event] = [] - - async def _update(event): - written.append(event) - return event - - monkeypatch.setattr(crud, "update_event", _update) - return written - - -def _rows(monkeypatch, events): - monkeypatch.setattr(crud.db, "fetchall", AsyncMock(return_value=events)) - - -CLOSED = _wave("early", 10.0, 5, -10, -1) -OPEN = _wave("regular", 25.0, 40, 0, 20) - - -@pytest.mark.asyncio -async def test_moved_wave_is_flagged(monkeypatch, swept): - """Early bird closed; the relay still advertises it.""" - _rows(monkeypatch, [_event([CLOSED, OPEN], published_wave_id="early")]) - - assert await crud.flag_wave_transitions() == 1 - assert [e.nostr_publish_pending for e in swept] == [True] - - -@pytest.mark.asyncio -async def test_unchanged_wave_is_left_alone(monkeypatch, swept): - _rows(monkeypatch, [_event([CLOSED, OPEN], published_wave_id="regular")]) - - assert await crud.flag_wave_transitions() == 0 - assert swept == [] - - -@pytest.mark.asyncio -async def test_selling_out_the_open_wave_is_a_transition(monkeypatch, swept): - """No wave open is itself an advertisable state, and a different one.""" - sold_out = _wave("regular", 25.0, 0, 0, 20) - _rows(monkeypatch, [_event([sold_out], published_wave_id="regular")]) - - assert await crud.flag_wave_transitions() == 1 - - -@pytest.mark.asyncio -async def test_already_advertising_nothing_is_not_reflagged(monkeypatch, swept): - """ "" means "published while nothing was on sale" — not drift.""" - _rows(monkeypatch, [_event([CLOSED], published_wave_id="")]) - - assert await crud.flag_wave_transitions() == 0 - assert swept == [] - - -@pytest.mark.asyncio -async def test_never_published_rows_are_skipped(monkeypatch, swept): - """NULL is no evidence of drift. Flagging these would republish the - whole table on the first boot after the upgrade.""" - _rows(monkeypatch, []) # the SQL filters them out - - assert await crud.flag_wave_transitions() == 0 - - -@pytest.mark.asyncio -async def test_publish_records_the_advertised_wave(monkeypatch): - """The sweep can only detect drift if a success writes the wave down.""" - event = _event([CLOSED, OPEN], published_wave_id="early") - monkeypatch.setattr(nostr_hooks, "update_event", AsyncMock(side_effect=lambda e: e)) - monkeypatch.setattr( - "lnbits.core.signers.resolve_for_wallet", - AsyncMock(return_value=SimpleNamespace(pubkey="pk")), - ) - monkeypatch.setattr( - nostr_hooks, - "publish_event_to_nostr", - AsyncMock(return_value=SimpleNamespace(id="nid", created_at=123)), - ) - - assert await nostr_hooks.publish_or_delete_nostr_event(event) is True - assert event.nostr_published_wave_id == "regular" - - -@pytest.mark.asyncio -async def test_delete_does_not_record_a_wave(monkeypatch): - """A takedown advertises nothing, so it must not claim a wave.""" - event = _event([CLOSED, OPEN], published_wave_id="early") - monkeypatch.setattr(nostr_hooks, "update_event", AsyncMock(side_effect=lambda e: e)) - monkeypatch.setattr( - "lnbits.core.signers.resolve_for_wallet", - AsyncMock(return_value=SimpleNamespace(pubkey="pk")), - ) - monkeypatch.setattr( - nostr_hooks, - "publish_event_to_nostr", - AsyncMock(return_value=SimpleNamespace(id="nid", created_at=123)), - ) - - assert await nostr_hooks.publish_or_delete_nostr_event(event, delete=True) is True - assert event.nostr_published_wave_id == "early" diff --git a/views_api.py b/views_api.py index 94b5bad..3edd953 100644 --- a/views_api.py +++ b/views_api.py @@ -1,10 +1,7 @@ import asyncio from datetime import datetime, timezone from http import HTTPStatus -from io import BytesIO -from pathlib import Path from typing import Any -from urllib.parse import urlsplit from fastapi import ( APIRouter, @@ -15,22 +12,18 @@ from fastapi import ( WebSocket, WebSocketDisconnect, ) -from fastapi.responses import StreamingResponse from lnbits.core.crud import get_user -from lnbits.core.crud.assets import get_public_asset from lnbits.core.crud.wallets import get_wallet from lnbits.core.models import Account, User, WalletTypeInfo from lnbits.core.models.payments import CreateInvoice from lnbits.core.services import create_payment_request -from lnbits.db import Filters, Page +from lnbits.helpers import urlsafe_short_hash from lnbits.decorators import ( check_admin, check_user_exists, - parse_filters, require_admin_key, require_invoice_key, ) -from lnbits.helpers import generate_filter_params_openapi, urlsafe_short_hash from lnbits.settings import settings from lnbits.utils.exchange_rates import ( fiat_amount_as_satoshis, @@ -38,7 +31,6 @@ from lnbits.utils.exchange_rates import ( satoshis_amount_as_fiat, ) from lnbits.utils.nostr import normalize_public_key -from PIL import Image from .crud import ( create_event, @@ -55,66 +47,29 @@ from .crud import ( get_settings, get_ticket, get_tickets, - get_tickets_by_event, get_tickets_by_payment_hash, get_tickets_by_user_id, - get_tickets_paginated, purge_unpaid_tickets, update_event, update_settings, update_ticket, ) from .models import ( - BasketTotals, CreateEvent, CreateTicket, Event, EventsSettings, - PromoValidateRequest, PublicEvent, PublicTicket, Ticket, - TicketFilters, TicketPaymentRequest, - TicketResendResult, - TicketWave, - effective_payment_methods, - ensure_ticket_waves, - get_active_ticket_waves, ) from .nostr_hooks import publish_or_delete_nostr_event -from .promo import ( - basket_totals, - find_promo, - normalize_code, - remaining_uses, - round_amount, -) -from .qr import ( - image_png_bytes, - load_qr_logo, - make_qr_png, - render_ticket_card, - ticket_card_filename, -) -from .services import ( - event_promo_usage, - hydrate_promo_usage, - refund_tickets, - resend_ticket_email_notification, - send_ticket_notification_in_background, - set_ticket_paid, -) -from .tasks import ( - deregister_payment_listener, - register_payment_listener, -) +from .services import refund_tickets, resend_ticket_email_notification +from .tasks import deregister_payment_listener, register_payment_listener events_api_router = APIRouter(prefix="/api/v1/events") tickets_api_router = APIRouter(prefix="/api/v1/tickets") -qr_api_router = APIRouter(prefix="/api/v1") -promo_api_router = APIRouter(prefix="/api/v1/promo") -tickets_filters = parse_filters(TicketFilters) def _is_fiat_currency(currency: str | None) -> bool: @@ -123,6 +78,8 @@ def _is_fiat_currency(currency: str | None) -> bool: # Literal-prefix routes (/public, /all, /pending, /settings) MUST be declared # before any "/{event_id}" route or FastAPI matches them as a path parameter. + + @events_api_router.get("") async def api_events( all_wallets: bool = Query(False), @@ -132,17 +89,12 @@ async def api_events( if all_wallets: user = await get_user(wallet.wallet.user) wallet_ids = user.wallet_ids if user else [] - events = await get_events(wallet_ids) - for event in events: - await hydrate_promo_usage(event) - return events + return await get_events(wallet_ids) -@events_api_router.get("/public", response_model=list[PublicEvent]) +@events_api_router.get("/public") async def api_events_public() -> list[Event]: - """Approved, non-canceled events for an anonymous public listing. - - Projected through `PublicEvent`: no wallet id, no promo codes.""" + """Approved, non-canceled events for an anonymous public listing.""" return await get_public_events() @@ -160,7 +112,6 @@ async def api_events_all( events = await get_all_events() enriched: list[dict] = [] for event in events: - await hydrate_promo_usage(event) wallet = await get_wallet(event.wallet) row = event.dict() row["wallet_user_id"] = wallet.user if wallet else None @@ -285,80 +236,33 @@ async def api_get_event(event_id: str) -> Event: closing_date = event.closing_date or event.event_end_date or event.event_start_date # Accept either YYYY-MM-DD or full ISO 8601 datetime (event_end_date # may carry a time component since v1.3.0-aio.3 / our start-end-time - # feature). Upstream v1.6.8 parses closing_date with a bare - # strptime("%Y-%m-%d") here; that raises ValueError on any event of ours - # whose closing date carries a time, which is most of them. + # feature). try: closing_dt = datetime.fromisoformat(closing_date) except ValueError: closing_dt = datetime.strptime(closing_date[:10], "%Y-%m-%d") if closing_dt.tzinfo is None: closing_dt = closing_dt.replace(tzinfo=timezone.utc) - - now = datetime.now(timezone.utc) - today = now.date() - active_waves = get_active_ticket_waves(event, today) - # `is_sales_closed` is upstream's name for `not is_window_open`; the - # comparison stays ours (instant-precise) rather than upstream's - # whole-day `today > closing_date.date()`. Upstream's form keeps sales - # open for the whole of the closing day, which for our datetime-bearing - # closing dates would extend every existing event's sales window. - is_sales_closed = now >= closing_dt + is_window_open = datetime.now(timezone.utc) < closing_dt is_min_tickets_met = ( event.sold >= event.extra.min_tickets if event.extra.conditional else True ) if event.amount_tickets < 1: raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.") - if event.extra.conditional and not is_min_tickets_met and is_sales_closed: + if event.extra.conditional and not is_min_tickets_met and not is_window_open: event.canceled = True await update_event(event) await refund_tickets(event_id) raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.") - if not active_waves: + if not is_window_open: raise HTTPException( - status_code=HTTPStatus.GONE, - detail=( - "Ticket closing date has passed." - if is_sales_closed - else "No ticket wave is currently open." - ), + status_code=HTTPStatus.GONE, detail="Ticket closing date has passed." ) return event -def _validate_wave_capacity(data: CreateEvent, existing: Event | None = None) -> None: - """Every ticket wave must state a real capacity. - - "Capacity is always required, there is no unlimited" (#34, #62) was - settled when capacity was a single number on the event. Since v1.6.8 it - is per-wave and `event.amount_tickets` is a derived roll-up, so the rule - has to be enforced where the organiser actually sets it — otherwise it - only survives as a `min="1"` on one HTML input, which no API client is - bound by. - - A zero-capacity wave can never be active (`get_active_ticket_waves` - requires `amount_tickets > 0`), so it is the wave-level form of exactly - what #62 removed: an event that looks on sale but refuses every - purchase. - - Selling out is the one legitimate route to zero, so an edit only checks - waves that are NEW to the event; waves already stored keep whatever - sales decremented them to. `ensure_ticket_waves` is used rather than the - raw list so an event submitted with no waves is checked through the - primary wave it will be given. - """ - known = {wave.id for wave in (existing.extra.ticket_waves if existing else [])} - for wave in ensure_ticket_waves(data): - if wave.id in known or wave.amount_tickets >= 1: - continue - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Ticket wave '{wave.title}' needs a capacity of at least 1.", - ) - - @events_api_router.post("") async def api_event_create( data: CreateEvent, @@ -372,8 +276,6 @@ async def api_event_create( if not data.wallet: data.wallet = wallet.wallet.id - _validate_wave_capacity(data) - from lnbits.settings import settings ext_settings = await get_settings() @@ -416,22 +318,6 @@ async def api_event_update( if event.wallet != wallet.wallet.id: raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.") - # Carry the stored waves over unless the request names the key. `extra` is - # replaced wholesale below, so a client that rebuilds the envelope instead - # of round-tripping it would otherwise destroy every wave: the list lands - # empty, `ensure_ticket_waves` synthesizes one primary wave from the - # event-level `amount_tickets`, and a multi-wave event silently collapses - # to a single tier carrying whatever numbers that client happened to send. - # Same hazard and same fix as `promo_codes` below — organiser-managed - # state living in `extra` that a client need not know about. Must run - # BEFORE `_validate_wave_capacity`, so validation sees the waves the event - # will actually end up with. An explicit `[]` still resets, as it does for - # promo codes. - if "ticket_waves" not in data.extra.__fields_set__: - data.extra.ticket_waves = event.extra.ticket_waves - - _validate_wave_capacity(data, event) - from lnbits.settings import settings ext_settings = await get_settings() @@ -448,13 +334,6 @@ async def api_event_update( if not data.closing_date: data.closing_date = data.event_end_date - # Promo codes are organizer-only and absent from public responses, so a - # client that round-trips a public record (or simply doesn't manage - # codes) would otherwise wipe them on every edit. Carry the stored list - # over unless the request names the key; an explicit `[]` still clears. - if "promo_codes" not in data.extra.__fields_set__: - data.extra.promo_codes = event.extra.promo_codes - # Explicit field list — never copy `status` from the request body. # Includes upstream v1.6.1 fields (allow_fiat, fiat_currency) so an # owner editing a fiat-enabled event keeps the fiat config. @@ -613,31 +492,6 @@ async def api_tickets_by_user( return await get_tickets_by_user_id(user_id) -@tickets_api_router.get( - "/paginated", - summary="Get paginated list of tickets", - openapi_extra=generate_filter_params_openapi(TicketFilters), - response_model=Page[Ticket], -) -async def api_tickets_paginated( - all_wallets: bool = Query(False), - filters: Filters = Depends(tickets_filters), - key_info: WalletTypeInfo = Depends(require_admin_key), -) -> Page[Ticket]: - wallet_ids = [key_info.wallet.id] - - if all_wallets: - user = await get_user(key_info.wallet.user) - wallet_ids = user.wallet_ids if user else [] - - if not filters.sortby: - filters.sortby = "time" - if not filters.direction: - filters.direction = "desc" - - return await get_tickets_paginated(wallet_ids, filters) - - @tickets_api_router.get("/{ticket_id}", response_model=PublicTicket) async def api_get_ticket(ticket_id: str) -> Ticket: ticket = await get_ticket(ticket_id) @@ -653,144 +507,6 @@ async def api_get_ticket(ticket_id: str) -> Ticket: return ticket -def _origin(url: str | None) -> str | None: - if not url: - return None - parts = urlsplit(url.strip()) - if not parts.scheme or not parts.netloc: - return None - return f"{parts.scheme.lower()}://{parts.netloc.lower()}" - - -def _allowed_frontend_origins() -> set[str]: - """Origins a buyer-side client may name in `CreateTicket.frontend_url`. - - The CORS allow-list is literally "which web apps may talk to this - LNbits", so it is the natural allow-list for "which web apps may be - linked from a ticket email". The LNbits host itself and the configured - custom frontend are always fine. - """ - origins: set[str] = set() - for candidate in [ - *getattr(settings, "lnbits_cors_allowed_origins", []), - settings.lnbits_baseurl, - getattr(settings, "lnbits_custom_frontend_url", None), - ]: - origin = _origin(candidate) - if origin: - origins.add(origin) - return origins - - -def _resolve_frontend_root(data: CreateTicket, request: Request) -> str: - """Root under which `/events/{event_id}` and `/events/ticket/{ticket_id}` - resolve for the buyer — the calling app when it says so, else the LNbits - host (the extension's own Quasar pages).""" - if not data.frontend_url: - return str(request.base_url).rstrip("/") - origin = _origin(data.frontend_url) - if not origin or origin not in _allowed_frontend_origins(): - # Fail loud rather than silently falling back: a wrong root means - # the buyer is returned to (and emailed a link into) the wrong app. - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="frontend_url origin is not allowed.", - ) - return data.frontend_url.rstrip("/") - - -def _resolve_ticket_wave(event: Event, ticket_wave_id: str | None) -> TicketWave: - """The wave a purchase or a price preview is priced against. - - Shared by the purchase and promo-validate endpoints so the two cannot - drift: whatever wave the preview quoted is the wave the invoice charges. - Selection is upstream v1.6.8's — an explicit id must be an OPEN wave, a - single open wave is implied, and an ambiguous choice is an error rather - than a silent pick. - """ - active_waves = get_active_ticket_waves(event) - if not active_waves: - raise HTTPException( - status_code=HTTPStatus.GONE, detail="No ticket wave is currently open." - ) - if ticket_wave_id: - wave = next((wave for wave in active_waves if wave.id == ticket_wave_id), None) - if not wave: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Invalid ticket wave selected.", - ) - return wave - if len(active_waves) == 1: - return active_waves[0] - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Please select a ticket wave.", - ) - - -async def _issue_free_tickets( - *, - event: Event, - quantity: int, - name: str | None, - email: str | None, - user_id: str | None, - promo_code: str | None, - nostr_identifier: str | None, - frontend_root: str, - selected_wave: TicketWave, -) -> TicketPaymentRequest: - """Issue `quantity` free tickets without minting an invoice. - - Each row is created then run through `set_ticket_paid` — the exact path - `on_invoice_paid` drives for a settled payment: it flips `paid`, bumps - the sold / available counters under the per-event lock, and republishes - the NIP-52 calendar event so connected clients see the new counts. - Notifications fire the same way. No invoice exists, so `sats_paid` is 0 - and these tickets are naturally skipped by `refund_tickets`. - - All rows in the batch share one synthetic `payment_hash` — the join key - the poll / WebSocket / My-Tickets lookups use — mirroring how the paid - multi-ticket path shares the real invoice hash. - """ - payment_hash = urlsafe_short_hash() - ticket_ids: list[str] = [] - for _ in range(quantity): - row_id = urlsafe_short_hash() - ticket = await create_ticket( - payment_hash=payment_hash, - wallet=event.wallet, - event=event.id, - name=name, - email=email, - user_id=user_id, - ticket_id=row_id, - extra={ - "applied_promo_code": promo_code, - # Free tickets consume wave stock exactly like paid ones — - # `set_ticket_paid` runs on this path too — so they must name - # their wave or the decrement lands on the primary wave. - "ticket_wave_id": selected_wave.id, - "ticket_wave_title": selected_wave.title, - "nostr_identifier": nostr_identifier, - "ticket_base_url": frontend_root, - "sats_paid": 0, - }, - ) - await set_ticket_paid(ticket) - send_ticket_notification_in_background(ticket) - ticket_ids.append(row_id) - - return TicketPaymentRequest( - payment_hash=payment_hash, - payment_request=None, - is_fiat=False, - paid=True, - ticket_ids=ticket_ids, - ) - - @tickets_api_router.post("/{event_id}") async def api_ticket_create( event_id: str, data: CreateTicket, request: Request @@ -808,23 +524,20 @@ async def api_ticket_create( if event.canceled: raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.") quantity = data.quantity - # `amount_tickets` IS the remaining count — `set_ticket_paid` decrements - # it on every sale, and upstream reads it the same way everywhere. Do - # not subtract `sold` from it: `sold` counts the same tickets the - # decrement already removed, so doing both takes each sale off twice - # (aiolabs/events#34). - if event.amount_tickets < 1: - raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.") - if quantity > event.amount_tickets: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Only {event.amount_tickets} ticket(s) remaining for this event.", - ) + if event.amount_tickets > 0: + if event.sold >= event.amount_tickets: + raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.") + remaining = event.amount_tickets - event.sold + if quantity > remaining: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Only {remaining} ticket(s) remaining for this event.", + ) name = data.name email = data.email user_id = data.user_id - promo_code = normalize_code(data.promo_code) + promo_code = data.promo_code.upper() if data.promo_code else None refund_address = data.refund_address nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None payment_method = (data.payment_method or "lightning").lower() @@ -833,11 +546,6 @@ async def api_ticket_create( status_code=HTTPStatus.BAD_REQUEST, detail="Unsupported payment method.", ) - # npub or NIP-05, both normalised to a hex pubkey. v1.6.8 replaced this - # with a hard "Only NIP-05 Nostr identifiers are supported." rejection — - # true for upstream, false here: `_send_nostr_ticket_notification` sends - # bare pubkeys via `send_nostr_dm`. That rejection merged in outside any - # conflict marker, so it would have silently dropped npub checkout. if nostr_identifier and "@" not in nostr_identifier: try: nostr_identifier = normalize_public_key(nostr_identifier) @@ -846,104 +554,45 @@ async def api_ticket_create( status_code=HTTPStatus.BAD_REQUEST, detail="Invalid Nostr identifier.", ) from exc - - selected_wave = _resolve_ticket_wave(event, data.ticket_wave_id) + unit_price = event.price_per_ticket extra: dict[str, Any] = {"tag": "events", "name": name, "email": email} - frontend_root = _resolve_frontend_root(data, request) - # One invoice, N tickets; the promo (if any) prices the whole quantity - # through the same `basket_totals` the validate endpoint uses, so the - # preview a buyer saw is what gets charged. Unlike validate, a bad code - # is a hard error here — silently charging full price would be worse. if promo_code: - promo = find_promo(event, promo_code) - if not promo: + # check if promo_code exists in event.extra.promo_codes + if promo_code not in [pc.code for pc in event.extra.promo_codes]: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code." ) - if not promo.active: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Promo code is not active.", - ) - usage = await event_promo_usage(event.id) - uses_left = remaining_uses(promo, usage.get(promo.code, 0)) - if uses_left is not None and uses_left < quantity: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=( - "Promo code has been fully redeemed." - if uses_left == 0 - else f"Only {uses_left} use(s) left on this promo code." - ), - ) + # get the promocode + promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code) extra["promo_code"] = promo.code - # Priced off `selected_wave`, not `event`: since v1.6.8 the price and - # currency live on the wave, and the event-level fields are a roll-up - # of the PRIMARY wave (`sync_event_ticket_waves`). Pricing off the - # event charges every buyer the first wave's price whichever wave they - # actually picked. Upstream prices one ticket; `basket_totals` keeps - # our quantity + promo arithmetic, so the "Apply" preview and the - # invoice still agree. - price = basket_totals(event, [promo.code], quantity, usage, selected_wave).total - else: - price = round_amount( - selected_wave.price_per_ticket * quantity, selected_wave.currency - ) + unit_price = event.price_per_ticket * (1 - promo.discount_percent / 100) + # Scale by quantity AFTER the promo applies. One invoice, N tickets. + price = unit_price * quantity - # Free tickets (final charge 0 — a free event or a 100%-off promo). - # Short-circuit before any invoice / fiat-provider logic: no Lightning - # invoice can settle for 0, so we issue the rows and mark them paid - # directly. payment_method is irrelevant here (nothing is charged). - if price <= 0: - return await _issue_free_tickets( - event=event, - quantity=quantity, - name=name, - email=email, - user_id=user_id, - promo_code=promo_code, - nostr_identifier=nostr_identifier, - frontend_root=frontend_root, - selected_wave=selected_wave, - ) - - # Fiat is a per-wave opt-in since v1.6.8. `effective_payment_methods` - # below reads `event.allow_fiat`, which is only the PRIMARY wave's, so - # this check is what actually protects a non-fiat wave. - if payment_method == "fiat" and not selected_wave.allow_fiat: + if payment_method == "fiat" and not event.allow_fiat: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, - detail="Fiat payments are not enabled for this ticket wave.", + detail="Fiat payments are not enabled for this event.", ) - # Organizer-controlled rails (extra.payment_methods; legacy events fall - # back to Lightning + fiat-if-allow_fiat). Checked after the free path - # because a free claim charges nothing on any rail. - if payment_method not in effective_payment_methods(event): - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Payment method not enabled for this event.", - ) - - if _is_fiat_currency(selected_wave.currency): + if _is_fiat_currency(event.currency): extra["fiat"] = True - extra["currency"] = selected_wave.currency + extra["currency"] = event.currency extra["fiatAmount"] = price - extra["rate"] = await get_fiat_rate_satoshis(selected_wave.currency) + extra["rate"] = await get_fiat_rate_satoshis(event.currency) if payment_method != "fiat": - price = await fiat_amount_as_satoshis(price, selected_wave.currency) + price = await fiat_amount_as_satoshis(price, event.currency) - invoice_unit = selected_wave.currency + invoice_unit = event.currency fiat_amount = price fiat_provider = None - if payment_method == "fiat": - if _is_fiat_currency(selected_wave.currency): - invoice_unit = selected_wave.currency + if _is_fiat_currency(event.currency): + invoice_unit = event.currency else: - invoice_unit = selected_wave.fiat_currency + invoice_unit = event.fiat_currency fiat_amount = await satoshis_amount_as_fiat(price, invoice_unit) extra["fiat"] = True extra["currency"] = invoice_unit @@ -965,37 +614,6 @@ async def api_ticket_create( else: invoice_unit = "sat" - # Each row gets a fresh urlsafe_short_hash id so single- and - # multi-ticket purchases stay shape-consistent — every scannable - # ticket id is a short hash, never the long bolt11 payment_hash. - # The shared `payment_hash` column is the join key for invoice - # lookup (poll endpoint, ws notifier, set_ticket_paid loop). Ids are - # minted BEFORE the invoice so the fiat success URL can carry them - # (the payment_hash only exists after `create_payment_request`). - ticket_ids: list[str] = [urlsafe_short_hash() for _ in range(quantity)] - - if payment_method == "fiat": - # Parameterise the provider's hosted checkout (consumed by - # lnbits/fiat/stripe.py `StripeCheckoutOptions`): bring the buyer - # back to the app they came from, lock the email they gave us, and - # label the line item with the event rather than the raw memo. - ticket_label = "ticket" if quantity == 1 else "tickets" - extra["checkout"] = { - "success_url": ( - f"{frontend_root}/events/{event.id}" - f"?checkout=success&tickets={','.join(ticket_ids)}" - ), - "cancel_url": f"{frontend_root}/events/{event.id}?checkout=cancelled", - "customer_email": email, - "line_item_name": f"{event.name} — {quantity} {ticket_label}", - "metadata": { - "event_id": event.id, - "quantity": str(quantity), - "ticket_ids": ",".join(ticket_ids), - **({"promo_code": promo_code} if promo_code else {}), - }, - } - payment = await create_payment_request( wallet_id=event.wallet, invoice_data=CreateInvoice( @@ -1007,8 +625,15 @@ async def api_ticket_create( extra=extra, ), ) + # Each row gets a fresh urlsafe_short_hash id so single- and + # multi-ticket purchases stay shape-consistent — every scannable + # ticket id is a short hash, never the long bolt11 payment_hash. + # The shared `payment_hash` column is the join key for invoice + # lookup (poll endpoint, ws notifier, set_ticket_paid loop). + ticket_ids: list[str] = [] sats_per_ticket = payment.sat // quantity if quantity else payment.sat - for row_id in ticket_ids: + for _ in range(quantity): + row_id = urlsafe_short_hash() await create_ticket( payment_hash=payment.payment_hash, wallet=event.wallet, @@ -1019,18 +644,13 @@ async def api_ticket_create( ticket_id=row_id, extra={ "applied_promo_code": promo_code, - # Which wave this ticket came from. `set_ticket_paid` debits - # the wave named here and falls back to waves[0] when it is - # absent, so omitting it would take every sale off the - # primary wave no matter what the buyer bought. - "ticket_wave_id": selected_wave.id, - "ticket_wave_title": selected_wave.title, "refund_address": refund_address, "nostr_identifier": nostr_identifier, - "ticket_base_url": frontend_root, + "ticket_base_url": str(request.base_url).rstrip("/"), "sats_paid": sats_per_ticket, }, ) + ticket_ids.append(row_id) return TicketPaymentRequest( payment_hash=payment.payment_hash, @@ -1126,12 +746,10 @@ async def api_ticket_delete( await delete_ticket(ticket_id) -@tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult) +@tickets_api_router.post("/{ticket_id}/resend-email") async def api_ticket_resend_email( - ticket_id: str, - request: Request, - wallet: WalletTypeInfo = Depends(require_admin_key), -) -> TicketResendResult: + ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) +) -> Ticket: ticket = await get_ticket(ticket_id) if not ticket: raise HTTPException( @@ -1148,13 +766,16 @@ async def api_ticket_resend_email( ) try: - return await resend_ticket_email_notification( - ticket, str(request.base_url).rstrip("/") - ) + return await resend_ticket_email_notification(ticket) except ValueError as exc: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail=str(exc) ) from exc + except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to resend ticket email.", + ) from exc @tickets_api_router.put("/register/{ticket_id}") @@ -1211,176 +832,3 @@ async def api_event_register_ticket( ticket.reg_timestamp = datetime.now(timezone.utc) ticket = await update_ticket(ticket) return ticket - - -@tickets_api_router.get("/event/{event_id}/stats") -async def api_event_ticket_stats( - event_id: str, - key_info: WalletTypeInfo = Depends(require_admin_key), -) -> dict: - """Door-scanner roster + counts for one event, organizer-only. - - Mirrors the `events_list_event_tickets` nostr-transport RPC for - callers that don't hold a raw user prvkey (the webapp post-#9, in - particular). Auth: wallet admin_key + the event's wallet must be - in the caller's wallet set. - """ - event = await get_event(event_id) - if not event: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." - ) - - user = await get_user(key_info.wallet.user) - owned_wallet_ids = user.wallet_ids if user else [key_info.wallet.id] - if event.wallet not in owned_wallet_ids: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="You do not own this event.", - ) - - tickets = await get_tickets_by_event(event_id) - paid_tickets = [t for t in tickets if t.paid] - registered_count = sum(1 for t in paid_tickets if t.registered) - - return { - "event_id": event_id, - "sold": len(paid_tickets), - "registered": registered_count, - "remaining": len(paid_tickets) - registered_count, - "tickets": [ - { - "id": t.id, - "name": t.name, - "registered": t.registered, - "registered_at": ( - t.reg_timestamp.isoformat() if t.reg_timestamp else None - ), - "applied_promo_code": t.extra.applied_promo_code, - } - for t in paid_tickets - ], - } - - -@qr_api_router.get("/qr/{ticket_id}", response_class=StreamingResponse) -async def api_ticket_qr(ticket_id: str): - """PNG of the ticket's scan payload (`ticket://`). - - Two shapes behind one route. Before the v1.6.8 merge this endpoint - existed on both sides — ours was "upstream's, without ticket-image - compositing", theirs added the compositing but dropped the logo. They - are the same endpoint, so they are merged rather than registered twice - (FastAPI serves whichever route is declared first, so the second copy - would have been silently unreachable): - - - wave opted into `use_ticket_image`: upstream's composite — the QR - pasted onto the organiser's uploaded template, or the bundled - default. QR size and paste coordinates are upstream's and are tied - to each other; no logo, because the template carries the branding. - - otherwise: our standalone QR at 300px with the instance logo. - - Anonymous by design — it is what the ticket email links to — and the id - is the same bearer token the ticket page exposes. - """ - ticket = await get_ticket(ticket_id) - if not ticket: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist." - ) - event = await get_event(ticket.event) - if not event: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." - ) - - headers = { - "Cache-Control": "no-cache, no-store, must-revalidate", - "Pragma": "no-cache", - "Expires": "0", - } - - waves = ensure_ticket_waves(event) - wave = next( - (wave for wave in waves if wave.id == ticket.extra.ticket_wave_id), - waves[0], - ) - - if not wave.use_ticket_image: - logo = await load_qr_logo() - image = make_qr_png(f"ticket://{ticket_id}", size=300, logo=logo) - return StreamingResponse( - BytesIO(image_png_bytes(image)), - media_type="image/png", - headers=headers, - ) - - background_bytes = None - if wave.ticket_image_id: - asset = await get_public_asset(wave.ticket_image_id) - if asset: - background_bytes = asset.data - - if background_bytes: - ticket_image = Image.open(BytesIO(background_bytes)).convert("RGBA") - else: - default_template = ( - Path(__file__).resolve().parent / "static" / "image" / "ticket.jpg" - ) - ticket_image = Image.open(default_template).convert("RGBA") - - ticket_image.paste(make_qr_png(f"ticket://{ticket_id}", size=157), (122, 505)) - output = BytesIO() - ticket_image.save(output, format="PNG") - output.seek(0) - return StreamingResponse(output, media_type="image/png", headers=headers) - - -@qr_api_router.get("/ticket-card/{ticket_id}", response_class=StreamingResponse) -async def api_ticket_card(ticket_id: str): - """Self-describing ticket PNG (event, when, where, QR, name, id) — the - same image the ticket email attaches. Anonymous like the QR endpoint.""" - ticket = await get_ticket(ticket_id) - if not ticket: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist." - ) - event = await get_event(ticket.event) - if not event: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." - ) - logo = await load_qr_logo() - card = render_ticket_card( - ticket, event, logo=logo, site_title=settings.lnbits_site_title - ) - filename = ticket_card_filename(ticket, event) - return StreamingResponse( - BytesIO(image_png_bytes(card)), - media_type="image/png", - headers={ - "Content-Disposition": f'inline; filename="{filename}"', - "Cache-Control": "no-cache, no-store, must-revalidate", - }, - ) - - -@promo_api_router.post("/validate/{event_id}") -async def api_validate_promo_codes( - event_id: str, data: PromoValidateRequest -) -> BasketTotals: - """Price a purchase with the given codes without committing to it — - what the buyer sees before paying. Anonymous and advisory: a code that - is unknown / inactive / exhausted is simply absent from - `discounts_applied`; the purchase endpoint is where hard errors live. - Same URL as upstream v2 (`quantity` instead of v2's `items`).""" - event = await get_event(event_id) - if not event: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." - ) - usage = await event_promo_usage(event_id) if event.extra.promo_codes else {} - # Same resolver the purchase endpoint uses, so the quote and the charge - # are priced against the same wave. - wave = _resolve_ticket_wave(event, data.ticket_wave_id) - return basket_totals(event, data.codes, data.quantity, usage, wave)