Add support for reserved seating (#1228)

* Initial work on seating

* Add seat guids

* Add product_list_top

* CartAdd: Ignore item when a seat is passed

* Cart display

* product_list_top → render_seating_plan

* Render seating plan in voucher redemption

* Fix failing tests

* Add tests for extending cart positions with seats

* Add subevent_forms to docs

* Update schema, migrations

* Dealing with expired orders

* steps to order change

* Change order positions

* Allow to add seats

* tests for ocm

* Fix things after rebase

* Seating plans API

* Add more tests for cart behaviour

* Widget support

* Adjust widget tests

* Re-enable CSP

* Update schema

* Api: position.seat

* Add guid to word list

* API: (sub)event.seating_plan

* Vali fixes

* Fix api

* Fix reference in test

* Fix test for real
This commit is contained in:
Raphael Michel
2019-06-25 11:00:03 +02:00
committed by GitHub
parent f79d17cb6a
commit 93089d87e3
77 changed files with 3689 additions and 164 deletions

View File

@@ -18,7 +18,7 @@ from pretix.base.i18n import language
from pretix.base.models import (
CachedFile, CartPosition, Checkin, CheckinList, Event, Item, ItemCategory,
ItemVariation, Order, OrderFee, OrderPayment, OrderPosition, OrderRefund,
Organizer, Question, Quota, User, Voucher, WaitingListEntry,
Organizer, Question, Quota, SeatingPlan, User, Voucher, WaitingListEntry,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.items import (
@@ -1971,6 +1971,157 @@ class CheckinListTestCase(TestCase):
assert lists[3].percent == 25
class SeatingTestCase(TestCase):
def setUp(self):
self.organizer = Organizer.objects.create(name='Dummy', slug='dummy')
with scope(organizer=self.organizer):
self.event = Event.objects.create(
organizer=self.organizer, name='Dummy', slug='dummy',
date_from=now(), date_to=now() - timedelta(hours=1),
)
self.ticket = self.event.items.create(name="Ticket", default_price=12)
self.plan = SeatingPlan.objects.create(
name="Plan", organizer=self.organizer, layout="{}"
)
self.event.seat_category_mappings.create(
layout_category='Stalls', product=self.ticket
)
self.seat_a1 = self.event.seats.create(name="A1", product=self.ticket, blocked=False)
self.seat_a2 = self.event.seats.create(name="A2", product=self.ticket, blocked=False)
@classscope(attr='organizer')
def test_free(self):
assert set(self.event.free_seats) == {self.seat_a1, self.seat_a2}
assert self.seat_a1.is_available()
assert self.seat_a2.is_available()
@classscope(attr='organizer')
def test_blocked(self):
self.seat_a1.blocked = True
self.seat_a1.save()
assert set(self.event.free_seats) == {self.seat_a2}
assert not self.seat_a1.is_available()
assert self.seat_a2.is_available()
@classscope(attr='organizer')
def test_order_pending(self):
o = Order.objects.create(
code='FOO', event=self.event, email='dummy@dummy.test', total=Decimal("30"),
locale='en', status=Order.STATUS_PENDING, datetime=now(),
expires=now() + timedelta(days=10),
)
OrderPosition.objects.create(
order=o, item=self.ticket, variation=None, price=Decimal("12"),
seat=self.seat_a1
)
assert set(self.event.free_seats) == {self.seat_a2}
assert not self.seat_a1.is_available()
@classscope(attr='organizer')
def test_order_paid(self):
o = Order.objects.create(
code='FOO', event=self.event, email='dummy@dummy.test', total=Decimal("30"),
locale='en', status=Order.STATUS_PAID, datetime=now(),
expires=now() + timedelta(days=10),
)
OrderPosition.objects.create(
order=o, item=self.ticket, variation=None, price=Decimal("12"),
seat=self.seat_a1
)
assert set(self.event.free_seats) == {self.seat_a2}
assert not self.seat_a1.is_available()
@classscope(attr='organizer')
def test_order_expired(self):
o = Order.objects.create(
code='FOO', event=self.event, email='dummy@dummy.test', total=Decimal("30"),
locale='en', status=Order.STATUS_EXPIRED, datetime=now(),
expires=now() + timedelta(days=10),
)
OrderPosition.objects.create(
order=o, item=self.ticket, variation=None, price=Decimal("12"),
seat=self.seat_a1
)
assert set(self.event.free_seats) == {self.seat_a1, self.seat_a2}
assert self.seat_a1.is_available()
@classscope(attr='organizer')
def test_cart_active(self):
CartPosition.objects.create(
event=self.event, cart_id='a', item=self.ticket, seat=self.seat_a1,
price=23, expires=now() + timedelta(minutes=10)
)
assert set(self.event.free_seats) == {self.seat_a2}
assert not self.seat_a1.is_available()
@classscope(attr='organizer')
def test_cart_expired(self):
CartPosition.objects.create(
event=self.event, cart_id='a', item=self.ticket, seat=self.seat_a1,
price=23, expires=now() - timedelta(minutes=10)
)
assert set(self.event.free_seats) == {self.seat_a1, self.seat_a2}
assert self.seat_a1.is_available()
@classscope(attr='organizer')
def test_subevent_order_pending(self):
se1 = self.event.subevents.create(date_from=now(), name="SE 1")
self.seat_a1.subevent = se1
self.seat_a1.save()
o = Order.objects.create(
code='FOO', event=self.event, email='dummy@dummy.test', total=Decimal("30"),
locale='en', status=Order.STATUS_PAID, datetime=now(),
expires=now() + timedelta(days=10),
)
OrderPosition.objects.create(
order=o, item=self.ticket, variation=None, price=Decimal("12"),
seat=self.seat_a1, subevent=se1
)
assert set(se1.free_seats) == set()
assert not self.seat_a1.is_available()
@classscope(attr='organizer')
def test_subevent_order_canceled(self):
se1 = self.event.subevents.create(date_from=now(), name="SE 1")
self.seat_a1.subevent = se1
self.seat_a1.save()
o = Order.objects.create(
code='FOO', event=self.event, email='dummy@dummy.test', total=Decimal("30"),
locale='en', status=Order.STATUS_CANCELED, datetime=now(),
expires=now() + timedelta(days=10),
)
OrderPosition.objects.create(
order=o, item=self.ticket, variation=None, price=Decimal("12"),
seat=self.seat_a1, subevent=se1
)
assert set(se1.free_seats) == {self.seat_a1}
assert self.seat_a1.is_available()
@classscope(attr='organizer')
def test_subevent_cart_active(self):
se1 = self.event.subevents.create(date_from=now(), name="SE 1")
self.seat_a1.subevent = se1
self.seat_a1.save()
CartPosition.objects.create(
event=self.event, cart_id='a', item=self.ticket, seat=self.seat_a1,
price=23, expires=now() + timedelta(minutes=10), subevent=se1
)
assert set(se1.free_seats) == set()
assert not self.seat_a1.is_available()
@classscope(attr='organizer')
def test_subevent_cart_expired(self):
se1 = self.event.subevents.create(date_from=now(), name="SE 1")
self.seat_a1.subevent = se1
self.seat_a1.save()
CartPosition.objects.create(
event=self.event, cart_id='a', item=self.ticket, seat=self.seat_a1,
price=23, expires=now() - timedelta(minutes=10), subevent=se1
)
assert set(se1.free_seats) == {self.seat_a1}
assert self.seat_a1.is_available()
@pytest.mark.django_db
@pytest.mark.parametrize("qtype,answer,expected", [
(Question.TYPE_STRING, "a", "a"),

View File

@@ -12,6 +12,7 @@ from django_scopes import scope
from pretix.base.decimal import round_decimal
from pretix.base.models import (
CartPosition, Event, InvoiceAddress, Item, Order, OrderPosition, Organizer,
SeatingPlan,
)
from pretix.base.models.items import SubEventItem
from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
@@ -635,6 +636,19 @@ class OrderChangeManagerTests(TestCase):
self.quota.items.add(self.ticket2)
self.quota.items.add(self.shirt)
self.stalls = Item.objects.create(event=self.event, name='Stalls', tax_rule=self.tr7,
default_price=Decimal('23.00'), admission=True)
self.plan = SeatingPlan.objects.create(
name="Plan", organizer=self.o, layout="{}"
)
self.event.seat_category_mappings.create(
layout_category='Stalls', product=self.stalls
)
self.quota.items.add(self.stalls)
self.seat_a1 = self.event.seats.create(name="A1", product=self.stalls, seat_guid="A1")
self.seat_a2 = self.event.seats.create(name="A2", product=self.stalls, seat_guid="A2")
self.seat_a3 = self.event.seats.create(name="A3", product=self.stalls, seat_guid="A3")
def _enable_reverse_charge(self):
self.tr7.eu_reverse_charge = True
self.tr7.home_country = Country('DE')
@@ -1631,3 +1645,206 @@ class OrderChangeManagerTests(TestCase):
assert self.order.status == Order.STATUS_PENDING
assert o2.total == Decimal('0.00')
assert o2.status == Order.STATUS_PAID
@classscope(attr='o')
def test_change_seat_circular(self):
self.op1.seat = self.seat_a1
self.op1.save()
self.op2.seat = self.seat_a2
self.op2.save()
self.ocm.change_seat(self.op1, self.seat_a2)
self.ocm.change_seat(self.op2, self.seat_a1)
self.ocm.commit()
self.op1.refresh_from_db()
self.op2.refresh_from_db()
assert self.op1.seat == self.seat_a2
assert self.op2.seat == self.seat_a1
@classscope(attr='o')
def test_change_seat_duplicate(self):
self.op1.seat = self.seat_a1
self.op1.save()
self.op2.seat = self.seat_a2
self.op2.save()
self.ocm.change_seat(self.op1, self.seat_a2)
with self.assertRaises(OrderError):
self.ocm.commit()
self.op1.refresh_from_db()
assert self.op1.seat == self.seat_a1
@classscope(attr='o')
def test_change_seat_and_cancel(self):
self.op1.seat = self.seat_a1
self.op1.save()
self.op2.seat = self.seat_a2
self.op2.save()
self.ocm.change_seat(self.op1, self.seat_a2)
self.ocm.cancel(self.op2)
self.ocm.commit()
self.op1.refresh_from_db()
assert self.op1.seat == self.seat_a2
@classscope(attr='o')
def test_change_seat(self):
self.op1.seat = self.seat_a1
self.op1.save()
self.ocm.change_seat(self.op1, self.seat_a2)
self.ocm.commit()
self.op1.refresh_from_db()
assert self.op1.seat == self.seat_a2
@classscope(attr='o')
def test_change_add_seat(self):
# does this make sense or do we block it based on the item? this is currently not reachable through the UI
self.ocm.change_seat(self.op1, self.seat_a1)
self.ocm.commit()
self.op1.refresh_from_db()
assert self.op1.seat == self.seat_a1
@classscope(attr='o')
def test_remove_seat(self):
# does this make sense or do we block it based on the item? this is currently not reachable through the UI
self.op1.seat = self.seat_a1
self.op1.save()
self.ocm.change_seat(self.op1, None)
self.ocm.commit()
self.op1.refresh_from_db()
assert self.op1.seat is None
@classscope(attr='o')
def test_add_with_seat(self):
self.ocm.add_position(self.stalls, None, price=Decimal('13.00'), seat=self.seat_a3)
self.ocm.commit()
op3 = self.order.positions.last()
assert op3.item == self.stalls
assert op3.seat == self.seat_a3
@classscope(attr='o')
def test_add_with_taken_seat(self):
self.op1.seat = self.seat_a1
self.op1.save()
self.ocm.add_position(self.stalls, None, price=Decimal('13.00'), seat=self.seat_a1)
with self.assertRaises(OrderError):
self.ocm.commit()
@classscope(attr='o')
def test_add_with_seat_required(self):
with self.assertRaises(OrderError):
self.ocm.add_position(self.stalls, None, price=Decimal('13.00'))
@classscope(attr='o')
def test_add_with_seat_forbidden(self):
with self.assertRaises(OrderError):
self.ocm.add_position(self.ticket, None, price=Decimal('13.00'), seat=self.seat_a1)
@classscope(attr='o')
def test_add_with_seat_blocked(self):
self.seat_a1.blocked = True
self.seat_a1.save()
self.ocm.add_position(self.stalls, None, price=Decimal('13.00'), seat=self.seat_a1)
with self.assertRaises(OrderError):
self.ocm.commit()
@classscope(attr='o')
def test_change_seat_to_blocked(self):
self.seat_a2.blocked = True
self.seat_a2.save()
self.op1.seat = self.seat_a1
self.op1.save()
self.ocm.change_seat(self.op1, self.seat_a2)
with self.assertRaises(OrderError):
self.ocm.commit()
self.op1.refresh_from_db()
assert self.op1.seat == self.seat_a1
@classscope(attr='o')
def test_change_seat_require_subevent_change(self):
self.event.has_subevents = True
self.event.save()
se1 = self.event.subevents.create(name="Foo", date_from=now())
se2 = self.event.subevents.create(name="Bar", date_from=now())
self.op1.subevent = se1
self.op1.seat = self.seat_a1
self.op1.save()
self.quota.subevent = se2
self.quota.save()
self.seat_a1.subevent = se1
self.seat_a1.save()
self.seat_a2.subevent = se2
self.seat_a2.save()
self.ocm.change_seat(self.op1, self.seat_a2)
with self.assertRaises(OrderError):
self.ocm.commit()
@classscope(attr='o')
def test_change_subevent_require_seat_change(self):
self.event.has_subevents = True
self.event.save()
se1 = self.event.subevents.create(name="Foo", date_from=now())
se2 = self.event.subevents.create(name="Bar", date_from=now())
self.op1.subevent = se1
self.op1.seat = self.seat_a1
self.op1.save()
self.quota.subevent = se2
self.quota.save()
self.seat_a1.subevent = se1
self.seat_a1.save()
self.seat_a2.subevent = se2
self.seat_a2.save()
self.ocm.change_subevent(self.op1, se2)
with self.assertRaises(OrderError):
self.ocm.commit()
@classscope(attr='o')
def test_change_subevent_and_seat(self):
self.event.has_subevents = True
self.event.save()
se1 = self.event.subevents.create(name="Foo", date_from=now())
se2 = self.event.subevents.create(name="Bar", date_from=now())
self.op1.subevent = se1
self.op1.seat = self.seat_a1
self.op1.save()
self.quota.subevent = se2
self.quota.save()
self.seat_a1.subevent = se1
self.seat_a1.save()
self.seat_a2.subevent = se2
self.seat_a2.save()
self.ocm.change_subevent(self.op1, se2)
self.ocm.change_seat(self.op1, self.seat_a2)
self.ocm.commit()
self.op1.refresh_from_db()
assert self.op1.seat == self.seat_a2
assert self.op1.subevent == se2
@classscope(attr='o')
def test_change_seat_inside_subevent(self):
self.event.has_subevents = True
self.event.save()
se1 = self.event.subevents.create(name="Foo", date_from=now())
self.op1.subevent = se1
self.op1.seat = self.seat_a1
self.op1.save()
self.seat_a1.subevent = se1
self.seat_a1.save()
self.seat_a2.subevent = se1
self.seat_a2.save()
self.ocm.change_seat(self.op1, self.seat_a2)
self.ocm.commit()
self.op1.refresh_from_db()
assert self.op1.seat == self.seat_a2
assert self.op1.subevent == se1
@classscope(attr='o')
def test_add_with_seat_and_subevent_mismatch(self):
self.event.has_subevents = True
self.event.save()
se1 = self.event.subevents.create(name="Foo", date_from=now())
se2 = self.event.subevents.create(name="Bar", date_from=now())
self.quota.subevent = se2
self.quota.save()
self.seat_a1.subevent = se1
self.seat_a1.save()
self.ocm.change_subevent(self.op1, se2)
with self.assertRaises(OrderError):
self.ocm.add_position(self.ticket, None, price=Decimal('13.00'), subevent=se2, seat=self.seat_a1)