Compare commits

..

11 commits

Author SHA1 Message Date
cadf30b704 chore(release): v1.6.8-aio.4
Some checks failed
lint.yml / chore(release): v1.6.8-aio.4 (push) Failing after 0s
- #68 ticket waves never rendered in the organiser table. The chip built
  its label inline with `LNbits.utils.formatCurrency(...)`, but Vue
  resolves template expressions against the component instance, where the
  `LNbits` global is not in scope. It threw "Cannot read properties of
  undefined (reading 'utils')" and the whole v-for rendered nothing, so
  every event showed an empty wave list however many waves it had.
  Organisers could not see or edit a wave from the LNbits UI at all.

The line came in with the v1.6.8 merge: it is upstream's, and the only
`LNbits.` reference in any template here — display.vue and ticket.vue
have none. `waveSummary` now builds the label in index.js, and trims the
time off wave dates for display.

Frontend only; no Python, no schema, no migration.

Verified in a browser against a two-wave event before tagging: both
chips render with formatted currency and no TypeError.
2026-10-03 21:06:55 +02:00
0e9cc76fef Merge pull request 'Ticket waves never rendered in the organiser table' (#68) from fix/wave-chip-lnbits-global into main
Some checks failed
lint.yml / Merge pull request 'Ticket waves never rendered in the organiser table' (#68) from fix/wave-chip-lnbits-global into main (push) Failing after 0s
Reviewed-on: #68
2026-10-03 19:05:55 +00:00
b4ca9fe65d fix(ui): ticket waves never rendered in the organiser table
Some checks failed
lint.yml / fix(ui): ticket waves never rendered in the organiser table (pull_request) Failing after 0s
The expanded event row showed "Ticket waves" with a + button and an
empty list, however many waves the event had. Reported from aio-demo and
reproduced in a fresh incognito window, so not a caching artifact.

The chip built its label inline with `LNbits.utils.formatCurrency(...)`.
Vue resolves template expressions against the component instance, where
the `LNbits` global is not in scope, so the expression threw

    TypeError: Cannot read properties of undefined (reading 'utils')

and the whole v-for rendered nothing. The promo-code chips beside it
were unaffected, which is what made the list look like a data problem —
the waves were always present in the response.

That line arrived with the v1.6.8 merge: it is upstream's, and upstream
is the only `LNbits.` reference in any template in this extension
(display.vue and ticket.vue have none). So the convention here was
already "format in a method", and the merge quietly broke it.

`waveSummary` now builds the label in index.js, where the global is in
scope. It also trims the day off wave dates for display — closing_date
defaults from event_end_date and can carry a time, which was rendering
as "2026-12-09T16:00:00+01:00" in the chip.

Verified in a browser against a two-wave event: both chips render as
"Primary wave - 2026-10-03 to 2026-11-02 - €30.00 - 50 tickets - 0 sold"
with no TypeError.
2026-10-03 20:53:27 +02:00
69c9a4f757 chore(release): v1.6.8-aio.3
Some checks failed
lint.yml / chore(release): v1.6.8-aio.3 (push) Failing after 0s
- #67 honour per-wave fiat when the organiser set an explicit rail list.
  `effective_payment_methods` returned `extra.payment_methods` before
  consulting the wave, so the `wave` argument added for #61 did nothing
  in the common case — the webapp always sets that list. The NIP-52 tag
  advertised `tickets_payment_methods: lightning,fiat` while omitting
  `tickets_allow_fiat`, and the checkout offered a card button that
  `api_ticket_create` then refused. Reported from aio-demo.

Eight lines in `models.py`; no schema change, no migration,
`migrations.py` still byte-identical to upstream v1.6.8.

The organiser-facing symptom is already gone on demo — aiolabs/webapp#179
makes the Card checkbox govern every wave, and re-saving the event
corrected all four. This is the backend half: it stops the published tag
contradicting itself when a wave genuinely cannot take fiat, which the
LNbits admin's per-wave toggles still allow.
2026-10-03 07:51:46 +02:00
33501dfc90 Merge pull request 'Honour per-wave fiat when the organiser set an explicit rail list' (#67) from fix/wave-aware-payment-methods into main
Some checks failed
lint.yml / Merge pull request 'Honour per-wave fiat when the organiser set an explicit rail list' (#67) from fix/wave-aware-payment-methods into main (push) Failing after 0s
Reviewed-on: #67
2026-10-03 05:50:43 +00:00
b55d6866d6 fix: honour per-wave fiat when the organiser set an explicit rail list
Some checks failed
lint.yml / fix: honour per-wave fiat when the organiser set an explicit rail list (pull_request) Failing after 0s
Reported from aio-demo: an event with fiat enabled, Card offered at
checkout, and the purchase refused with "Fiat payments are not enabled
for this ticket wave."

`effective_payment_methods` returned the organiser's explicit
`extra.payment_methods` list before ever consulting the wave:

    explicit = list(...)
    if explicit:
        return explicit          # <- the wave never got a look in

So the `wave` argument I added for #61 did nothing in the common case.
The webapp always sets `extra.payment_methods` from its payment-method
checkboxes, which means the explicit path is the normal one, not the
exception — three layers then disagreed:

- the NIP-52 tag advertised `tickets_payment_methods: lightning,fiat`
  while omitting `tickets_allow_fiat`, contradicting itself
- the checkout rendered a Card button
- `api_ticket_create`, the only wave-aware check, refused the purchase

Asking about a specific wave means asking what a buyer can actually use
for it, so a rail that wave cannot honour is now dropped. The
event-level question (no wave) still reports every rail the organiser
enabled — that is what `/republish-all` and the admin views want.

Fiat-only rails on a non-fiat wave now yield an empty list, which is
honest: nothing is purchasable from that wave.

5 tests, including both publisher shapes with an explicit list — the
case that actually bit, and which the #61 tests missed because their
fixtures left `payment_methods` empty. 133 pass.

Does not fix the data on events already created through the webapp:
their waves were saved with `allow_fiat` unset, so those waves genuinely
cannot take fiat until toggled. That is aiolabs/webapp's side.
2026-09-30 19:32:07 +02:00
fe40d2ab14 chore(release): v1.6.8-aio.2
Some checks failed
lint.yml / chore(release): v1.6.8-aio.2 (push) Failing after 0s
Two wave fixes found while checking whether the webapp had been updated
for v1.6.8.

- #65 keep ticket waves when a client edits an event without them. A
  client that rebuilt the `extra` envelope rather than round-tripping it
  destroyed every wave: the list arrived empty, a single primary wave was
  synthesized from the event-level `amount_tickets`, and a multi-wave
  event silently collapsed into one tier. `promo_codes` had carried the
  same guard since the v1.6.8 merge; `ticket_waves` is the same class of
  organiser state and now carries it too.
- #66 expose `ticket_waves` on public event responses. A buyer cannot
  choose a tier without its id, and nothing public carried one — the
  NIP-52 tags describe the active wave but name no id. Waves move to
  `EventExtraBase`, leaving promo codes as the only organiser-private
  field in `extra`. Buyers can now see upcoming tiers and their prices,
  which is what #61 recorded as the cost of the flat-tag decision.

No schema change; both are model/serialisation only.

Verified against bohm's dev LNbits before tagging: the exact PUT that
collapsed a two-wave event now leaves both intact with the roll-up
unchanged, the public endpoint carries the waves while still hiding
promo codes, and a webapp-shaped edit that writes through to the primary
wave takes effect (139 = 99 + 40) where it was previously discarded.

#66 is a prerequisite for aiolabs/webapp#176, which is merged to dev and
needs this deployed before its wave picker works for anyone but the
organiser.
2026-09-29 09:24:36 +02:00
26c0a5c429 Merge pull request 'Expose ticket waves on public event responses' (#66) from feat/public-ticket-waves into main
Some checks failed
lint.yml / Merge pull request 'Expose ticket waves on public event responses' (#66) from feat/public-ticket-waves into main (push) Failing after 0s
Reviewed-on: #66
2026-09-29 06:58:36 +00:00
e706b46003 Merge pull request 'Keep ticket waves when a client edits an event without them' (#65) from fix/preserve-ticket-waves-on-edit into main
Some checks failed
lint.yml / Merge pull request 'Keep ticket waves when a client edits an event without them' (#65) from fix/preserve-ticket-waves-on-edit into main (push) Failing after 0s
Reviewed-on: #65
2026-09-29 06:58:25 +00:00
209a895415 feat: expose ticket waves on public event responses
Some checks failed
lint.yml / feat: expose ticket waves on public event responses (pull_request) Failing after 0s
A buyer cannot choose a ticket wave without its id, and nothing public
carried one: `PublicEventExtra` is `EventExtraBase`, which did not
include `ticket_waves`, and the NIP-52 tags deliberately publish only
the active wave with no identifier (#61). So `GET /events/{id}` and
`/events/public` gave a client everything except the one field it needs
to say which tier it wants — and the purchase endpoint refuses when
several waves are open.

Moves `ticket_waves` from `EventExtra` to `EventExtraBase`, leaving
`promo_codes` as the only organizer-private field in `extra`.

A wave holds an id, title, date window, currency, price, remaining stock
and the fiat flags — the sales information a buyer needs in order to
choose. The only thing publishing it reveals is the upcoming price
schedule, which is precisely what #61 recorded as the cost of settling
on flat Nostr tags; over REST it is a gain rather than a leak.

Prerequisite for wave support in the webapp, which otherwise cannot
offer a wave picker to anyone but the organizer.

Two tests pin the projection: the public extra carries waves and never
promo codes, and a serialised `PublicEvent` shows the same.
2026-09-29 08:14:49 +02:00
064795c62a fix: keep ticket waves when a client edits an event without them
Some checks failed
lint.yml / fix: keep ticket waves when a client edits an event without them (pull_request) Failing after 0s
`api_event_update` replaces `extra` wholesale, so a client that rebuilds
the envelope rather than round-tripping it destroyed every wave: the list
arrived empty, `ensure_ticket_waves` synthesized a single primary wave
from the event-level `amount_tickets`, and a multi-wave event silently
collapsed into one tier carrying whatever numbers that client sent.

Reproduced against a running instance before fixing — a PUT whose `extra`
omitted the key turned a two-wave event into:

    amount_tickets=999 price=77.0 waves=1
    primary "Primary wave" 77.0 999

`promo_codes` has carried the same guard since the v1.6.8 merge, for the
same reason and in the same function; `ticket_waves` is the same class of
state — organiser-managed, living in `extra`, and invisible to a client
that does not implement it. I added the first and did not extend the
reasoning to the second.

The carry-over runs before `_validate_wave_capacity` so validation sees
the waves the event will actually end up with; otherwise a client that
omitted both the waves and a real capacity would be rejected for a
zero-capacity primary wave that existed only because its waves had just
been dropped. An explicit `[]` still resets, matching promo codes.

Note the aio webapp is not what surfaced this — it spreads the existing
`extra` and so preserves waves by accident. Any client that does not is
exposed.

6 tests, including one pinning the ordering and one that fails if either
organiser-owned `extra` field loses its guard. 126 pass.
2026-09-29 08:12:09 +02:00
8 changed files with 327 additions and 25 deletions

View file

@ -1,6 +1,6 @@
{ {
"id": "events", "id": "events",
"version": "1.6.8-aio.1", "version": "1.6.8-aio.4",
"name": "Events", "name": "Events",
"repo": "https://git.atitlan.io/aiolabs/events", "repo": "https://git.atitlan.io/aiolabs/events",
"short_description": "Sell and register event tickets", "short_description": "Sell and register event tickets",

View file

@ -58,7 +58,8 @@ class TicketWave(BaseModel):
class EventExtraBase(BaseModel): class EventExtraBase(BaseModel):
"""Everything in `extra` that is safe to show anyone. `EventExtra` adds """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, the organizer-only promo codes on top; `PublicEventExtra` is this base,
so anonymous responses can never carry them.""" so anonymous responses can never carry them."""
@ -73,6 +74,19 @@ class EventExtraBase(BaseModel):
# `effective_payment_methods`. Same field name/shape as upstream v2 so the # `effective_payment_methods`. Same field name/shape as upstream v2 so the
# eventual rebase (#33) merges cleanly. # eventual rebase (#33) merges cleanly.
payment_methods: list[str] = Field(default_factory=list) 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) @validator("payment_methods", pre=True)
def normalize_payment_methods(cls, v): def normalize_payment_methods(cls, v):
@ -92,12 +106,6 @@ class EventExtraBase(BaseModel):
class EventExtra(EventExtraBase): class EventExtra(EventExtraBase):
promo_codes: list[PromoCode] = Field(default_factory=list) 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 PublicEventExtra = EventExtraBase
@ -216,6 +224,14 @@ def effective_payment_methods(
""" """
explicit = list(getattr(event.extra, "payment_methods", []) or []) explicit = list(getattr(event.extra, "payment_methods", []) or [])
if explicit: 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 return explicit
methods = ["lightning"] methods = ["lightning"]
if wave.allow_fiat if wave is not None else event.allow_fiat: if wave.allow_fiat if wave is not None else event.allow_fiat:

View file

@ -414,6 +414,26 @@ window.PageEvents = {
this.ticketImageUploadTarget = null 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) { soldTicketsForWave(eventId, waveId) {
return this.allPaidTickets.filter( return this.allPaidTickets.filter(
ticket => ticket =>

View file

@ -271,23 +271,7 @@
> >
<span <span
style="white-space: normal; line-height: 1.3" style="white-space: normal; line-height: 1.3"
v-text=" v-text="waveSummary(props.row.id, wave)"
`${wave.title} - ${wave.opening_date} to ${
wave.closing_date
} - ${
isFiatCurrency(wave.currency)
? LNbits.utils.formatCurrency(
Number(
wave.price_per_ticket || 0
).toFixed(2),
wave.currency
)
: `${wave.price_per_ticket} sats`
} - ${wave.amount_tickets} tickets - ${soldTicketsForWave(
props.row.id,
wave.id
)} sold`
"
></span> ></span>
</q-chip> </q-chip>
</div> </div>

View file

@ -158,3 +158,38 @@ def test_advertised_wave_key_tracks_the_published_wave():
# Published while nothing is on sale — a real state, distinct from the # Published while nothing is on sale — a real state, distinct from the
# NULL that means "never published". # NULL that means "never published".
assert advertised_wave_key(_event([CLOSED_EARLY_BIRD])) == "" 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"

View file

@ -5,6 +5,10 @@ from ..models import (
CreateEvent, CreateEvent,
CreateTicket, CreateTicket,
EventExtra, EventExtra,
PromoCode,
PublicEvent,
PublicEventExtra,
TicketWave,
effective_payment_methods, effective_payment_methods,
) )
@ -108,3 +112,108 @@ def test_payment_methods_are_normalised_and_deduplicated():
def test_unknown_payment_method_is_rejected(): def test_unknown_payment_method_is_rejected():
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
EventExtra(payment_methods=["cash"]) 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)) == []

View file

@ -0,0 +1,124 @@
"""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"

View file

@ -416,6 +416,20 @@ async def api_event_update(
if event.wallet != wallet.wallet.id: if event.wallet != wallet.wallet.id:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.") 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) _validate_wave_capacity(data, event)
from lnbits.settings import settings from lnbits.settings import settings