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

@@ -24,6 +24,7 @@ from .orders import (
from .organizer import (
Organizer, Organizer_SettingsStore, Team, TeamAPIToken, TeamInvite,
)
from .seating import Seat, SeatCategoryMapping, SeatingPlan
from .tax import TaxRule
from .vouchers import Voucher
from .waitinglist import WaitingListEntry

View File

@@ -336,6 +336,8 @@ class Event(EventMixin, LoggedModel):
verbose_name=_('Event series'),
default=False
)
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
related_name='events')
objects = ScopedManager(organizer='organizer')
@@ -348,6 +350,26 @@ class Event(EventMixin, LoggedModel):
def __str__(self):
return str(self.name)
@property
def free_seats(self):
from .orders import CartPosition, Order, OrderPosition
return self.seats.annotate(
has_order=Exists(
OrderPosition.objects.filter(
order__event=self,
seat_id=OuterRef('pk'),
order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID]
)
),
has_cart=Exists(
CartPosition.objects.filter(
event=self,
seat_id=OuterRef('pk'),
expires__gte=now()
)
)
).filter(has_order=False, has_cart=False, blocked=False)
@property
def presale_has_ended(self):
if self.has_subevents:
@@ -531,6 +553,24 @@ class Event(EventMixin, LoggedModel):
for i in items:
cl.limit_products.add(item_map[i.pk])
if other.seating_plan:
if other.seating_plan.organizer_id == self.organizer_id:
self.seating_plan = other.seating_plan
else:
self.organizer.seating_plans.create(name=other.seating_plan.name, layout=other.seating_plan.layout)
self.save()
for m in other.seat_category_mappings.filter(subevent__isnull=True):
m.pk = None
m.event = self
m.product = item_map[m.product_id]
m.save()
for s in other.seats.filter(subevent__isnull=True):
s.pk = None
s.event = self
s.save()
for s in other.settings._objects.all():
s.object = self
s.pk = None
@@ -874,6 +914,8 @@ class SubEvent(EventMixin, LoggedModel):
null=True, blank=True,
verbose_name=_("Frontpage text")
)
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
related_name='subevents')
items = models.ManyToManyField('Item', through='SubEventItem')
variations = models.ManyToManyField('ItemVariation', through='SubEventItemVariation')
@@ -888,6 +930,28 @@ class SubEvent(EventMixin, LoggedModel):
def __str__(self):
return '{} - {}'.format(self.name, self.get_date_range_display())
@property
def free_seats(self):
from .orders import CartPosition, Order, OrderPosition
return self.seats.annotate(
has_order=Exists(
OrderPosition.objects.filter(
order__event_id=self.event_id,
subevent=self,
seat_id=OuterRef('pk'),
order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID]
)
),
has_cart=Exists(
CartPosition.objects.filter(
event_id=self.event_id,
subevent=self,
seat_id=OuterRef('pk'),
expires__gte=now()
)
)
).filter(has_order=False, has_cart=False, blocked=False)
@cached_property
def settings(self):
return self.event.settings

View File

