forked from CGM_Public/pretix_original
Implement corona-safe seating (#1685)
This commit is contained in:
31
src/pretix/base/migrations/0153_auto_20200528_1953.py
Normal file
31
src/pretix/base/migrations/0153_auto_20200528_1953.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 3.0.6 on 2020-05-28 19:53
|
||||
|
||||
import django_countries.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.helpers.countries
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0152_auto_20200511_1504'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='seat',
|
||||
name='x',
|
||||
field=models.FloatField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='seat',
|
||||
name='y',
|
||||
field=models.FloatField(null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='seat',
|
||||
name='seat_guid',
|
||||
field=models.CharField(db_index=True, max_length=190),
|
||||
),
|
||||
]
|
||||
@@ -12,7 +12,7 @@ from django.core.files.storage import default_storage
|
||||
from django.core.mail import get_connection
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
|
||||
from django.db.models import Exists, OuterRef, Prefetch, Q, Subquery
|
||||
from django.template.defaultfilters import date as _date
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.formats import date_format
|
||||
@@ -391,36 +391,16 @@ class Event(EventMixin, LoggedModel):
|
||||
return urljoin(build_absolute_uri(self, 'presale:event.index'), img)
|
||||
|
||||
def free_seats(self, ignore_voucher=None, sales_channel='web', include_blocked=False):
|
||||
from .orders import CartPosition, Order, OrderPosition
|
||||
from .vouchers import Voucher
|
||||
vqs = Voucher.objects.filter(
|
||||
event=self,
|
||||
seat_id=OuterRef('pk'),
|
||||
redeemed__lt=F('max_usages'),
|
||||
).filter(
|
||||
Q(valid_until__isnull=True) | Q(valid_until__gte=now())
|
||||
)
|
||||
if ignore_voucher:
|
||||
vqs = vqs.exclude(pk=ignore_voucher.pk)
|
||||
qs = 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()
|
||||
)
|
||||
),
|
||||
has_voucher=Exists(
|
||||
vqs
|
||||
)
|
||||
).filter(has_order=False, has_cart=False, has_voucher=False)
|
||||
from .seating import Seat
|
||||
|
||||
qs_annotated = Seat.annotated(self.seats, self.pk, None,
|
||||
ignore_voucher_id=ignore_voucher.pk if ignore_voucher else None,
|
||||
minimal_distance=self.settings.seating_minimal_distance)
|
||||
|
||||
qs = qs_annotated.filter(has_order=False, has_cart=False, has_voucher=False)
|
||||
if self.settings.seating_minimal_distance > 0:
|
||||
qs = qs.filter(has_closeby_taken=False)
|
||||
|
||||
if not (sales_channel in self.settings.seating_allow_blocked_seats_for_channel or include_blocked):
|
||||
qs = qs.filter(blocked=False)
|
||||
return qs
|
||||
@@ -1061,39 +1041,14 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
).strip()
|
||||
|
||||
def free_seats(self, ignore_voucher=None, sales_channel='web', include_blocked=False):
|
||||
from .orders import CartPosition, Order, OrderPosition
|
||||
from .vouchers import Voucher
|
||||
vqs = Voucher.objects.filter(
|
||||
event_id=self.event_id,
|
||||
subevent=self,
|
||||
seat_id=OuterRef('pk'),
|
||||
redeemed__lt=F('max_usages'),
|
||||
).filter(
|
||||
Q(valid_until__isnull=True) | Q(valid_until__gte=now())
|
||||
)
|
||||
if ignore_voucher:
|
||||
vqs = vqs.exclude(pk=ignore_voucher.pk)
|
||||
qs = 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()
|
||||
)
|
||||
),
|
||||
has_voucher=Exists(
|
||||
vqs
|
||||
)
|
||||
).filter(has_order=False, has_cart=False, has_voucher=False)
|
||||
from .seating import Seat
|
||||
qs_annotated = Seat.annotated(self.seats, self.event_id, self,
|
||||
ignore_voucher_id=ignore_voucher.pk if ignore_voucher else None,
|
||||
minimal_distance=self.settings.seating_minimal_distance)
|
||||
qs = qs_annotated.filter(has_order=False, has_cart=False, has_voucher=False)
|
||||
if self.settings.seating_minimal_distance > 0:
|
||||
qs = qs.filter(has_closeby_taken=False)
|
||||
|
||||
if not (sales_channel in self.settings.seating_allow_blocked_seats_for_channel or include_blocked):
|
||||
qs = qs.filter(blocked=False)
|
||||
return qs
|
||||
|
||||
@@ -5,7 +5,8 @@ import jsonschema
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import F, Q
|
||||
from django.db.models import Exists, F, OuterRef, Q, Value
|
||||
from django.db.models.functions import Power
|
||||
from django.utils.deconstruct import deconstructible
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
@@ -41,7 +42,7 @@ class SeatingPlan(LoggedModel):
|
||||
layout = models.TextField(validators=[SeatingPlanLayoutValidator()])
|
||||
|
||||
Category = namedtuple('Categrory', 'name')
|
||||
RawSeat = namedtuple('Seat', 'name guid number row category zone sorting_rank row_label seat_label')
|
||||
RawSeat = namedtuple('Seat', 'name guid number row category zone sorting_rank row_label seat_label x y')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -69,7 +70,9 @@ class SeatingPlan(LoggedModel):
|
||||
# *will* have gaps. We chose this way over just sorting the seats and continuously enumerating them as an
|
||||
# optimization, because this way we do not need to update the rank of very seat if we change a plan a little.
|
||||
for zi, z in enumerate(self.layout_data['zones']):
|
||||
zpos = (z['position']['x'], z['position']['y'])
|
||||
for ri, r in enumerate(z['rows']):
|
||||
rpos = (zpos[0] + r['position']['x'], zpos[1] + r['position']['y'])
|
||||
row_label = None
|
||||
if r.get('row_label'):
|
||||
row_label = r['row_label'].replace("%s", r.get('row_number', str(ri)))
|
||||
@@ -98,7 +101,9 @@ class SeatingPlan(LoggedModel):
|
||||
seat_label=seat_label,
|
||||
zone=z['name'],
|
||||
category=s['category'],
|
||||
sorting_rank=rank
|
||||
sorting_rank=rank,
|
||||
x=rpos[0] + s['position']['x'],
|
||||
y=rpos[1] + s['position']['y'],
|
||||
)
|
||||
|
||||
|
||||
@@ -130,6 +135,8 @@ class Seat(models.Model):
|
||||
product = models.ForeignKey('Item', null=True, blank=True, related_name='seats', on_delete=models.CASCADE)
|
||||
blocked = models.BooleanField(default=False)
|
||||
sorting_rank = models.BigIntegerField(default=0)
|
||||
x = models.FloatField(null=True)
|
||||
y = models.FloatField(null=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['sorting_rank', 'seat_guid']
|
||||
@@ -153,7 +160,66 @@ class Seat(models.Model):
|
||||
return self.name
|
||||
return ', '.join(parts)
|
||||
|
||||
def is_available(self, ignore_cart=None, ignore_orderpos=None, ignore_voucher_id=None, sales_channel='web'):
|
||||
@classmethod
|
||||
def annotated(cls, qs, event_id, subevent, ignore_voucher_id=None, minimal_distance=0,
|
||||
ignore_order_id=None, ignore_cart_id=None):
|
||||
from . import Order, OrderPosition, Voucher, CartPosition
|
||||
|
||||
vqs = Voucher.objects.filter(
|
||||
event_id=event_id,
|
||||
subevent=subevent,
|
||||
seat_id=OuterRef('pk'),
|
||||
redeemed__lt=F('max_usages'),
|
||||
).filter(
|
||||
Q(valid_until__isnull=True) | Q(valid_until__gte=now())
|
||||
)
|
||||
if ignore_voucher_id:
|
||||
vqs = vqs.exclude(pk=ignore_voucher_id)
|
||||
opqs = OrderPosition.objects.filter(
|
||||
order__event_id=event_id,
|
||||
subevent=subevent,
|
||||
seat_id=OuterRef('pk'),
|
||||
order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID]
|
||||
)
|
||||
if ignore_order_id:
|
||||
opqs = opqs.exclude(order_id=ignore_order_id)
|
||||
cqs = CartPosition.objects.filter(
|
||||
event_id=event_id,
|
||||
subevent=subevent,
|
||||
seat_id=OuterRef('pk'),
|
||||
expires__gte=now()
|
||||
)
|
||||
if ignore_cart_id:
|
||||
cqs = cqs.exclude(cart_id=ignore_cart_id)
|
||||
qs_annotated = qs.annotate(
|
||||
has_order=Exists(
|
||||
opqs
|
||||
),
|
||||
has_cart=Exists(
|
||||
cqs
|
||||
),
|
||||
has_voucher=Exists(
|
||||
vqs
|
||||
)
|
||||
)
|
||||
|
||||
if minimal_distance > 0:
|
||||
# TODO: Is there a more performant implementation on PostgreSQL using
|
||||
# https://www.postgresql.org/docs/8.2/functions-geometry.html ?
|
||||
sq_closeby = qs_annotated.annotate(
|
||||
distance=(
|
||||
Power(F('x') - OuterRef('x'), Value(2), output_field=models.FloatField()) +
|
||||
Power(F('y') - OuterRef('y'), Value(2), output_field=models.FloatField())
|
||||
)
|
||||
).filter(
|
||||
Q(has_order=True) | Q(has_cart=True) | Q(has_voucher=True),
|
||||
distance__lt=minimal_distance ** 2
|
||||
)
|
||||
qs_annotated = qs_annotated.annotate(has_closeby_taken=Exists(sq_closeby))
|
||||
return qs_annotated
|
||||
|
||||
def is_available(self, ignore_cart=None, ignore_orderpos=None, ignore_voucher_id=None, sales_channel='web',
|
||||
ignore_distancing=False, distance_ignore_cart_id=None):
|
||||
from .orders import Order
|
||||
|
||||
if self.blocked and sales_channel not in self.event.settings.seating_allow_blocked_seats_for_channel:
|
||||
@@ -173,4 +239,30 @@ class Seat(models.Model):
|
||||
opqs = opqs.exclude(pk=ignore_orderpos.pk)
|
||||
if ignore_voucher_id:
|
||||
vqs = vqs.exclude(pk=ignore_voucher_id)
|
||||
return not opqs.exists() and (ignore_cart is True or not cpqs.exists()) and not vqs.exists()
|
||||
|
||||
if opqs.exists() or (ignore_cart is not True and cpqs.exists()) or vqs.exists():
|
||||
return False
|
||||
|
||||
if self.event.settings.seating_minimal_distance > 0 and not ignore_distancing:
|
||||
ev = (self.subevent or self.event)
|
||||
qs_annotated = Seat.annotated(ev.seats, self.event_id, self.subevent,
|
||||
ignore_voucher_id=ignore_voucher_id,
|
||||
minimal_distance=0,
|
||||
ignore_order_id=ignore_orderpos.order_id if ignore_orderpos else None,
|
||||
ignore_cart_id=(
|
||||
distance_ignore_cart_id or
|
||||
(ignore_cart.cart_id if ignore_cart else None)
|
||||
))
|
||||
qs_closeby_taken = qs_annotated.annotate(
|
||||
distance=(
|
||||
Power(F('x') - Value(self.x), Value(2), output_field=models.FloatField()) +
|
||||
Power(F('y') - Value(self.y), Value(2), output_field=models.FloatField())
|
||||
)
|
||||
).exclude(pk=self.pk).filter(
|
||||
Q(has_order=True) | Q(has_cart=True) | Q(has_voucher=True),
|
||||
distance__lt=self.event.settings.seating_minimal_distance ** 2
|
||||
)
|
||||
if qs_closeby_taken.exists():
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -889,7 +889,9 @@ class CartManager:
|
||||
available_count = 0
|
||||
|
||||
if isinstance(op, self.AddOperation):
|
||||
if op.seat and not op.seat.is_available(ignore_voucher_id=op.voucher.id if op.voucher else None, sales_channel=self._sales_channel):
|
||||
if op.seat and not op.seat.is_available(ignore_voucher_id=op.voucher.id if op.voucher else None,
|
||||
sales_channel=self._sales_channel,
|
||||
distance_ignore_cart_id=self.cart_id):
|
||||
available_count = 0
|
||||
err = err or error_messages['seat_unavailable']
|
||||
|
||||
|
||||
@@ -1375,7 +1375,7 @@ class OrderChangeManager:
|
||||
for seat, diff in self._seatdiff.items():
|
||||
if diff <= 0:
|
||||
continue
|
||||
if not seat.is_available(sales_channel=self.order.sales_channel) or diff > 1:
|
||||
if not seat.is_available(sales_channel=self.order.sales_channel, ignore_distancing=True) or diff > 1:
|
||||
raise OrderError(self.error_messages['seat_unavailable'].format(seat=seat.name))
|
||||
|
||||
if self.event.has_subevents:
|
||||
|
||||
@@ -67,6 +67,8 @@ def generate_seats(event, subevent, plan, mapping):
|
||||
update(seat, 'sorting_rank', ss.sorting_rank),
|
||||
update(seat, 'row_label', ss.row_label),
|
||||
update(seat, 'seat_label', ss.seat_label),
|
||||
update(seat, 'x', ss.x),
|
||||
update(seat, 'y', ss.y),
|
||||
])
|
||||
if updated:
|
||||
seat.save()
|
||||
@@ -82,6 +84,8 @@ def generate_seats(event, subevent, plan, mapping):
|
||||
sorting_rank=ss.sorting_rank,
|
||||
row_label=ss.row_label,
|
||||
seat_label=ss.seat_label,
|
||||
x=ss.x,
|
||||
y=ss.y,
|
||||
product=p,
|
||||
))
|
||||
|
||||
|
||||
@@ -1648,6 +1648,10 @@ Your {event} team"""))
|
||||
'default': settings.ENTROPY['giftcard_secret'],
|
||||
'type': int
|
||||
},
|
||||
'seating_minimal_distance': {
|
||||
'default': '0',
|
||||
'type': float
|
||||
},
|
||||
'seating_allow_blocked_seats_for_channel': {
|
||||
'default': [],
|
||||
'type': list
|
||||
|
||||
@@ -2087,8 +2087,8 @@ class SeatingTestCase(TestCase):
|
||||
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)
|
||||
self.seat_a1 = self.event.seats.create(name="A1", product=self.ticket, blocked=False, x=0, y=0)
|
||||
self.seat_a2 = self.event.seats.create(name="A2", product=self.ticket, blocked=False, x=1, y=1)
|
||||
|
||||
@classscope(attr='organizer')
|
||||
def test_free(self):
|
||||
@@ -2104,6 +2104,28 @@ class SeatingTestCase(TestCase):
|
||||
assert not self.seat_a1.is_available()
|
||||
assert self.seat_a2.is_available()
|
||||
|
||||
@classscope(attr='organizer')
|
||||
def test_blocked_in_proximity(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
|
||||
)
|
||||
|
||||
self.event.settings.seating_minimal_distance = 1.5
|
||||
assert set(self.event.free_seats()) == set()
|
||||
assert not self.seat_a1.is_available()
|
||||
assert not self.seat_a2.is_available()
|
||||
|
||||
self.event.settings.seating_minimal_distance = 1.4
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user