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