diff --git a/config.json b/config.json index f7e0ec1..f356813 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "id": "events", - "version": "1.6.8-aio.4", + "version": "1.6.8-aio.1", "name": "Events", "repo": "https://git.atitlan.io/aiolabs/events", "short_description": "Sell and register event tickets", diff --git a/models.py b/models.py index c5a1131..1768b44 100644 --- a/models.py +++ b/models.py @@ -58,8 +58,7 @@ class TicketWave(BaseModel): 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 + """Everything in `extra` that is safe to show anyone. `EventExtra` adds the organizer-only promo codes on top; `PublicEventExtra` is this base, so anonymous responses can never carry them.""" @@ -74,19 +73,6 @@ class EventExtraBase(BaseModel): # `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): @@ -106,6 +92,12 @@ class EventExtraBase(BaseModel): class EventExtra(EventExtraBase): promo_codes: list[PromoCode] = 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. + ticket_waves: list[TicketWave] = Field(default_factory=list) PublicEventExtra = EventExtraBase @@ -224,14 +216,6 @@ def effective_payment_methods( """ 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: diff --git a/static/js/index.js b/static/js/index.js index fc6e807..edddaba 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -414,26 +414,6 @@ window.PageEvents = { 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 => diff --git a/static/js/index.vue b/static/js/index.vue index 4795a3c..3ffe9bb 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -271,7 +271,23 @@ > diff --git a/tests/test_publish_active_wave.py b/tests/test_publish_active_wave.py index 964a0c4..7c73781 100644 --- a/tests/test_publish_active_wave.py +++ b/tests/test_publish_active_wave.py @@ -158,38 +158,3 @@ def test_advertised_wave_key_tracks_the_published_wave(): # 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_ticket_models.py b/tests/test_ticket_models.py index 6d782d2..7d1ab3f 100644 --- a/tests/test_ticket_models.py +++ b/tests/test_ticket_models.py @@ -5,10 +5,6 @@ from ..models import ( CreateEvent, CreateTicket, EventExtra, - PromoCode, - PublicEvent, - PublicEventExtra, - TicketWave, effective_payment_methods, ) @@ -112,108 +108,3 @@ def test_payment_methods_are_normalised_and_deduplicated(): 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_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/views_api.py b/views_api.py index 94b5bad..91a03f3 100644 --- a/views_api.py +++ b/views_api.py @@ -416,20 +416,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