From ecd05b416865ff828caa10a2ca3afca4467ff4a1 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 28 Sep 2026 23:11:30 +0200 Subject: [PATCH] fix: require a capacity on every ticket wave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Capacity is always required, there is no unlimited" was settled on #34 and enforced in #62 — but as a single number on the event. Since v1.6.8 capacity is per-wave and `event.amount_tickets` is a derived roll-up (`sync_event_ticket_waves`), so the rule had nothing holding it up at the level organisers actually set: the wave form's capacity input had no minimum, and nothing on the backend checked one at all. 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, on the card and at checkout both. `_validate_wave_capacity` now runs on create and update. Two details that matter: - it checks `ensure_ticket_waves(data)` rather than the raw list, so an event submitted with no waves is checked through the primary wave it is about to be given, not vacuously passed - on an edit it only checks waves NEW to the event. Selling out is the legitimate route to zero, and rejecting it would make a sold-out event uneditable — including the legacy zero-capacity rows this rule exists to let organisers fix Frontend: the wave dialog's capacity input gains the `min="1"` and hint the event-level field already had, and a new wave opens at 1 rather than 0. That default uses `??`, not `||` — a sold-out wave holds 0 and must keep showing it instead of silently regaining stock when saved. Also drops the stale `0 = unlimited / not ticketed` contract still documented on `CreateEvent.amount_tickets`, which has not been true since #62. 6 new tests; 120 pass. ruff, black, prettier clean; mypy error set unchanged from baseline. --- models.py | 7 +- static/js/index.js | 6 +- static/js/index.vue | 2 + tests/test_wave_capacity_required.py | 118 +++++++++++++++++++++++++++ views_api.py | 35 ++++++++ 5 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 tests/test_wave_capacity_required.py diff --git a/models.py b/models.py index 92dcd84..1768b44 100644 --- a/models.py +++ b/models.py @@ -115,7 +115,12 @@ class CreateEvent(BaseModel): currency: str = "sat" allow_fiat: bool = False fiat_currency: str = "GBP" - amount_tickets: int = 0 # 0 = unlimited / not ticketed + # 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 price_per_ticket: float = 0 # 0 = free banner: str | None = None location: str | None = None # venue/address (NIP-52 'location' tag) diff --git a/static/js/index.js b/static/js/index.js index f702a4e..edddaba 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -901,7 +901,11 @@ window.PageEvents = { primaryWave.fiat_currency || event.fiat_currency || 'GBP', - amount_tickets: wave?.amount_tickets || 0, + // `??` 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 || diff --git a/static/js/index.vue b/static/js/index.vue index a52d056..3ffe9bb 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -1094,7 +1094,9 @@ dense v-model.number="ticketWaveDialog.data.amount_tickets" type="number" + min="1" label="Amount of tickets" + hint="Tickets on sale in this wave" >
diff --git a/tests/test_wave_capacity_required.py b/tests/test_wave_capacity_required.py new file mode 100644 index 0000000..5f40c4c --- /dev/null +++ b/tests/test_wave_capacity_required.py @@ -0,0 +1,118 @@ +"""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/views_api.py b/views_api.py index 8a79af9..91a03f3 100644 --- a/views_api.py +++ b/views_api.py @@ -328,6 +328,37 @@ async def api_get_event(event_id: str) -> Event: 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, @@ -341,6 +372,8 @@ 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() @@ -383,6 +416,8 @@ async def api_event_update( if event.wallet != wallet.wallet.id: raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.") + _validate_wave_capacity(data, event) + from lnbits.settings import settings ext_settings = await get_settings()