From 38969747f4951000b4e53f49d27ec6de8b89760e Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Mon, 10 Oct 2022 12:59:38 +0200 Subject: [PATCH] API: New implementation for cart creation (#2833) --- doc/api/resources/carts.rst | 33 ++- src/pretix/__init__.py | 2 +- src/pretix/api/serializers/cart.py | 317 +++++++++++++++----------- src/pretix/api/serializers/order.py | 4 + src/pretix/api/views/cart.py | 210 +++++++++++++---- src/pretix/base/services/cart.py | 81 ++++--- src/tests/api/test_cart.py | 337 ++++++++++++++++++++++++++-- src/tests/api/test_order_create.py | 2 +- 8 files changed, 746 insertions(+), 240 deletions(-) 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