Compare commits

...

2 commits

Author SHA1 Message Date
09c81c377b Merge pull request 'Require a capacity on every ticket wave' (#64) from fix/per-wave-capacity into main
Some checks failed
lint.yml / Merge pull request 'Require a capacity on every ticket wave' (#64) from fix/per-wave-capacity into main (push) Failing after 0s
Reviewed-on: #64
2026-09-28 22:03:03 +00:00
ecd05b4168 fix: require a capacity on every ticket wave
Some checks failed
lint.yml / fix: require a capacity on every ticket wave (pull_request) Failing after 0s
"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.
2026-09-28 23:11:30 +02:00
5 changed files with 166 additions and 2 deletions

View file

@ -115,7 +115,12 @@ class CreateEvent(BaseModel):
currency: str = "sat" currency: str = "sat"
allow_fiat: bool = False allow_fiat: bool = False
fiat_currency: str = "GBP" 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 price_per_ticket: float = 0 # 0 = free
banner: str | None = None banner: str | None = None
location: str | None = None # venue/address (NIP-52 'location' tag) location: str | None = None # venue/address (NIP-52 'location' tag)

View file

@ -901,7 +901,11 @@ window.PageEvents = {
primaryWave.fiat_currency || primaryWave.fiat_currency ||
event.fiat_currency || event.fiat_currency ||
'GBP', '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: price_per_ticket:
wave?.price_per_ticket || wave?.price_per_ticket ||
primaryWave.price_per_ticket || primaryWave.price_per_ticket ||

View file

@ -1094,7 +1094,9 @@
dense dense
v-model.number="ticketWaveDialog.data.amount_tickets" v-model.number="ticketWaveDialog.data.amount_tickets"
type="number" type="number"
min="1"
label="Amount of tickets" label="Amount of tickets"
hint="Tickets on sale in this wave"
></q-input> ></q-input>
</div> </div>
<div class="col"> <div class="col">

View file

@ -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)

View file

@ -328,6 +328,37 @@ async def api_get_event(event_id: str) -> Event:
return 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("") @events_api_router.post("")
async def api_event_create( async def api_event_create(
data: CreateEvent, data: CreateEvent,
@ -341,6 +372,8 @@ async def api_event_create(
if not data.wallet: if not data.wallet:
data.wallet = wallet.wallet.id data.wallet = wallet.wallet.id
_validate_wave_capacity(data)
from lnbits.settings import settings from lnbits.settings import settings
ext_settings = await get_settings() ext_settings = await get_settings()
@ -383,6 +416,8 @@ 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.")
_validate_wave_capacity(data, event)
from lnbits.settings import settings from lnbits.settings import settings
ext_settings = await get_settings() ext_settings = await get_settings()