From d99997124976aeedef1fe482c07f9d70096aa703 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Sun, 6 Sep 2020 13:38:44 +0200 Subject: [PATCH] Allow to disable self-choice seating --- src/pretix/base/services/cart.py | 21 ++++++++++++------- src/pretix/base/services/orders.py | 21 ++++++++++++------- src/pretix/base/settings.py | 12 +++++++++++ src/pretix/control/forms/orders.py | 5 ++++- .../templates/pretixcontrol/order/change.html | 2 +- src/pretix/control/views/orders.py | 2 +- src/pretix/control/views/organizer.py | 4 ++-- .../templates/pretixpresale/event/index.html | 2 +- src/pretix/presale/views/cart.py | 2 +- src/pretix/presale/views/event.py | 21 ++++++++++++------- src/tests/base/test_orders.py | 7 ++++++- src/tests/presale/test_cart.py | 19 +++++++++++++++++ src/tests/presale/test_checkout.py | 7 +++++++ 13 files changed, 94 insertions(+), 31 deletions(-) diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 22cbdbde07..1324609c0d 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -6,7 +6,7 @@ from typing import List, Optional from celery.exceptions import MaxRetriesExceededError from django.core.exceptions import ValidationError from django.db import DatabaseError, transaction -from django.db.models import Count, Exists, OuterRef, Q +from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Value from django.dispatch import receiver from django.utils.timezone import make_aware, now from django.utils.translation import gettext as _, pgettext_lazy @@ -147,6 +147,8 @@ class CartManager: ).select_related('item', 'subevent') def _is_seated(self, item, subevent): + if not self.event.settings.seating_choice: + return False if (item, subevent) not in self._seated_cache: self._seated_cache[item, subevent] = item.seat_category_mappings.filter(subevent=subevent).exists() return self._seated_cache[item, subevent] @@ -328,15 +330,18 @@ class CartManager: raise e def extend_expired_positions(self): + requires_seat = Exists( + SeatCategoryMapping.objects.filter( + Q(product=OuterRef('item')) + & (Q(subevent=OuterRef('subevent')) if self.event.has_subevents else Q(subevent__isnull=True)) + ) + ) + if not self.event.settings.seating_choice: + requires_seat = Value(0, output_field=IntegerField()) expired = self.positions.filter(expires__lte=self.now_dt).select_related( 'item', 'variation', 'voucher', 'addon_to', 'addon_to__item' ).annotate( - requires_seat=Exists( - SeatCategoryMapping.objects.filter( - Q(product=OuterRef('item')) - & (Q(subevent=OuterRef('subevent')) if self.event.has_subevents else Q(subevent__isnull=True)) - ) - ) + requires_seat=requires_seat ).prefetch_related( 'item__quotas', 'variation__quotas', @@ -349,7 +354,7 @@ class CartManager: if cp.pk in removed_positions or (cp.addon_to_id and cp.addon_to_id in removed_positions): continue - cp.item.requires_seat = cp.requires_seat + cp.item.requires_seat = self.event.settings.seating_choice and cp.requires_seat if cp.is_bundled: bundle = cp.addon_to.item.bundles.filter(bundled_item=cp.item, bundled_variation=cp.variation).first() diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 7763d914ef..ad5ef1fade 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -9,7 +9,9 @@ from celery.exceptions import MaxRetriesExceededError from django.conf import settings from django.core.cache import cache from django.db import transaction -from django.db.models import Exists, F, Max, Min, OuterRef, Q, Sum +from django.db.models import ( + Exists, F, IntegerField, Max, Min, OuterRef, Q, Sum, Value, +) from django.db.models.functions import Coalesce, Greatest from django.db.transaction import get_connection from django.dispatch import receiver @@ -890,13 +892,16 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str], except InvoiceAddress.DoesNotExist: pass - positions = CartPosition.objects.annotate( - requires_seat=Exists( - SeatCategoryMapping.objects.filter( - Q(product=OuterRef('item')) - & (Q(subevent=OuterRef('subevent')) if event.has_subevents else Q(subevent__isnull=True)) - ) + requires_seat = Exists( + SeatCategoryMapping.objects.filter( + Q(product=OuterRef('item')) + & (Q(subevent=OuterRef('subevent')) if event.has_subevents else Q(subevent__isnull=True)) ) + ) + if not event.settings.seating_choice: + requires_seat = Value(0, output_field=IntegerField()) + positions = CartPosition.objects.annotate( + requires_seat=requires_seat ).filter( id__in=position_ids, event=event ) @@ -1377,7 +1382,7 @@ class OrderChangeManager: raise OrderError(self.error_messages['subevent_required']) seated = item.seat_category_mappings.filter(subevent=subevent).exists() - if seated and not seat: + if seated and not seat and self.event.settings.seating_choice: raise OrderError(self.error_messages['seat_required']) elif not seated and seat: raise OrderError(self.error_messages['seat_forbidden']) diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index ba55e62deb..6276af88b5 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -1744,6 +1744,18 @@ Your {event} team""")) 'default': settings.ENTROPY['giftcard_secret'], 'type': int }, + 'seating_choice': { + 'default': 'True', + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Customers can choose their own seats"), + help_text=_("If disabled, you will need to manually assign seats in the backend. Note that this can mean " + "people will not know their seat after their purchase and it might not be written on their " + "ticket."), + ), + 'type': bool, + }, 'seating_minimal_distance': { 'default': '0', 'type': float diff --git a/src/pretix/control/forms/orders.py b/src/pretix/control/forms/orders.py index 3e8281bba7..609c83b7a7 100644 --- a/src/pretix/control/forms/orders.py +++ b/src/pretix/control/forms/orders.py @@ -399,7 +399,10 @@ class OrderPositionChangeForm(forms.Form): self.fields['tax_rule'].queryset = instance.event.tax_rules.all() self.fields['tax_rule'].label_from_instance = self.taxrule_label_from_instance - if not instance.seat: + if not instance.seat and not ( + not instance.event.settings.seating_choice and + instance.item.seat_category_mappings.filter(subevent=instance.subevent).exists() + ): del self.fields['seat'] choices = [ diff --git a/src/pretix/control/templates/pretixcontrol/order/change.html b/src/pretix/control/templates/pretixcontrol/order/change.html index acaa0bf3e1..6cb851e526 100644 --- a/src/pretix/control/templates/pretixcontrol/order/change.html +++ b/src/pretix/control/templates/pretixcontrol/order/change.html @@ -112,7 +112,7 @@ {% endif %} - {% if position.seat %} + {% if position.form.seat %}
{% trans "Seat" %} diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 036f5b8d46..a2c56c6c53 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -1514,7 +1514,7 @@ class OrderChange(OrderView): elif change_subevent is not None: ocm.change_subevent(p, *change_subevent) - if p.seat and p.form.cleaned_data['seat'] and p.form.cleaned_data['seat'] != p.seat.seat_guid: + if p.form.cleaned_data.get('seat') and (not p.seat or p.form.cleaned_data['seat'] != p.seat.seat_guid): ocm.change_seat(p, p.form.cleaned_data['seat']) if p.form.cleaned_data['price'] is not None and p.form.cleaned_data['price'] != p.price: diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index bfc1ed0a6a..9e1ef23f7a 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -26,8 +26,8 @@ from django.views.generic import ( from pretix.api.models import WebHook from pretix.base.auth import get_auth_backends from pretix.base.models import ( - CachedFile, Device, GiftCard, OrderPayment, Organizer, Team, TeamInvite, - User, LogEntry, + CachedFile, Device, GiftCard, LogEntry, OrderPayment, Organizer, Team, + TeamInvite, User, ) from pretix.base.models.event import Event, EventMetaProperty, EventMetaValue from pretix.base.models.giftcards import gen_giftcard_secret diff --git a/src/pretix/presale/templates/pretixpresale/event/index.html b/src/pretix/presale/templates/pretixpresale/event/index.html index 6058ccc34e..9762d0902a 100644 --- a/src/pretix/presale/templates/pretixpresale/event/index.html +++ b/src/pretix/presale/templates/pretixpresale/event/index.html @@ -237,7 +237,7 @@ action="{% eventurl request.event "presale:event.cart.add" cart_namespace=cart_namespace %}?next={{ cart_redirect|urlencode }}"> {% csrf_token %} - {% if ev.seating_plan_id %} + {% if ev.seating_plan_id and event.settings.seating_choice %} {% if event.has_subevents %} {% eventsignal event "pretix.presale.signals.render_seating_plan" request=request subevent=subevent %} {% else %} diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py index 3df41e6165..efeaa47a3d 100644 --- a/src/pretix/presale/views/cart.py +++ b/src/pretix/presale/views/cart.py @@ -474,7 +474,7 @@ class RedeemView(NoSearchIndexViewMixin, EventViewMixin, TemplateView): context['items_by_category'] = item_group_by_category(items) context['subevent'] = self.subevent - context['seating_available'] = self.voucher.seating_available(self.subevent) + context['seating_available'] = self.request.event.settings.seating_choice and self.voucher.seating_available(self.subevent) context['new_tab'] = ( 'require_cookie' in self.request.GET and diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index 60c8ee254e..4b1ac2218a 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -9,7 +9,9 @@ import isoweek import pytz from django.conf import settings from django.core.exceptions import PermissionDenied -from django.db.models import Count, Exists, OuterRef, Prefetch +from django.db.models import ( + Count, Exists, IntegerField, OuterRef, Prefetch, Value, +) from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils.decorators import method_decorator @@ -61,6 +63,16 @@ def item_group_by_category(items): def get_grouped_items(event, subevent=None, voucher=None, channel='web', require_seat=0, base_qs=None, allow_addons=False, quota_cache=None, filter_items=None, filter_categories=None): base_qs = base_qs if base_qs is not None else event.items + + requires_seat = Exists( + SeatCategoryMapping.objects.filter( + product_id=OuterRef('pk'), + subevent=subevent + ) + ) + if not event.settings.seating_choice: + requires_seat = Value(0, output_field=IntegerField()) + items = base_qs.using(settings.DATABASE_REPLICA).filter_available(channel=channel, voucher=voucher, allow_addons=allow_addons).select_related( 'category', 'tax_rule', # for re-grouping 'hidden_if_available', @@ -111,12 +123,7 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require disabled=True, ) ), - requires_seat=Exists( - SeatCategoryMapping.objects.filter( - product_id=OuterRef('pk'), - subevent=subevent - ) - ), + requires_seat=requires_seat, ).filter( quotac__gt=0, subevent_disabled=False, ).order_by('category__position', 'category_id', 'position', 'name') diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index ec137fa0d8..66a466d1b9 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -2098,7 +2098,12 @@ class OrderChangeManagerTests(TestCase): self.ocm.commit() @classscope(attr='o') - def test_add_with_seat_required(self): + def test_add_with_seat_not_required_if_no_choice(self): + self.event.settings.seating_choice = False + self.ocm.add_position(self.stalls, None, price=Decimal('13.00')) + + @classscope(attr='o') + def test_add_with_seat_not_required(self): with self.assertRaises(OrderError): self.ocm.add_position(self.stalls, None, price=Decimal('13.00')) diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py index 795371f801..ceb752d8c7 100644 --- a/src/tests/presale/test_cart.py +++ b/src/tests/presale/test_cart.py @@ -3685,6 +3685,25 @@ class CartSeatingTest(CartTestMixin, TestCase): objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) self.assertEqual(len(objs), 0) + def test_add_seat_not_required_if_no_choice(self): + self.event.settings.seating_choice = False + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1', + }, follow=True) + with scopes_disabled(): + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 1) + assert objs[0].seat is None + + def test_seat_not_allowed_if_no_choice(self): + self.event.settings.seating_choice = False + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'seat_%d' % self.ticket.id: self.seat_a2.seat_guid, + }, follow=True) + with scopes_disabled(): + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 0) + def test_add_with_seat_with_variation(self): with scopes_disabled(): v1 = self.ticket.variations.create(value='Regular', active=True) diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index c8c5f2feb0..6fe5da7110 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -3244,6 +3244,13 @@ class CheckoutSeatingTest(BaseCheckoutTestCase, TestCase): _perform_order(self.event, 'manual', [self.cp1.pk], 'admin@example.org', 'en', None, {}, 'web') assert not CartPosition.objects.filter(pk=self.cp1.pk).exists() + @scopes_disabled() + def test_seat_not_required_if_no_choice(self): + self.cp1.seat = None + self.cp1.save() + self.event.settings.seating_choice = False + _perform_order(self.event, 'manual', [self.cp1.pk], 'admin@example.org', 'en', None, {}, 'web') + @scopes_disabled() def test_seat_not_allowed(self): self.cp1.item = self.workshop1