diff --git a/doc/api/resources/carts.rst b/doc/api/resources/carts.rst index a717880dd0..8453bfb283 100644 --- a/doc/api/resources/carts.rst +++ b/doc/api/resources/carts.rst @@ -243,6 +243,99 @@ Cart position endpoints :statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this order. +.. 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! + + .. warning:: This endpoint is considered **experimental**. It might change at any time without prior notice. + + .. warning:: The same limitations as with the regular creation endpoint apply. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/cartpositions/bulk_create/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + [ + { + "item": 1, + "variation": null, + "price": "23.00", + "attendee_name_parts": { + "given_name": "Peter", + "family_name": "Miller" + }, + "attendee_email": null, + "answers": [ + { + "question": 1, + "answer": "23", + "options": [] + } + ], + "subevent": null + }, + { + "item": 1, + "variation": null, + "price": "23.00", + "attendee_name_parts": { + "given_name": "Maria", + "family_name": "Miller" + }, + "attendee_email": null, + "answers": [ + { + "question": 1, + "answer": "23", + "options": [] + } + ], + "subevent": null + } + ] + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "results": [ + { + "success": true, + "errors": null, + "data": { + "id": 1, + ... + }, + }, + { + "success": "false", + "errors": { + "non_field_errors": ["There is not enough quota available on quota \"Tickets\" to perform the operation."] + }, + "data": null + } + ] + } + + :param organizer: The ``slug`` field of the organizer of the event to create positions for + :param event: The ``slug`` field of the event to create positions for + :statuscode 200: See response for success + :statuscode 400: Your input could not be parsed + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this + order. + .. http:delete:: /api/v1/organizers/(organizer)/events/(event)/cartpositions/(id)/ Deletes a cart position, identified by its internal ID. diff --git a/src/pretix/__init__.py b/src/pretix/__init__.py index 1d917ea587..d0caa3aee7 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.2.0.dev0" +__version__ = "4.2.0.dev1" diff --git a/src/pretix/api/auth/devicesecurity.py b/src/pretix/api/auth/devicesecurity.py index 6f6100d6a3..43021b3644 100644 --- a/src/pretix/api/auth/devicesecurity.py +++ b/src/pretix/api/auth/devicesecurity.py @@ -36,6 +36,7 @@ class AllowListSecurityProfile: def is_allowed(self, request): key = (request.method, f"{request.resolver_match.namespace}:{request.resolver_match.url_name}") + print(key) return key in self.allowlist @@ -163,6 +164,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile): ('POST', 'api-v1:orderrefund-list'), ('POST', 'api-v1:orderrefund-done'), ('POST', 'api-v1:cartposition-list'), + ('POST', 'api-v1:cartposition-bulk-create'), ('GET', 'api-v1:checkinlist-list'), ('POST', 'api-v1:checkinlistpos-redeem'), ('POST', 'plugins:pretix_posbackend:order.posprintlog'), diff --git a/src/pretix/api/serializers/cart.py b/src/pretix/api/serializers/cart.py index 822b06d90d..a1ea0430c4 100644 --- a/src/pretix/api/serializers/cart.py +++ b/src/pretix/api/serializers/cart.py @@ -73,53 +73,60 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer): minutes=self.context['event'].settings.get('reservation_time', as_type=int) ) - with self.context['event'].lock(): - 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: + 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('The product "{}" is not assigned to a quota.').format( - str(validated_data.get('item')) + gettext_lazy('There is not enough quota available on quota "{}" to perform ' + 'the operation.').format( + quota.name ) ) - for quota in new_quotas: - avail = quota.availability() - 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 - ) - ) - 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 - if not seat.is_available( - sales_channel=validated_data.get('sales_channel', 'web'), - distance_ignore_cart_id=validated_data['cart_id'], - ): - raise ValidationError(gettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)) - elif seated: - raise ValidationError('The specified product requires to choose a seat.') + for quota in new_quotas: + newsize = self.context['quota_cache'][quota.pk][1] - 1 + self.context['quota_cache'][quota.pk] = ( + Quota.AVAILABILITY_OK if newsize > 9 else Quota.AVAILABILITY_GONE, + newsize + ) - validated_data.pop('sales_channel') - cp = CartPosition.objects.create(event=self.context['event'], **validated_data) + 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 + if not seat.is_available( + sales_channel=validated_data.get('sales_channel', 'web'), + distance_ignore_cart_id=validated_data['cart_id'], + ): + raise ValidationError(gettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)) + elif seated: + raise ValidationError('The specified product requires to choose a seat.') + + validated_data.pop('sales_channel') + cp = CartPosition.objects.create(event=self.context['event'], **validated_data) for answ_data in answers_data: options = answ_data.pop('options') diff --git a/src/pretix/api/views/cart.py b/src/pretix/api/views/cart.py index 834e2f1e85..e6c205bb8e 100644 --- a/src/pretix/api/views/cart.py +++ b/src/pretix/api/views/cart.py @@ -21,14 +21,18 @@ # from django.db import transaction 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 pretix.api.serializers.cart import ( CartPositionCreateSerializer, CartPositionSerializer, ) from pretix.base.models import CartPosition +from pretix.base.services.locking import NoLockManager class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet): @@ -50,18 +54,61 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly def get_serializer_context(self): ctx = super().get_serializer_context() ctx['event'] = self.request.event + ctx['quota_cache'] = {} return ctx def create(self, request, *args, **kwargs): serializer = CartPositionCreateSerializer(data=request.data, context=self.get_serializer_context()) serializer.is_valid(raise_exception=True) - with transaction.atomic(): + with transaction.atomic(), self.request.event.lock(): self.perform_create(serializer) - cp = serializer.instance - serializer = CartPositionSerializer(cp, context=serializer.context) - + cp = serializer.instance + serializer = CartPositionSerializer(cp, context=serializer.context) headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + @action(detail=False, methods=['POST']) + def bulk_create(self, request, *args, **kwargs): + if not isinstance(request.data, list): # noqa + 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, + }) + + return Response({'results': results}, status=status.HTTP_200_OK) + def perform_create(self, serializer): serializer.save() diff --git a/src/tests/api/test_cart.py b/src/tests/api/test_cart.py index 8ae9054c81..b2fff8cc6f 100644 --- a/src/tests/api/test_cart.py +++ b/src/tests/api/test_cart.py @@ -784,3 +784,107 @@ def test_cartpos_create_unseated(token_client, organizer, event, item, quota, se ) assert resp.status_code == 400 assert resp.data == ['The specified product does not allow to choose a seat.'] + + +@pytest.mark.django_db +def test_cartpos_create_bulk_simple(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD) + res['item'] = item.pk + 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() == 2 + cp1 = CartPosition.objects.get(pk=resp.data['results'][0]['data']['id']) + cp2 = CartPosition.objects.get(pk=resp.data['results'][1]['data']['id']) + assert cp1.price == Decimal('23.00') + assert cp2.price == Decimal('23.00') + + +@pytest.mark.django_db +def test_cartpos_create_bulk_partial_validation_failure(token_client, organizer, event, item, quota, question): + res1 = copy.deepcopy(CARTPOS_CREATE_PAYLOAD) + res1['item'] = item.pk + res2 = copy.deepcopy(CARTPOS_CREATE_PAYLOAD) + res2['item'] = -1 + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/cartpositions/bulk_create/'.format( + organizer.slug, event.slug + ), format='json', data=[ + res1, + res2 + ] + ) + assert resp.status_code == 200 + 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'] == {'item': ['Invalid pk "-1" - object does not exist.']} + + with scopes_disabled(): + assert CartPosition.objects.count() == 1 + cp1 = CartPosition.objects.get(pk=resp.data['results'][0]['data']['id']) + assert cp1.price == Decimal('23.00') + + +@pytest.mark.django_db +def test_cartpos_create_bulk_partial_quota_failure(token_client, organizer, event, item, quota, question): + quota.size = 1 + quota.save() + res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD) + res['item'] = item.pk + res['expires'] = (now() + datetime.timedelta(days=1)).isoformat() + 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'] + assert resp.data['results'][1]['errors'] == {'non_field_errors': ['There is not enough quota available on quota "Budget Quota" to perform the operation.']} + + with scopes_disabled(): + assert CartPosition.objects.count() == 1 + cp1 = CartPosition.objects.get(pk=resp.data['results'][0]['data']['id']) + assert cp1.price == Decimal('23.00') + + +@pytest.mark.django_db +def test_cartpos_create_bulk_partial_seat_failure(token_client, organizer, event, item, quota, question, seat): + res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD) + res['seat'] = seat.seat_guid + res['item'] = item.pk + res['expires'] = (now() + datetime.timedelta(days=1)).isoformat() + 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'] + assert resp.data['results'][1]['errors'] == {'non_field_errors': ['The selected seat "Seat A1" is not available.']} + + with scopes_disabled(): + assert CartPosition.objects.count() == 1 + cp1 = CartPosition.objects.get(pk=resp.data['results'][0]['data']['id']) + assert cp1.price == Decimal('23.00')