@@ -630,7 +630,7 @@ class Order(LockModel, LoggedModel):
), tz)
return term_last
def _can_be_paid(self, count_waitinglist=True, ignore_date=False) -> Union[bool, str]:
def _can_be_paid(self, count_waitinglist=True, ignore_date=False, force=False) -> Union[bool, str]:
error_messages = {
'late_lastdate': _("The payment can not be accepted as the last date of payments configured in the "
"payment settings is over."),
@@ -638,29 +638,37 @@ class Order(LockModel, LoggedModel):
"payments should be accepted in the payment settings."),
'require_approval': _('This order is not yet approved by the event organizer.')
}
if self.require_approval:
return error_messages['require_approval']
term_last = self.payment_term_last
if term_last and not ignore_date:
if now() > term_last:
return error_messages['late_lastdate']
if not force:
if self.require_approval:
return error_messages['require_approval']
term_last = self.payment_term_last
if term_last and not ignore_date:
if now() > term_last:
return error_messages['late_lastdate']
if self.status == self.STATUS_PENDING:
return True
if not self.event.settings.get('payment_term_accept_late') and not ignore_date:
if not self.event.settings.get('payment_term_accept_late') and not ignore_date and not force:
return error_messages['late']
return self._is_still_available(count_waitinglist=count_waitinglist)
return self._is_still_available(count_waitinglist=count_waitinglist, force=force)
def _is_still_available(self, now_dt: datetime=None, count_waitinglist=True) -> Union[bool, str]:
def _is_still_available(self, now_dt: datetime=None, count_waitinglist=True, force=False) -> Union[bool, str]:
error_messages = {
'unavailable': _('The ordered product "{item}" is no longer available.'),
'seat_unavailable': _('The seat "{seat}" is no longer available.'),
}
now_dt = now_dt or now()
positions = self.positions.all().select_related('item', 'variation')
positions = self.positions.all().select_related('item', 'variation', 'seat')
quota_cache = {}
try:
for i, op in enumerate(positions):
if op.seat:
if not op.seat.is_available(ignore_orderpos=op):
raise Quota.QuotaExceededException(error_messages['seat_unavailable'].format(seat=op.seat))
if force:
continue
quotas = list(op.quotas)
if len(quotas) == 0:
raise Quota.QuotaExceededException(error_messages['unavailable'].format(
@@ -938,6 +946,8 @@ class AbstractPosition(models.Model):
:type voucher: Voucher
:param meta_info: Additional meta information on the position, JSON-encoded.
:type meta_info: str
:param seat: Seat, if reserved seating is used.
:type seat: Seat
"""
subevent = models.ForeignKey(
SubEvent,
@@ -984,6 +994,9 @@ class AbstractPosition(models.Model):
verbose_name=_("Meta information"),
null=True, blank=True
)
seat = models.ForeignKey(
'Seat', null=True, blank=True, on_delete=models.PROTECT
)
class Meta:
abstract = True
@@ -1183,8 +1196,8 @@ class OrderPayment(models.Model):
def _mark_paid(self, force, count_waitinglist, user, auth, ignore_date=False, overpaid=False):
from pretix.base.signals import order_paid
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date)
if not force and can_be_paid is not True:
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date, force=force)
if can_be_paid is not True:
self.order.log_action('pretix.event.order.quotaexceeded', {
'message': can_be_paid
}, user=user, auth=auth)

View File

@@ -0,0 +1,111 @@
import json
from collections import namedtuple
import jsonschema
from django.contrib.staticfiles import finders
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.deconstruct import deconstructible
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import Event, Item, LoggedModel, Organizer, SubEvent
@deconstructible
class SeatingPlanLayoutValidator:
def __call__(self, value):
if not isinstance(value, dict):
try:
val = json.loads(value)
except ValueError:
raise ValidationError(_('Your layout file is not a valid JSON file.'))
else:
val = value
with open(finders.find('seating/seating-plan.schema.json'), 'r') as f:
schema = json.loads(f.read())
try:
jsonschema.validate(val, schema)
except jsonschema.ValidationError as e:
raise ValidationError(_('Your layout file is not a valid seating plan. Error message: {}').format(str(e)))
class SeatingPlan(LoggedModel):
"""
Represents an abstract seating plan, without relation to any event.
"""
name = models.CharField(max_length=190, verbose_name=_('Name'))
organizer = models.ForeignKey(Organizer, related_name='seating_plans', on_delete=models.CASCADE)
layout = models.TextField(validators=[SeatingPlanLayoutValidator()])
Category = namedtuple('Categrory', 'name')
RawSeat = namedtuple('Seat', 'name guid number row category')
def __str__(self):
return self.name
@property
def layout_data(self):
return json.loads(self.layout)
@layout_data.setter
def layout_data(self, v):
self.layout = json.dumps(v)
def get_categories(self):
return [
self.Category(name=c['name'])
for c in self.layout_data['categories']
]
def iter_all_seats(self):
for z in self.layout_data['zones']:
for r in z['rows']:
for s in r['seats']:
yield self.RawSeat(
number=s['seat_number'],
guid=s['seat_guid'],
name='{} {}'.format(r['row_number'], s['seat_number']), # TODO: Zone? Variable scheme?
row=r['row_number'],
category=s['category']
)
class SeatCategoryMapping(models.Model):
"""
Input seating plans have abstract "categories", such as "Balcony seat", etc. This model maps them to actual
pretix product on a per-(sub)event level.
"""
event = models.ForeignKey(Event, related_name='seat_category_mappings', on_delete=models.CASCADE)
subevent = models.ForeignKey(SubEvent, null=True, blank=True, related_name='seat_category_mappings', on_delete=models.CASCADE)
layout_category = models.CharField(max_length=190)
product = models.ForeignKey(Item, related_name='seat_category_mappings', on_delete=models.CASCADE)
class Seat(models.Model):
"""
This model is used to represent every single specific seat within an (sub)event that can be selected. It's mainly
used for internal bookkeeping and not to be modified by users directly.
"""
event = models.ForeignKey(Event, related_name='seats', on_delete=models.CASCADE)
subevent = models.ForeignKey(SubEvent, null=True, blank=True, related_name='seats', on_delete=models.CASCADE)
name = models.CharField(max_length=190)
seat_guid = models.CharField(max_length=190, db_index=True)
product = models.ForeignKey('Item', null=True, blank=True, related_name='seats', on_delete=models.CASCADE)
blocked = models.BooleanField(default=False)
def __str__(self):
return self.name
def is_available(self, ignore_cart=None, ignore_orderpos=None):
from .orders import Order
if self.blocked:
return False
opqs = self.orderposition_set.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
cpqs = self.cartposition_set.filter(expires__gte=now())
if ignore_cart:
cpqs = cpqs.exclude(pk=ignore_cart.pk)
if ignore_orderpos:
opqs = opqs.exclude(pk=ignore_orderpos.pk)
return not opqs.exists() and not cpqs.exists()

View File

@@ -10,6 +10,8 @@ from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled
from pretix.base.models import SeatCategoryMapping
from ..decimal import round_decimal
from .base import LoggedModel
from .event import Event, SubEvent
@@ -395,3 +397,11 @@ class Voucher(LoggedModel):
"""
return Order.objects.filter(all_positions__voucher__in=[self]).distinct()
def seating_available(self):
kwargs = {}
if self.subevent:
kwargs['subevent'] = self.subevent
if self.quota_id:
return SeatCategoryMapping.objects.filter(product__quotas__pk=self.quota_id, **kwargs).exists()
return self.item.seat_category_mappings.filter(**kwargs).exists()