mirror of
https://github.com/pretix/pretix.git
synced 2026-05-05 15:14:04 +00:00
API: New implementation for cart creation (#2833)
This commit is contained in:
@@ -19,4 +19,4 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
__version__ = "4.14.0.dev0"
|
||||
__version__ = "4.14.1.dev0"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -19,19 +19,28 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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.']}]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user