diff --git a/doc/api/resources/carts.rst b/doc/api/resources/carts.rst index e42632fec9..3076257a6d 100644 --- a/doc/api/resources/carts.rst +++ b/doc/api/resources/carts.rst @@ -194,6 +194,7 @@ Cart position endpoints * ``subevent`` (optional) * ``expires`` (optional) * ``includes_tax`` (optional) + * ``sales_channel`` (optional) * ``answers`` * ``question`` diff --git a/src/pretix/api/serializers/cart.py b/src/pretix/api/serializers/cart.py index 2f5db5020b..3792780dd4 100644 --- a/src/pretix/api/serializers/cart.py +++ b/src/pretix/api/serializers/cart.py @@ -30,11 +30,12 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer): expires = serializers.DateTimeField(required=False) attendee_name = serializers.CharField(required=False, allow_null=True) seat = serializers.CharField(required=False, allow_null=True) + sales_channel = serializers.CharField(required=False, default='sales_channel') class Meta: model = CartPosition fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email', - 'subevent', 'expires', 'includes_tax', 'answers', 'seat') + 'subevent', 'expires', 'includes_tax', 'answers', 'seat', 'sales_channel') def create(self, validated_data): answers_data = validated_data.pop('answers') @@ -86,11 +87,12 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer): raise ValidationError('The specified seat ID is not unique.') else: validated_data['seat'] = seat - if not seat.is_available(): + if not seat.is_available(sales_channel=validated_data.get('sales_channel', 'web')): raise ValidationError(ugettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)) elif seated: raise ValidationError('The specified product requires to choose a seat.') + validated_data.pop('sales_channel') cp = CartPosition.objects.create(event=self.context['event'], **validated_data) for answ_data in answers_data: diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index b3e210efea..53d62a3608 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -778,7 +778,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): errs[i]['seat'] = ['The specified seat does not exist.'] else: pos_data['seat'] = seat - if (seat not in free_seats and not seat.is_available()) or seat in seats_seen: + if (seat not in free_seats and not seat.is_available(sales_channel=validated_data.get('sales_channel', 'web'))) or seat in seats_seen: errs[i]['seat'] = [ugettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)] seats_seen.add(seat) elif seated: diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 22981b0ef6..a9f7768e12 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -385,7 +385,7 @@ class Event(EventMixin, LoggedModel): if img: return urljoin(build_absolute_uri(self, 'presale:event.index'), img) - def free_seats(self, ignore_voucher=None): + def free_seats(self, ignore_voucher=None, sales_channel='web'): from .orders import CartPosition, Order, OrderPosition from .vouchers import Voucher vqs = Voucher.objects.filter( @@ -397,7 +397,7 @@ class Event(EventMixin, LoggedModel): ) if ignore_voucher: vqs = vqs.exclude(pk=ignore_voucher.pk) - return self.seats.annotate( + qs = self.seats.annotate( has_order=Exists( OrderPosition.objects.filter( order__event=self, @@ -415,7 +415,10 @@ class Event(EventMixin, LoggedModel): has_voucher=Exists( vqs ) - ).filter(has_order=False, has_cart=False, has_voucher=False, blocked=False) + ).filter(has_order=False, has_cart=False, has_voucher=False) + if sales_channel not in self.settings.seating_allow_blocked_seats_for_channel: + qs = qs.filter(blocked=False) + return qs @property def presale_has_ended(self): @@ -1006,7 +1009,7 @@ class SubEvent(EventMixin, LoggedModel): def __str__(self): return '{} - {}'.format(self.name, self.get_date_range_display()) - def free_seats(self, ignore_voucher=None): + def free_seats(self, ignore_voucher=None, sales_channel='web'): from .orders import CartPosition, Order, OrderPosition from .vouchers import Voucher vqs = Voucher.objects.filter( @@ -1019,7 +1022,7 @@ class SubEvent(EventMixin, LoggedModel): ) if ignore_voucher: vqs = vqs.exclude(pk=ignore_voucher.pk) - return self.seats.annotate( + qs = self.seats.annotate( has_order=Exists( OrderPosition.objects.filter( order__event_id=self.event_id, @@ -1039,7 +1042,10 @@ class SubEvent(EventMixin, LoggedModel): has_voucher=Exists( vqs ) - ).filter(has_order=False, has_cart=False, blocked=False, has_voucher=False) + ).filter(has_order=False, has_cart=False, has_voucher=False) + if sales_channel not in self.settings.seating_allow_blocked_seats_for_channel: + qs = qs.filter(blocked=False) + return qs @cached_property def settings(self): diff --git a/src/pretix/base/models/seating.py b/src/pretix/base/models/seating.py index c3a3592042..7fb5b26616 100644 --- a/src/pretix/base/models/seating.py +++ b/src/pretix/base/models/seating.py @@ -135,10 +135,10 @@ class Seat(models.Model): return self.name return ', '.join(parts) - def is_available(self, ignore_cart=None, ignore_orderpos=None, ignore_voucher_id=None): + def is_available(self, ignore_cart=None, ignore_orderpos=None, ignore_voucher_id=None, sales_channel='web'): from .orders import Order - if self.blocked: + if self.blocked and sales_channel not in self.event.settings.seating_allow_blocked_seats_for_channel: return False opqs = self.orderposition_set.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID]) cpqs = self.cartposition_set.filter(expires__gte=now()) diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index baf1c79f15..fcb65ec991 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -271,7 +271,7 @@ class CartManager: raise CartError(error_messages['ended']) seated = self._is_seated(op.item, op.subevent) - if seated and (not op.seat or op.seat.blocked): + if seated and (not op.seat or (op.seat.blocked and self._sales_channel not in self.event.settings.seating_allow_blocked_seats_for_channel)): raise CartError(error_messages['seat_invalid']) elif op.seat and not seated: raise CartError(error_messages['seat_forbidden']) @@ -830,7 +830,7 @@ 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): + 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): available_count = 0 err = err or error_messages['seat_unavailable'] @@ -878,7 +878,7 @@ class CartManager: new_cart_positions.append(cp) elif isinstance(op, self.ExtendOperation): - if op.seat and not op.seat.is_available(ignore_cart=op.position, + if op.seat and not op.seat.is_available(ignore_cart=op.position, sales_channel=self._sales_channel, ignore_voucher_id=op.position.voucher_id): err = err or error_messages['seat_unavailable'] op.position.addons.all().delete() diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 2876bd70fe..86497c9352 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -418,7 +418,8 @@ def _check_date(event: Event, now_dt: datetime): raise OrderError(error_messages['ended']) -def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition], address: InvoiceAddress=None): +def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition], address: InvoiceAddress=None, + sales_channel='web'): err = None errargs = None _check_date(event, now_dt) @@ -512,7 +513,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio if cp.seat: # Unlike quotas (which we blindly trust as long as the position is not expired), we check seats every # time, since we absolutely can not overbook a seat. - if not cp.seat.is_available(ignore_cart=cp, ignore_voucher_id=cp.voucher_id) or cp.seat.blocked: + if not cp.seat.is_available(ignore_cart=cp, ignore_voucher_id=cp.voucher_id, sales_channel=sales_channel): err = err or error_messages['seat_unavailable'] cp.delete() continue @@ -806,7 +807,7 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str], raise OrderError(error_messages['empty']) if len(position_ids) != len(positions): raise OrderError(error_messages['internal']) - _check_positions(event, now_dt, positions, address=addr) + _check_positions(event, now_dt, positions, address=addr, sales_channel=sales_channel) order, payment = _create_order(event, email, positions, now_dt, pprov, locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel, gift_cards=gift_cards, shown_total=shown_total) @@ -1197,7 +1198,7 @@ class OrderChangeManager: for seat, diff in self._seatdiff.items(): if diff <= 0: continue - if not seat.is_available() or diff > 1: + if not seat.is_available(sales_channel=self.order.sales_channel) 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/settings.py b/src/pretix/base/settings.py index c024d5e18a..8e8265a2cd 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -754,7 +754,11 @@ Your {event} team""")) 'giftcard_length': { 'default': settings.ENTROPY['giftcard_secret'], 'type': int - } + }, + 'seating_allow_blocked_seats_for_channel': { + 'default': [], + 'type': list + }, } PERSON_NAME_TITLE_GROUPS = OrderedDict([ ('english_common', (_('Most common English titles'), ( diff --git a/src/tests/api/test_cart.py b/src/tests/api/test_cart.py index 567f44935b..e12ee2956e 100644 --- a/src/tests/api/test_cart.py +++ b/src/tests/api/test_cart.py @@ -4,12 +4,15 @@ from decimal import Decimal from unittest import mock import pytest +from django.dispatch import receiver from django.utils.timezone import now from django_scopes import scopes_disabled from pytz import UTC +from pretix.base.channels import SalesChannel from pretix.base.models import Question, SeatingPlan from pretix.base.models.orders import CartPosition +from pretix.base.signals import register_sales_channels @pytest.fixture @@ -49,6 +52,21 @@ def quota(event, item): return q +class FoobarSalesChannel(SalesChannel): + identifier = "bar" + verbose_name = "Foobar" + icon = "home" + testmode_supported = False + unlimited_items_per_order = True + + +@receiver(register_sales_channels, dispatch_uid="test_cart_register_sales_channels") +def base_sales_channels(sender, **kwargs): + return ( + FoobarSalesChannel(), + ) + + TEST_CARTPOSITION_RES = { 'id': 1, 'cart_id': 'aaa@api', @@ -163,6 +181,7 @@ CARTPOS_CREATE_PAYLOAD = { 'subevent': None, 'expires': '2018-06-11T10:00:00Z', 'includes_tax': True, + 'sales_channel': 'web', 'answers': [] } @@ -667,6 +686,23 @@ def test_cartpos_create_with_blocked_seat(token_client, organizer, event, item, assert resp.data == ['The selected seat "A1" is not available.'] +@pytest.mark.django_db +def test_cartpos_create_with_blocked_seat_allowed(token_client, organizer, event, item, quota, seat, question): + seat.blocked = True + seat.save() + res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD) + res['item'] = item.pk + res['seat'] = seat.seat_guid + res['sales_channel'] = 'bar' + event.settings.seating_allow_blocked_seats_for_channel = ['bar'] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/cartpositions/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + + @pytest.mark.django_db def test_cartpos_create_with_used_seat(token_client, organizer, event, item, quota, seat, question): CartPosition.objects.create( diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index 6fb9c627a5..90f1ae4843 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -2741,6 +2741,24 @@ def test_order_create_with_seat(token_client, organizer, event, item, quota, sea assert p.seat == seat +@pytest.mark.django_db +def test_order_create_with_blocked_seat_allowed(token_client, organizer, event, item, quota, seat, question): + seat.blocked = True + seat.save() + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['seat'] = seat.seat_guid + res['positions'][0]['answers'][0]['question'] = question.pk + res['sales_channel'] = 'bar' + event.settings.seating_allow_blocked_seats_for_channel = ['bar'] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + + @pytest.mark.django_db def test_order_create_with_blocked_seat(token_client, organizer, event, item, quota, seat, question): seat.blocked = True