diff --git a/src/pretix/base/migrations/0153_auto_20200528_1953.py b/src/pretix/base/migrations/0153_auto_20200528_1953.py new file mode 100644 index 0000000000..f1d5c636d4 --- /dev/null +++ b/src/pretix/base/migrations/0153_auto_20200528_1953.py @@ -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), + ), + ] diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 00095b7e1b..a073030ec5 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -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 diff --git a/src/pretix/base/models/seating.py b/src/pretix/base/models/seating.py index a3cebcd59b..8ca9e5b7aa 100644 --- a/src/pretix/base/models/seating.py +++ b/src/pretix/base/models/seating.py @@ -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 diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 771984dc87..380a91d298 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -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'] diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 590d930563..d15fdd2446 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -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: diff --git a/src/pretix/base/services/seating.py b/src/pretix/base/services/seating.py index 8a31d77380..ccc0e9b167 100644 --- a/src/pretix/base/services/seating.py +++ b/src/pretix/base/services/seating.py @@ -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, )) diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 512a60d75e..9faba96b81 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -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 diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py index 814040f4b3..ed014e17ed 100644 --- a/src/tests/base/test_models.py +++ b/src/tests/base/test_models.py @@ -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(