diff --git a/doc/api/resources/carts.rst b/doc/api/resources/carts.rst
index f1877c57c8..9b4fbd76a1 100644
--- a/doc/api/resources/carts.rst
+++ b/doc/api/resources/carts.rst
@@ -17,8 +17,8 @@ The cart position resource contains the following public fields:
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the cart position
-cart_id string Identifier of the cart this belongs to. Needs to end
- in "@api" for API-created positions.
+cart_id string Identifier of the cart this belongs to, needs to end
+ in "@api" for API-created positions
datetime datetime Time of creation
expires datetime The cart position will expire at this time and no longer block quota
item integer ID of the item
@@ -29,14 +29,15 @@ attendee_name_parts object of strings Composition of
attendee_email string Specified attendee email address for this position (or ``null``)
voucher integer Internal ID of the voucher used for this position (or ``null``)
addon_to integer Internal ID of the position this position is an add-on for (or ``null``)
-subevent integer ID of the date inside an event series this position belongs to (or ``null``).
+is_bundled boolean If ``addon_to`` is set, this shows whether this is a bundled product or an addon product
+subevent integer ID of the date inside an event series this position belongs to (or ``null``)
answers list of objects Answers to user-defined questions
├ question integer Internal ID of the answered question
├ answer string Text representation of the answer
├ question_identifier string The question's ``identifier`` field
├ options list of integers Internal IDs of selected option(s)s (only for choice types)
└ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
-seat objects The assigned seat. Can be ``null``.
+seat objects The assigned seat (or ``null``)
├ id integer Internal ID of the seat instance
├ name string Human-readable seat name
└ seat_guid string Identifier of the seat within the seating plan
@@ -46,6 +47,10 @@ seat objects The assigned se
This ``seat`` attribute has been added.
+.. versionchanged:: 4.14
+
+ This ``is_bundled`` attribute has been added and the cart creation endpoints have been updated.
+
Cart position endpoints
-----------------------
@@ -87,6 +92,7 @@ Cart position endpoints
"attendee_email": null,
"voucher": null,
"addon_to": null,
+ "is_bundled": false,
"subevent": null,
"datetime": "2018-06-11T10:00:00Z",
"expires": "2018-06-11T10:00:00Z",
@@ -133,6 +139,7 @@ Cart position endpoints
"attendee_email": null,
"voucher": null,
"addon_to": null,
+ "is_bundled": false,
"subevent": null,
"datetime": "2018-06-11T10:00:00Z",
"expires": "2018-06-11T10:00:00Z",
@@ -168,7 +175,7 @@ Cart position endpoints
* does not validate if the event's ticket sales are already over or haven't started
- * does not support add-on products at the moment
+ * does not validate constraints on add-on products at the moment
* does not check or calculate prices but believes any prices you send
@@ -176,6 +183,8 @@ Cart position endpoints
* does not support file upload questions
+ Note that more validation might be added in the future, so please do not rely on missing validation.
+
You can supply the following fields of the resource:
* ``cart_id`` (optional, needs to end in ``@api``)
@@ -190,6 +199,8 @@ Cart position endpoints
* ``includes_tax`` (optional, **deprecated**, do not use, will be removed)
* ``sales_channel`` (optional)
* ``voucher`` (optional, expect a voucher code)
+ * ``addons`` (optional, expect a list of nested objects of cart positions)
+ * ``bundled`` (optional, expect a list of nested objects of cart positions)
* ``answers``
* ``question``
@@ -221,6 +232,12 @@ Cart position endpoints
"options": []
}
],
+ "addons": [
+ {
+ "item": 2,
+ "variation": null,
+ }
+ ],
"subevent": null
}
@@ -232,7 +249,7 @@ Cart position endpoints
Vary: Accept
Content-Type: application/json
- (Full cart position resource, see above.)
+ (Full cart position resource, see above, with additional nested objects "addons" and "bundled".)
:param organizer: The ``slug`` field of the organizer of the event to create a position for
:param event: The ``slug`` field of the event to create a position for
@@ -244,8 +261,8 @@ Cart position endpoints
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/cartpositions/bulk_create/
- Creates multiple new cart position. This operation is deliberately not atomic, so each cart position can succeed
- or fail individually, so the response code of the response is not the only thing to look at!
+ Creates multiple new cart position. **This operation is deliberately not atomic, so each cart position can succeed
+ or fail individually, so the response code of the response is not the only thing to look at!**
.. warning:: This endpoint is considered **experimental**. It might change at any time without prior notice.
diff --git a/src/pretix/__init__.py b/src/pretix/__init__.py
index 9c8fd4b361..1c18477fb2 100644
--- a/src/pretix/__init__.py
+++ b/src/pretix/__init__.py
@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# .
#
-__version__ = "4.14.0.dev0"
+__version__ = "4.14.1.dev0"
diff --git a/src/pretix/api/serializers/cart.py b/src/pretix/api/serializers/cart.py
index 0c02a14a28..4a4faaea39 100644
--- a/src/pretix/api/serializers/cart.py
+++ b/src/pretix/api/serializers/cart.py
@@ -23,8 +23,7 @@ import os
from datetime import timedelta
from django.core.files import File
-from django.db.models import Q
-from django.utils.crypto import get_random_string
+from django.db.models import prefetch_related_objects
from django.utils.timezone import now
from django.utils.translation import gettext_lazy
from rest_framework import serializers
@@ -34,7 +33,7 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import (
AnswerCreateSerializer, AnswerSerializer, InlineSeatSerializer,
)
-from pretix.base.models import Quota, Seat, Voucher
+from pretix.base.models import Seat, Voucher
from pretix.base.models.orders import CartPosition
@@ -52,148 +51,18 @@ class CartPositionSerializer(I18nAwareModelSerializer):
model = CartPosition
fields = ('id', 'cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'attendee_email', 'voucher', 'addon_to', 'subevent', 'datetime', 'expires', 'includes_tax',
- 'answers', 'seat')
+ 'answers', 'seat', 'is_bundled')
-class CartPositionCreateSerializer(I18nAwareModelSerializer):
+class BaseCartPositionCreateSerializer(I18nAwareModelSerializer):
answers = AnswerCreateSerializer(many=True, required=False)
- 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')
includes_tax = serializers.BooleanField(required=False, allow_null=True)
- voucher = serializers.CharField(required=False, allow_null=True)
class Meta:
model = CartPosition
- fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
- 'subevent', 'expires', 'includes_tax', 'answers', 'seat', 'sales_channel', 'voucher')
-
- def create(self, validated_data):
- answers_data = validated_data.pop('answers')
- if not validated_data.get('cart_id'):
- cid = "{}@api".format(get_random_string(48))
- while CartPosition.objects.filter(cart_id=cid).exists():
- cid = "{}@api".format(get_random_string(48))
- validated_data['cart_id'] = cid
-
- if not validated_data.get('expires'):
- validated_data['expires'] = now() + timedelta(
- minutes=self.context['event'].settings.get('reservation_time', as_type=int)
- )
-
- new_quotas = (validated_data.get('variation').quotas.filter(subevent=validated_data.get('subevent'))
- if validated_data.get('variation')
- else validated_data.get('item').quotas.filter(subevent=validated_data.get('subevent')))
- if len(new_quotas) == 0:
- raise ValidationError(
- gettext_lazy('The product "{}" is not assigned to a quota.').format(
- str(validated_data.get('item'))
- )
- )
- for quota in new_quotas:
- avail = quota.availability(_cache=self.context['quota_cache'])
- if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < 1):
- raise ValidationError(
- gettext_lazy('There is not enough quota available on quota "{}" to perform '
- 'the operation.').format(
- quota.name
- )
- )
-
- for quota in new_quotas:
- oldsize = self.context['quota_cache'][quota.pk][1]
- newsize = oldsize - 1 if oldsize is not None else None
- self.context['quota_cache'][quota.pk] = (
- Quota.AVAILABILITY_OK if newsize is None or newsize > 0 else Quota.AVAILABILITY_GONE,
- newsize
- )
-
- attendee_name = validated_data.pop('attendee_name', '')
- if attendee_name and not validated_data.get('attendee_name_parts'):
- validated_data['attendee_name_parts'] = {
- '_legacy': attendee_name
- }
-
- seated = validated_data.get('item').seat_category_mappings.filter(subevent=validated_data.get('subevent')).exists()
- if validated_data.get('seat'):
- if not seated:
- raise ValidationError('The specified product does not allow to choose a seat.')
- try:
- seat = self.context['event'].seats.get(seat_guid=validated_data['seat'], subevent=validated_data.get('subevent'))
- except Seat.DoesNotExist:
- raise ValidationError('The specified seat does not exist.')
- except Seat.MultipleObjectsReturned:
- raise ValidationError('The specified seat ID is not unique.')
- else:
- validated_data['seat'] = seat
- elif seated:
- raise ValidationError('The specified product requires to choose a seat.')
-
- if validated_data.get('voucher'):
- try:
- voucher = self.context['event'].vouchers.get(code__iexact=validated_data.get('voucher'))
- except Voucher.DoesNotExist:
- raise ValidationError('The specified voucher does not exist.')
-
- if voucher and not voucher.applies_to(validated_data.get('item'), validated_data.get('variation')):
- raise ValidationError('The specified voucher is not valid for the given item and variation.')
-
- if voucher and voucher.seat and voucher.seat != validated_data.get('seat'):
- raise ValidationError('The specified voucher is not valid for this seat.')
-
- if voucher and voucher.subevent_id and (not validated_data.get('subevent') or voucher.subevent_id != validated_data['subevent'].pk):
- raise ValidationError('The specified voucher is not valid for this subevent.')
-
- if voucher.valid_until is not None and voucher.valid_until < now():
- raise ValidationError('The specified voucher is expired.')
-
- redeemed_in_carts = CartPosition.objects.filter(
- Q(voucher=voucher) & Q(event=self.context['event']) & Q(expires__gte=now())
- )
- cart_count = redeemed_in_carts.count()
- v_avail = voucher.max_usages - voucher.redeemed - cart_count
- if v_avail < 1:
- raise ValidationError('The specified voucher has already been used the maximum number of times.')
-
- validated_data['voucher'] = voucher
-
- if validated_data.get('seat'):
- if not validated_data['seat'].is_available(
- sales_channel=validated_data.get('sales_channel', 'web'),
- distance_ignore_cart_id=validated_data['cart_id'],
- ignore_voucher_id=validated_data['voucher'].pk if validated_data.get('voucher') else None,
- ):
- raise ValidationError(
- gettext_lazy('The selected seat "{seat}" is not available.').format(seat=validated_data['seat'].name))
-
- validated_data.pop('sales_channel')
- # todo: does this make sense?
- validated_data['custom_price_input'] = validated_data['price']
- # todo: listed price, etc?
- # currently does not matter because there is no way to transform an API cart position into an order that keeps
- # prices, cart positions are just quota/voucher placeholders
- validated_data['custom_price_input_is_net'] = not validated_data.pop('includes_tax', True)
- cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
-
- for answ_data in answers_data:
- options = answ_data.pop('options')
- if isinstance(answ_data['answer'], File):
- an = answ_data.pop('answer')
- answ = cp.answers.create(**answ_data, answer='')
- answ.file.save(os.path.basename(an.name), an, save=False)
- answ.answer = 'file://' + answ.file.name
- answ.save()
- an.close()
- else:
- answ = cp.answers.create(**answ_data)
- answ.options.add(*options)
- return cp
-
- def validate_cart_id(self, cid):
- if cid and not cid.endswith('@api'):
- raise ValidationError('Cart ID should end in @api or be empty.')
- return cid
+ fields = ('item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
+ 'subevent', 'includes_tax', 'answers')
def validate_item(self, item):
if item.event != self.context['event']:
@@ -240,4 +109,178 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
raise ValidationError(
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
)
+
+ if not data.get('expires'):
+ data['expires'] = now() + timedelta(
+ minutes=self.context['event'].settings.get('reservation_time', as_type=int)
+ )
+
+ quotas_for_item_cache = self.context.get('quotas_for_item_cache', {})
+ quotas_for_variation_cache = self.context.get('quotas_for_variation_cache', {})
+
+ seated = data.get('item').seat_category_mappings.filter(subevent=data.get('subevent')).exists()
+ if data.get('seat'):
+ if not seated:
+ raise ValidationError({'seat': ['The specified product does not allow to choose a seat.']})
+ try:
+ seat = self.context['event'].seats.get(seat_guid=data['seat'], subevent=data.get('subevent'))
+ except Seat.DoesNotExist:
+ raise ValidationError({'seat': ['The specified seat does not exist.']})
+ except Seat.MultipleObjectsReturned:
+ raise ValidationError({'seat': ['The specified seat ID is not unique.']})
+ else:
+ data['seat'] = seat
+ elif seated:
+ raise ValidationError({'seat': ['The specified product requires to choose a seat.']})
+
+ if data.get('voucher'):
+ try:
+ voucher = self.context['event'].vouchers.get(code__iexact=data['voucher'])
+ except Voucher.DoesNotExist:
+ raise ValidationError({'voucher': ['The specified voucher does not exist.']})
+
+ if voucher and not voucher.applies_to(data['item'], data.get('variation')):
+ raise ValidationError({'voucher': ['The specified voucher is not valid for the given item and variation.']})
+
+ if voucher and voucher.seat and voucher.seat != data.get('seat'):
+ raise ValidationError({'voucher': ['The specified voucher is not valid for this seat.']})
+
+ if voucher and voucher.subevent_id and (not data.get('subevent') or voucher.subevent_id != data['subevent'].pk):
+ raise ValidationError({'voucher': ['The specified voucher is not valid for this subevent.']})
+
+ if voucher.valid_until is not None and voucher.valid_until < now():
+ raise ValidationError({'voucher': ['The specified voucher is expired.']})
+
+ data['voucher'] = voucher
+
+ if not data.get('voucher') or (not data['voucher'].allow_ignore_quota and not data['voucher'].block_quota):
+ if data.get('variation'):
+ if data['variation'].pk not in quotas_for_variation_cache:
+ quotas_for_variation_cache[data['variation'].pk] = data['variation'].quotas.filter(subevent=data.get('subevent'))
+ data['_quotas'] = quotas_for_variation_cache[data['variation'].pk]
+ else:
+ if data['item'].pk not in quotas_for_item_cache:
+ quotas_for_item_cache[data['item'].pk] = data['item'].quotas.filter(subevent=data.get('subevent'))
+ data['_quotas'] = quotas_for_item_cache[data['item'].pk]
+
+ if len(data['_quotas']) == 0:
+ raise ValidationError(
+ gettext_lazy('The product "{}" is not assigned to a quota.').format(
+ str(data.get('item'))
+ )
+ )
+ else:
+ data['_quotas'] = []
+
+ return data
+
+ def create(self, validated_data):
+ validated_data.pop('_quotas')
+ answers_data = validated_data.pop('answers')
+
+ attendee_name = validated_data.pop('attendee_name', '')
+ if attendee_name and not validated_data.get('attendee_name_parts'):
+ validated_data['attendee_name_parts'] = {
+ '_legacy': attendee_name
+ }
+
+ # todo: does this make sense?
+ validated_data['custom_price_input'] = validated_data['price']
+ # todo: listed price, etc?
+ # currently does not matter because there is no way to transform an API cart position into an order that keeps
+ # prices, cart positions are just quota/voucher placeholders
+ validated_data['custom_price_input_is_net'] = not validated_data.pop('includes_tax', True)
+ cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
+
+ for answ_data in answers_data:
+ options = answ_data.pop('options')
+ if isinstance(answ_data['answer'], File):
+ an = answ_data.pop('answer')
+ answ = cp.answers.create(**answ_data, answer='')
+ answ.file.save(os.path.basename(an.name), an, save=False)
+ answ.answer = 'file://' + answ.file.name
+ answ.save()
+ an.close()
+ else:
+ answ = cp.answers.create(**answ_data)
+ answ.options.add(*options)
+ return cp
+
+
+class CartPositionCreateSerializer(BaseCartPositionCreateSerializer):
+ expires = serializers.DateTimeField(required=False)
+ addons = BaseCartPositionCreateSerializer(many=True, required=False)
+ bundled = BaseCartPositionCreateSerializer(many=True, required=False)
+ seat = serializers.CharField(required=False, allow_null=True)
+ sales_channel = serializers.CharField(required=False, default='sales_channel')
+ voucher = serializers.CharField(required=False, allow_null=True)
+
+ class Meta:
+ model = CartPosition
+ fields = BaseCartPositionCreateSerializer.Meta.fields + (
+ 'cart_id', 'expires', 'addons', 'bundled', 'seat', 'sales_channel', 'voucher'
+ )
+
+ def validate_cart_id(self, cid):
+ if cid and not cid.endswith('@api'):
+ raise ValidationError('Cart ID should end in @api or be empty.')
+ return cid
+
+ def create(self, validated_data):
+ validated_data.pop('sales_channel')
+ addons_data = validated_data.pop('addons', None)
+ bundled_data = validated_data.pop('bundled', None)
+
+ cp = super().create(validated_data)
+
+ if addons_data:
+ for addon_data in addons_data:
+ addon_data['addon_to'] = cp
+ addon_data['is_bundled'] = False
+ super().create(addon_data)
+
+ if bundled_data:
+ for bundle_data in bundled_data:
+ bundle_data['addon_to'] = cp
+ bundle_data['is_bundled'] = True
+ super().create(bundle_data)
+
+ return cp
+
+ def validate(self, data):
+ data = super().validate(data)
+
+ # This is currently only a very basic validation of add-ons and bundled products, we don't validate their number
+ # or price. We can always go stricter, as the endpoint is documented as experimental.
+ # However, this serializer should always be *at least* as strict as the order creation serializer.
+
+ if data.get('item') and data.get('addons'):
+ prefetch_related_objects([data['item']], 'addons')
+ for sub_data in data['addons']:
+ if not any(a.addon_category_id == sub_data['item'].category_id for a in data['item'].addons.all()):
+ raise ValidationError({
+ 'addons': [
+ 'The product "{prod}" can not be used as an add-on product for "{main}".'.format(
+ prod=str(sub_data['item']),
+ main=str(data['item']),
+ )
+ ]
+ })
+
+ if data.get('item') and data.get('bundled'):
+ prefetch_related_objects([data['item']], 'bundles')
+ for sub_data in data['bundled']:
+ if not any(
+ a.bundled_item_id == sub_data['item'].pk and
+ a.bundled_variation_id == (sub_data['variation'].pk if sub_data.get('variation') else None)
+ for a in data['item'].bundles.all()
+ ):
+ raise ValidationError({
+ 'bundled': [
+ 'The product "{prod}" can not be used as an bundled product for "{main}".'.format(
+ prod=str(sub_data['item']),
+ main=str(data['item']),
+ )
+ ]
+ })
return data
diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py
index b7cd6944ad..4d1fa36204 100644
--- a/src/pretix/api/serializers/order.py
+++ b/src/pretix/api/serializers/order.py
@@ -1086,6 +1086,10 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
seated = pos_data.get('item').seat_category_mappings.filter(subevent=pos_data.get('subevent')).exists()
if pos_data.get('seat'):
+ if pos_data.get('addon_to'):
+ errs[i]['seat'] = ['Seats are currently not supported for add-on products.']
+ continue
+
if not seated:
errs[i]['seat'] = ['The specified product does not allow to choose a seat.']
try:
diff --git a/src/pretix/api/views/cart.py b/src/pretix/api/views/cart.py
index e6c205bb8e..60c271adcc 100644
--- a/src/pretix/api/views/cart.py
+++ b/src/pretix/api/views/cart.py
@@ -19,19 +19,28 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# .
#
+from collections import Counter
+from typing import List
+
from django.db import transaction
+from django.utils.crypto import get_random_string
+from django.utils.functional import cached_property
+from django.utils.translation import gettext as _
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.filters import OrderingFilter
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin
from rest_framework.response import Response
-from rest_framework.settings import api_settings
+from rest_framework.serializers import as_serializer_error
from pretix.api.serializers.cart import (
CartPositionCreateSerializer, CartPositionSerializer,
)
from pretix.base.models import CartPosition
+from pretix.base.services.cart import (
+ _get_quota_availability, _get_voucher_availability, error_messages,
+)
from pretix.base.services.locking import NoLockManager
@@ -54,18 +63,17 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
- ctx['quota_cache'] = {}
+ ctx['quotas_for_item_cache'] = {}
+ ctx['quotas_for_variation_cache'] = {}
return ctx
def create(self, request, *args, **kwargs):
- serializer = CartPositionCreateSerializer(data=request.data, context=self.get_serializer_context())
+ ctx = self.get_serializer_context()
+ serializer = CartPositionCreateSerializer(data=request.data, context=ctx)
serializer.is_valid(raise_exception=True)
- with transaction.atomic(), self.request.event.lock():
- self.perform_create(serializer)
- cp = serializer.instance
- serializer = CartPositionSerializer(cp, context=serializer.context)
+ results = self._create(serializers=[serializer], raise_exception=True, ctx=ctx)
headers = self.get_success_headers(serializer.data)
- return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
+ return Response(results[0]['data'], status=status.HTTP_201_CREATED, headers=headers)
@action(detail=False, methods=['POST'])
def bulk_create(self, request, *args, **kwargs):
@@ -73,42 +81,158 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
return Response({"error": "Please supply a list"}, status=status.HTTP_400_BAD_REQUEST)
ctx = self.get_serializer_context()
- with transaction.atomic():
- serializers = [
- CartPositionCreateSerializer(data=d, context=ctx)
- for d in request.data
- ]
-
- lockfn = self.request.event.lock
- if not any(s.is_valid(raise_exception=False) for s in serializers):
- lockfn = NoLockManager
-
- results = []
- with lockfn():
- for s in serializers:
- if s.is_valid(raise_exception=False):
- try:
- cp = s.save()
- except ValidationError as e:
- results.append({
- 'success': False,
- 'data': None,
- 'errors': {api_settings.NON_FIELD_ERRORS_KEY: e.detail},
- })
- else:
- results.append({
- 'success': True,
- 'data': CartPositionSerializer(cp, context=ctx).data,
- 'errors': None,
- })
- else:
- results.append({
- 'success': False,
- 'data': None,
- 'errors': s.errors,
- })
+ serializers = [
+ CartPositionCreateSerializer(data=d, context=ctx)
+ for d in request.data
+ ]
+ results = self._create(serializers=serializers, raise_exception=False, ctx=ctx)
return Response({'results': results}, status=status.HTTP_200_OK)
def perform_create(self, serializer):
- serializer.save()
+ raise NotImplementedError()
+
+ def _require_locking(self, quota_diff, voucher_use_diff, seat_diff):
+ if voucher_use_diff or seat_diff:
+ # If any vouchers or seats are used, we lock to make sure we don't redeem them to often
+ return True
+
+ if quota_diff and any(q.size is not None for q in quota_diff):
+ # If any quotas are affected that are not unlimited, we lock
+ return True
+
+ return False
+
+ @cached_property
+ def _create_default_cart_id(self):
+ cid = "{}@api".format(get_random_string(48))
+ while CartPosition.objects.filter(cart_id=cid).exists():
+ cid = "{}@api".format(get_random_string(48))
+ return cid
+
+ def _create(self, serializers: List[CartPositionCreateSerializer], ctx, raise_exception=False):
+ voucher_use_diff = Counter()
+ quota_diff = Counter()
+ seat_diff = Counter()
+ results = [{} for pserializer in serializers]
+
+ for i, pserializer in enumerate(serializers):
+ if not pserializer.is_valid(raise_exception=raise_exception):
+ results[i] = {
+ 'success': False,
+ 'data': None,
+ 'errors': pserializer.errors,
+ }
+
+ for pserializer in serializers:
+ if pserializer.errors:
+ continue
+
+ validated_data = pserializer.validated_data
+ if not validated_data.get('cart_id'):
+ validated_data['cart_id'] = self._create_default_cart_id
+
+ if validated_data.get('voucher'):
+ voucher_use_diff[validated_data['voucher']] += 1
+
+ if validated_data.get('seat'):
+ seat_diff[validated_data['seat']] += 1
+
+ for q in validated_data['_quotas']:
+ quota_diff[q] += 1
+ for sub_data in validated_data.get('addons', []) + validated_data.get('bundled', []):
+ for q in sub_data['_quotas']:
+ quota_diff[q] += 1
+
+ seats_seen = set()
+
+ lockfn = NoLockManager
+ if self._require_locking(quota_diff, voucher_use_diff, seat_diff):
+ lockfn = self.request.event.lock
+
+ with lockfn() as now_dt, transaction.atomic():
+ vouchers_ok, vouchers_depend_on_cart = _get_voucher_availability(
+ self.request.event,
+ voucher_use_diff,
+ now_dt,
+ exclude_position_ids=[],
+ )
+ quotas_ok = _get_quota_availability(quota_diff, now_dt)
+
+ for i, pserializer in enumerate(serializers):
+ if results[i]:
+ continue
+
+ try:
+ validated_data = pserializer.validated_data
+
+ if validated_data.get('seat'):
+ # Assumption: Add-ons currently can't have seats
+ if validated_data['seat'] in seats_seen:
+ raise ValidationError(error_messages['seat_multiple'])
+ seats_seen.add(validated_data['seat'])
+
+ quotas_needed = Counter()
+ for q in validated_data['_quotas']:
+ quotas_needed[q] += 1
+ for sub_data in validated_data.get('addons', []) + validated_data.get('bundled', []):
+ for q in sub_data['_quotas']:
+ quotas_needed[q] += 1
+
+ for q, needed in quotas_needed.items():
+ if quotas_ok[q] < needed:
+ raise ValidationError(
+ _('There is not enough quota available on quota "{}" to perform the operation.').format(
+ q.name
+ )
+ )
+
+ if validated_data.get('voucher'):
+ # Assumption: Add-ons currently can't have vouchers, thus we only need to check the main voucher
+ if vouchers_ok[validated_data['voucher']] < 1:
+ raise ValidationError(
+ {'voucher': [_('The specified voucher has already been used the maximum number of times.')]}
+ )
+
+ if validated_data.get('seat'):
+ # Assumption: Add-ons currently can't have seats, thus we only need to check the main product
+ if not validated_data['seat'].is_available(
+ sales_channel=validated_data.get('sales_channel', 'web'),
+ distance_ignore_cart_id=validated_data['cart_id'],
+ ignore_voucher_id=validated_data['voucher'].pk if validated_data.get('voucher') else None,
+ ):
+ raise ValidationError(
+ {'seat': [_('The selected seat "{seat}" is not available.').format(seat=validated_data['seat'].name)]}
+ )
+
+ for q, needed in quotas_needed.items():
+ quotas_ok[q] -= needed
+ if validated_data.get('voucher'):
+ vouchers_ok[validated_data['voucher']] -= 1
+
+ if any(qa < 0 for qa in quotas_ok.values()):
+ # Safeguard, should never happen because of conditions above
+ raise ValidationError(error_messages['unavailable'])
+
+ cp = pserializer.create(validated_data)
+
+ d = CartPositionSerializer(cp, context=ctx).data
+ addons = sorted(cp.addons.all(), key=lambda a: a.pk) # order of creation, safe since they are created in the same transaction
+ d['addons'] = CartPositionSerializer([a for a in addons if not a.is_bundled], many=True, context=ctx).data
+ d['bundled'] = CartPositionSerializer([a for a in addons if a.is_bundled], many=True, context=ctx).data
+
+ results[i] = {
+ 'success': True,
+ 'data': d,
+ 'errors': None,
+ }
+ except ValidationError as e:
+ if raise_exception:
+ raise
+ results[i] = {
+ 'success': False,
+ 'data': None,
+ 'errors': as_serializer_error(e),
+ }
+
+ return results
diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py
index 965ec3886f..dea55573ef 100644
--- a/src/pretix/base/services/cart.py
+++ b/src/pretix/base/services/cart.py
@@ -147,6 +147,45 @@ error_messages = {
}
+def _get_quota_availability(quota_diff, now_dt):
+ quotas_ok = defaultdict(int)
+ qa = QuotaAvailability()
+ qa.queue(*[k for k, v in quota_diff.items() if v > 0])
+ qa.compute(now_dt=now_dt)
+ for quota, count in quota_diff.items():
+ if count <= 0:
+ quotas_ok[quota] = 0
+ break
+ avail = qa.results[quota]
+ if avail[1] is not None and avail[1] < count:
+ quotas_ok[quota] = min(count, avail[1])
+ else:
+ quotas_ok[quota] = count
+ return quotas_ok
+
+
+def _get_voucher_availability(event, voucher_use_diff, now_dt, exclude_position_ids):
+ vouchers_ok = {}
+ _voucher_depend_on_cart = set()
+ for voucher, count in voucher_use_diff.items():
+ voucher.refresh_from_db()
+
+ if voucher.valid_until is not None and voucher.valid_until < now_dt:
+ raise CartError(error_messages['voucher_expired'])
+
+ redeemed_in_carts = CartPosition.objects.filter(
+ Q(voucher=voucher) & Q(event=event) &
+ Q(expires__gte=now_dt)
+ ).exclude(pk__in=exclude_position_ids)
+ cart_count = redeemed_in_carts.count()
+ v_avail = voucher.max_usages - voucher.redeemed - cart_count
+ if cart_count > 0:
+ _voucher_depend_on_cart.add(voucher)
+ vouchers_ok[voucher] = v_avail
+
+ return vouchers_ok, _voucher_depend_on_cart
+
+
class CartManager:
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'voucher', 'quotas',
'addon_to', 'subevent', 'bundled', 'seat', 'listed_price',
@@ -819,43 +858,13 @@ class CartManager:
self._quota_diff.update(quota_diff)
self._operations += operations
- def _get_quota_availability(self):
- quotas_ok = defaultdict(int)
- qa = QuotaAvailability()
- qa.queue(*[k for k, v in self._quota_diff.items() if v > 0])
- qa.compute(now_dt=self.now_dt)
- for quota, count in self._quota_diff.items():
- if count <= 0:
- quotas_ok[quota] = 0
- break
- avail = qa.results[quota]
- if avail[1] is not None and avail[1] < count:
- quotas_ok[quota] = min(count, avail[1])
- else:
- quotas_ok[quota] = count
- return quotas_ok
-
def _get_voucher_availability(self):
- vouchers_ok = {}
- self._voucher_depend_on_cart = set()
- for voucher, count in self._voucher_use_diff.items():
- voucher.refresh_from_db()
-
- if voucher.valid_until is not None and voucher.valid_until < self.now_dt:
- raise CartError(error_messages['voucher_expired'])
-
- redeemed_in_carts = CartPosition.objects.filter(
- Q(voucher=voucher) & Q(event=self.event) &
- Q(expires__gte=self.now_dt)
- ).exclude(pk__in=[
+ vouchers_ok, self._voucher_depend_on_cart = _get_voucher_availability(
+ self.event, self._voucher_use_diff, self.now_dt,
+ exclude_position_ids=[
op.position.id for op in self._operations if isinstance(op, self.ExtendOperation)
- ])
- cart_count = redeemed_in_carts.count()
- v_avail = voucher.max_usages - voucher.redeemed - cart_count
- if cart_count > 0:
- self._voucher_depend_on_cart.add(voucher)
- vouchers_ok[voucher] = v_avail
-
+ ]
+ )
return vouchers_ok
def _check_min_max_per_product(self):
@@ -908,7 +917,7 @@ class CartManager:
def _perform_operations(self):
vouchers_ok = self._get_voucher_availability()
- quotas_ok = self._get_quota_availability()
+ quotas_ok = _get_quota_availability(self._quota_diff, self.now_dt)
err = None
new_cart_positions = []
diff --git a/src/tests/api/test_cart.py b/src/tests/api/test_cart.py
index 3c150f56b8..03cd709561 100644
--- a/src/tests/api/test_cart.py
+++ b/src/tests/api/test_cart.py
@@ -82,6 +82,7 @@ TEST_CARTPOSITION_RES = {
'attendee_email': None,
'voucher': None,
'addon_to': None,
+ 'is_bundled': False,
'subevent': None,
'datetime': '2018-06-11T10:00:00Z',
'expires': '2018-06-11T10:00:00Z',
@@ -352,7 +353,7 @@ def test_cartpos_create_item_validation(token_client, organizer, event, item, it
), format='json', data=res
)
assert resp.status_code == 400
- assert resp.data == ['The product "Budget Ticket" is not assigned to a quota.']
+ assert resp.data == {'non_field_errors': ['The product "Budget Ticket" is not assigned to a quota.']}
quota.variations.add(var1)
resp = token_client.post(
@@ -704,7 +705,7 @@ def test_cartpos_create_with_blocked_seat(token_client, organizer, event, item,
), format='json', data=res
)
assert resp.status_code == 400
- assert resp.data == ['The selected seat "Seat A1" is not available.']
+ assert resp.data == {'seat': ['The selected seat "Seat A1" is not available.']}
@pytest.mark.django_db
@@ -739,7 +740,7 @@ def test_cartpos_create_with_used_seat(token_client, organizer, event, item, quo
), format='json', data=res
)
assert resp.status_code == 400
- assert resp.data == ['The selected seat "Seat A1" is not available.']
+ assert resp.data == {'seat': ['The selected seat "Seat A1" is not available.']}
@pytest.mark.django_db
@@ -753,7 +754,7 @@ def test_cartpos_create_with_unknown_seat(token_client, organizer, event, item,
), format='json', data=res
)
assert resp.status_code == 400
- assert resp.data == ['The specified seat does not exist.']
+ assert resp.data == {'seat': ['The specified seat does not exist.']}
@pytest.mark.django_db
@@ -766,7 +767,7 @@ def test_cartpos_create_require_seat(token_client, organizer, event, item, quota
), format='json', data=res
)
assert resp.status_code == 400
- assert resp.data == ['The specified product requires to choose a seat.']
+ assert resp.data == {'seat': ['The specified product requires to choose a seat.']}
@pytest.mark.django_db
@@ -783,7 +784,7 @@ def test_cartpos_create_unseated(token_client, organizer, event, item, quota, se
), format='json', data=res
)
assert resp.status_code == 400
- assert resp.data == ['The specified product does not allow to choose a seat.']
+ assert resp.data == {'seat': ['The specified product does not allow to choose a seat.']}
@pytest.mark.django_db
@@ -882,7 +883,7 @@ def test_cartpos_create_bulk_partial_seat_failure(token_client, organizer, event
assert len(resp.data['results']) == 2
assert resp.data['results'][0]['success']
assert not resp.data['results'][1]['success']
- assert resp.data['results'][1]['errors'] == {'non_field_errors': ['The selected seat "Seat A1" is not available.']}
+ assert resp.data['results'][1]['errors'] == {'non_field_errors': ['You can not select the same seat multiple times.']}
with scopes_disabled():
assert CartPosition.objects.count() == 1
@@ -921,7 +922,7 @@ def test_cartpos_create_with_voucher_unknown(token_client, organizer, event, ite
), format='json', data=res
)
assert resp.status_code == 400
- assert resp.data == ['The specified voucher does not exist.']
+ assert resp.data == {'voucher': ['The specified voucher does not exist.']}
@pytest.mark.django_db
@@ -938,7 +939,7 @@ def test_cartpos_create_with_voucher_invalid_item(token_client, organizer, event
), format='json', data=res
)
assert resp.status_code == 400
- assert resp.data == ['The specified voucher is not valid for the given item and variation.']
+ assert resp.data == {'voucher': ['The specified voucher is not valid for the given item and variation.']}
@pytest.mark.django_db
@@ -956,7 +957,7 @@ def test_cartpos_create_with_voucher_invalid_seat(token_client, organizer, event
), format='json', data=res
)
assert resp.status_code == 400
- assert resp.data == ['The specified voucher is not valid for this seat.']
+ assert resp.data == {'voucher': ['The specified voucher is not valid for this seat.']}
@pytest.mark.django_db
@@ -976,7 +977,7 @@ def test_cartpos_create_with_voucher_invalid_subevent(token_client, organizer, e
), format='json', data=res
)
assert resp.status_code == 400
- assert resp.data == ['The specified voucher is not valid for this subevent.']
+ assert resp.data == {'voucher': ['The specified voucher is not valid for this subevent.']}
@pytest.mark.django_db
@@ -992,7 +993,7 @@ def test_cartpos_create_with_voucher_expired(token_client, organizer, event, ite
), format='json', data=res
)
assert resp.status_code == 400
- assert resp.data == ['The specified voucher is expired.']
+ assert resp.data == {'voucher': ['The specified voucher is expired.']}
@pytest.mark.django_db
@@ -1008,7 +1009,7 @@ def test_cartpos_create_with_voucher_redeemed(token_client, organizer, event, it
), format='json', data=res
)
assert resp.status_code == 400
- assert resp.data == ['The specified voucher has already been used the maximum number of times.']
+ assert resp.data == {'voucher': ['The specified voucher has already been used the maximum number of times.']}
@pytest.mark.django_db
@@ -1060,9 +1061,317 @@ def test_cartpos_create_bulk_with_voucher_redeemed(token_client, organizer, even
assert len(resp.data['results']) == 2
assert resp.data['results'][0]['success']
assert not resp.data['results'][1]['success']
- assert resp.data['results'][1]['errors'] == {'non_field_errors': ['The specified voucher has already been used the maximum number of times.']}
+ assert resp.data['results'][1]['errors'] == {'voucher': ['The specified voucher has already been used the maximum number of times.']}
with scopes_disabled():
assert CartPosition.objects.count() == 1
cp1 = CartPosition.objects.get(pk=resp.data['results'][0]['data']['id'])
assert cp1.voucher == voucher
+
+
+@pytest.mark.django_db
+def test_cartpos_create_bulk_with_addon(token_client, organizer, event, item, quota):
+ with scopes_disabled():
+ addon_cat = event.categories.create(name='Addons')
+ addon_item = event.items.create(name='Workshop', default_price=2, category=addon_cat)
+ item.addons.create(addon_category=addon_cat)
+ q = event.quotas.create(name="Addon Quota", size=200)
+ q.items.add(addon_item)
+
+ res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
+ res['item'] = item.pk
+ res['expires'] = (now() + datetime.timedelta(days=1)).isoformat()
+ res['addons'] = [
+ {
+ 'item': addon_item.pk,
+ 'variation': None,
+ 'price': '1.00',
+ 'attendee_name_parts': {'full_name': 'Peter\'s friend'},
+ 'attendee_email': None,
+ 'subevent': None,
+ 'includes_tax': True,
+ 'answers': []
+ }
+ ]
+ resp = token_client.post(
+ '/api/v1/organizers/{}/events/{}/cartpositions/bulk_create/'.format(
+ organizer.slug, event.slug
+ ), format='json', data=[
+ res,
+ res
+ ]
+ )
+ assert resp.status_code == 200
+ assert len(resp.data['results']) == 2
+ assert resp.data['results'][0]['success']
+ assert resp.data['results'][1]['success']
+
+ with scopes_disabled():
+ assert CartPosition.objects.count() == 4
+ cp1 = CartPosition.objects.get(pk=resp.data['results'][0]['data']['id'])
+ cp1a = cp1.addons.get()
+ assert cp1a.pk == resp.data['results'][0]['data']['addons'][0]['id']
+ assert cp1a.item == addon_item
+ assert not cp1a.is_bundled
+ assert cp1a.attendee_name == "Peter's friend"
+
+
+@pytest.mark.django_db
+def test_cartpos_create_bulk_with_addon_partially_available(token_client, organizer, event, item, quota):
+ with scopes_disabled():
+ addon_cat = event.categories.create(name='Addons')
+ addon_item = event.items.create(name='Workshop', default_price=2, category=addon_cat)
+ item.addons.create(addon_category=addon_cat)
+ q = event.quotas.create(name="Addon Quota", size=1)
+ q.items.add(addon_item)
+
+ res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
+ res['item'] = item.pk
+ res['expires'] = (now() + datetime.timedelta(days=1)).isoformat()
+ res['addons'] = [
+ {
+ 'item': addon_item.pk,
+ 'variation': None,
+ 'price': '1.00',
+ 'attendee_name_parts': {'full_name': 'Peter\'s friend'},
+ 'attendee_email': None,
+ 'subevent': None,
+ 'includes_tax': True,
+ 'answers': []
+ }
+ ]
+ resp = token_client.post(
+ '/api/v1/organizers/{}/events/{}/cartpositions/bulk_create/'.format(
+ organizer.slug, event.slug
+ ), format='json', data=[
+ res,
+ res
+ ]
+ )
+ assert resp.status_code == 200
+ assert len(resp.data['results']) == 2
+ assert resp.data['results'][0]['success']
+ assert not resp.data['results'][1]['success']
+
+ with scopes_disabled():
+ assert CartPosition.objects.count() == 2
+ cp1 = CartPosition.objects.get(pk=resp.data['results'][0]['data']['id'])
+ cp1a = cp1.addons.get()
+ assert cp1a.item == addon_item
+ assert not cp1a.is_bundled
+ assert cp1a.attendee_name == "Peter's friend"
+
+
+@pytest.mark.django_db
+def test_cartpos_create_bulk_with_bundled(token_client, organizer, event, item, quota):
+ with scopes_disabled():
+ bundled_item = event.items.create(name='Workshop', default_price=2)
+ item.bundles.create(bundled_item=bundled_item)
+ q = event.quotas.create(name="Addon Quota", size=200)
+ q.items.add(bundled_item)
+
+ res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
+ res['item'] = item.pk
+ res['expires'] = (now() + datetime.timedelta(days=1)).isoformat()
+ res['bundled'] = [
+ {
+ 'item': bundled_item.pk,
+ 'variation': None,
+ 'price': '1.00',
+ 'attendee_name_parts': {'full_name': 'Peter\'s friend'},
+ 'attendee_email': None,
+ 'subevent': None,
+ 'includes_tax': True,
+ 'answers': []
+ }
+ ]
+ resp = token_client.post(
+ '/api/v1/organizers/{}/events/{}/cartpositions/bulk_create/'.format(
+ organizer.slug, event.slug
+ ), format='json', data=[
+ res,
+ res
+ ]
+ )
+ assert resp.status_code == 200
+ assert len(resp.data['results']) == 2
+ assert resp.data['results'][0]['success']
+ assert resp.data['results'][1]['success']
+
+ with scopes_disabled():
+ assert CartPosition.objects.count() == 4
+ cp1 = CartPosition.objects.get(pk=resp.data['results'][0]['data']['id'])
+ cp1a = cp1.addons.get()
+ assert cp1a.pk == resp.data['results'][0]['data']['bundled'][0]['id']
+ assert cp1a.item == bundled_item
+ assert cp1a.is_bundled
+ assert cp1a.attendee_name == "Peter's friend"
+
+
+@pytest.mark.django_db
+def test_cartpos_create_bulk_with_bundled_partially_available(token_client, organizer, event, item, quota):
+ with scopes_disabled():
+ bundled_item = event.items.create(name='Workshop', default_price=2)
+ item.bundles.create(bundled_item=bundled_item)
+ q = event.quotas.create(name="Addon Quota", size=1)
+ q.items.add(bundled_item)
+
+ res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
+ res['item'] = item.pk
+ res['expires'] = (now() + datetime.timedelta(days=1)).isoformat()
+ res['bundled'] = [
+ {
+ 'item': bundled_item.pk,
+ 'variation': None,
+ 'price': '1.00',
+ 'attendee_name_parts': {'full_name': 'Peter\'s friend'},
+ 'attendee_email': None,
+ 'subevent': None,
+ 'includes_tax': True,
+ 'answers': []
+ }
+ ]
+ resp = token_client.post(
+ '/api/v1/organizers/{}/events/{}/cartpositions/bulk_create/'.format(
+ organizer.slug, event.slug
+ ), format='json', data=[
+ res,
+ res
+ ]
+ )
+ assert resp.status_code == 200
+ assert len(resp.data['results']) == 2
+ assert resp.data['results'][0]['success']
+ assert not resp.data['results'][1]['success']
+
+ with scopes_disabled():
+ assert CartPosition.objects.count() == 2
+ cp1 = CartPosition.objects.get(pk=resp.data['results'][0]['data']['id'])
+ cp1a = cp1.addons.get()
+ assert cp1a.item == bundled_item
+ assert cp1a.is_bundled
+ assert cp1a.attendee_name == "Peter's friend"
+
+
+@pytest.mark.django_db
+def test_cartpos_create_bulk_with_bundled_without_configuration(token_client, organizer, event, item, quota):
+ with scopes_disabled():
+ bundled_item = event.items.create(name='Workshop', default_price=2)
+ q = event.quotas.create(name="Addon Quota", size=1)
+ q.items.add(bundled_item)
+
+ res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
+ res['item'] = item.pk
+ res['expires'] = (now() + datetime.timedelta(days=1)).isoformat()
+ res['bundled'] = [
+ {
+ 'item': bundled_item.pk,
+ 'variation': None,
+ 'price': '1.00',
+ 'attendee_name_parts': {'full_name': 'Peter\'s friend'},
+ 'attendee_email': None,
+ 'subevent': None,
+ 'includes_tax': True,
+ 'answers': []
+ }
+ ]
+ resp = token_client.post(
+ '/api/v1/organizers/{}/events/{}/cartpositions/bulk_create/'.format(
+ organizer.slug, event.slug
+ ), format='json', data=[
+ res
+ ]
+ )
+ assert resp.status_code == 200
+ assert resp.data == {
+ 'results': [
+ {
+ 'data': None,
+ 'success': False,
+ 'errors': {
+ 'bundled': ['The product "Workshop" can not be used as an bundled product for "Budget Ticket".']
+ }
+ }
+ ]
+ }
+
+
+@pytest.mark.django_db
+def test_cartpos_create_bulk_with_addon_without_configuration(token_client, organizer, event, item, quota):
+ with scopes_disabled():
+ bundled_item = event.items.create(name='Workshop', default_price=2)
+ q = event.quotas.create(name="Addon Quota", size=1)
+ q.items.add(bundled_item)
+
+ res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
+ res['item'] = item.pk
+ res['expires'] = (now() + datetime.timedelta(days=1)).isoformat()
+ res['addons'] = [
+ {
+ 'item': bundled_item.pk,
+ 'variation': None,
+ 'price': '1.00',
+ 'attendee_name_parts': {'full_name': 'Peter\'s friend'},
+ 'attendee_email': None,
+ 'subevent': None,
+ 'includes_tax': True,
+ 'answers': []
+ }
+ ]
+ resp = token_client.post(
+ '/api/v1/organizers/{}/events/{}/cartpositions/bulk_create/'.format(
+ organizer.slug, event.slug
+ ), format='json', data=[
+ res
+ ]
+ )
+ assert resp.status_code == 200
+ assert resp.data == {
+ 'results': [
+ {
+ 'data': None,
+ 'success': False,
+ 'errors': {
+ 'addons': ['The product "Workshop" can not be used as an add-on product for "Budget Ticket".']
+ }
+ }
+ ]
+ }
+
+
+@pytest.mark.django_db
+def test_cartpos_create_bulk_validation_error_in_addon(token_client, organizer, event, item, quota):
+ res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
+ res['item'] = item.pk
+ res['expires'] = (now() + datetime.timedelta(days=1)).isoformat()
+ res['addons'] = [
+ {
+ 'item': -1,
+ 'variation': None,
+ 'price': '1.00',
+ 'attendee_name_parts': {'full_name': 'Peter\'s friend'},
+ 'attendee_email': None,
+ 'subevent': None,
+ 'includes_tax': True,
+ 'answers': []
+ }
+ ]
+ resp = token_client.post(
+ '/api/v1/organizers/{}/events/{}/cartpositions/bulk_create/'.format(
+ organizer.slug, event.slug
+ ), format='json', data=[
+ res
+ ]
+ )
+ assert resp.status_code == 200
+ assert resp.data == {
+ 'results': [
+ {
+ 'data': None,
+ 'success': False,
+ 'errors': {
+ 'addons': [{'item': ['Invalid pk "-1" - object does not exist.']}]
+ }
+ }
+ ]
+ }
diff --git a/src/tests/api/test_order_create.py b/src/tests/api/test_order_create.py
index ba4458faa4..2c97fc972c 100644
--- a/src/tests/api/test_order_create.py
+++ b/src/tests/api/test_order_create.py
@@ -1986,7 +1986,7 @@ def test_order_create_with_duplicate_seat(token_client, organizer, event, item,
"price": "23.00",
"attendee_name_parts": {"full_name": "Peter"},
"attendee_email": None,
- "addon_to": 1,
+ "addon_to": None,
"answers": [],
"subevent": None,
"seat": seat.seat_guid