Implement corona-safe seating (#1685)

This commit is contained in:
Raphael Michel
2020-05-29 11:39:47 +02:00
committed by GitHub
parent 03bcfc7c5a
commit cf3412d54d
8 changed files with 183 additions and 73 deletions

View 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),
),
]

View File

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

View File

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

View File

@@ -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']

View File

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

View File

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

View File

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

View File

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