diff --git a/doc/api/resources/vouchers.rst b/doc/api/resources/vouchers.rst index 44a673ea05..cd51e1ebed 100644 --- a/doc/api/resources/vouchers.rst +++ b/doc/api/resources/vouchers.rst @@ -231,6 +231,76 @@ Endpoints :statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource. :statuscode 409: The server was unable to acquire a lock and could not process your request. You can try again after a short waiting period. +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/vouchers/batch_create/ + + Creates multiple new vouchers atomically. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/vouchers/batch_create/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + Content-Length: 408 + + [ + { + "code": "43K6LKM37FBVR2YG", + "max_usages": 1, + "valid_until": null, + "block_quota": false, + "allow_ignore_quota": false, + "price_mode": "set", + "value": "12.00", + "item": 1, + "variation": null, + "quota": null, + "tag": "testvoucher", + "comment": "", + "subevent": null + }, + { + "code": "ASDKLJCYXCASDASD", + "max_usages": 1, + "valid_until": null, + "block_quota": false, + "allow_ignore_quota": false, + "price_mode": "set", + "value": "12.00", + "item": 1, + "variation": null, + "quota": null, + "tag": "testvoucher", + "comment": "", + "subevent": null + }, + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 Created + Vary: Accept + Content-Type: application/json + + [ + { + "id": 1, + "code": "43K6LKM37FBVR2YG", + … + }, … + } + + :param organizer: The ``slug`` field of the organizer to create a vouchers for + :param event: The ``slug`` field of the event to create a vouchers for + :statuscode 201: no error + :statuscode 400: The vouchers could not be created due to invalid submitted data. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource. + :statuscode 409: The server was unable to acquire a lock and could not process your request. You can try again after a short waiting period. + .. http:patch:: /api/v1/organizers/(organizer)/events/(event)/vouchers/(id)/ Update a voucher. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of diff --git a/src/pretix/api/serializers/voucher.py b/src/pretix/api/serializers/voucher.py index 5a8f4c0e30..b3e9e3bfb7 100644 --- a/src/pretix/api/serializers/voucher.py +++ b/src/pretix/api/serializers/voucher.py @@ -1,7 +1,27 @@ +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.base.models import Voucher +class VoucherListSerializer(serializers.ListSerializer): + def create(self, validated_data): + codes = set() + errs = [] + err = False + for voucher_data in validated_data: + if voucher_data['code'] in codes: + err = True + errs.append({'code': ['Duplicate voucher code in request.']}) + else: + codes.add(voucher_data['code']) + errs.append({}) + if err: + raise ValidationError(errs) + return super().create(validated_data) + + class VoucherSerializer(I18nAwareModelSerializer): class Meta: model = Voucher @@ -9,6 +29,7 @@ class VoucherSerializer(I18nAwareModelSerializer): 'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota', 'tag', 'comment', 'subevent') read_only_fields = ('id', 'redeemed') + list_serializer_class = VoucherListSerializer def validate(self, data): data = super().validate(data) diff --git a/src/pretix/api/views/voucher.py b/src/pretix/api/views/voucher.py index 9729751582..f4b62b7813 100644 --- a/src/pretix/api/views/voucher.py +++ b/src/pretix/api/views/voucher.py @@ -1,11 +1,12 @@ import contextlib + from django.db import transaction from django.db.models import F, Q from django.utils.timezone import now from django_filters.rest_framework import ( BooleanFilter, DjangoFilterBackend, FilterSet, ) -from rest_framework import viewsets, status +from rest_framework import status, viewsets from rest_framework.decorators import list_route from rest_framework.exceptions import PermissionDenied from rest_framework.filters import OrderingFilter @@ -56,7 +57,7 @@ class VoucherViewSet(viewsets.ModelViewSet): return False if 'block_quota' in data and not data.get('block_quota'): - return False + return False if instance and 'block_quota' not in data and not instance.block_quota: return False @@ -111,3 +112,24 @@ class VoucherViewSet(viewsets.ModelViewSet): auth=self.request.auth, ) super().perform_destroy(instance) + + @list_route(methods=['POST']) + def batch_create(self, request, *args, **kwargs): + if any(self._predict_quota_check(d, None) for d in request.data): + lockfn = request.event.lock + else: + lockfn = contextlib.suppress # noop context manager + with lockfn(): + serializer = self.get_serializer(data=request.data, many=True) + serializer.is_valid(raise_exception=True) + with transaction.atomic(): + serializer.save(event=self.request.event) + for i in serializer.instance: + i.log_action( + 'pretix.voucher.added', + user=self.request.user, + auth=self.request.auth, + data=self.request.data + ) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) diff --git a/src/tests/api/test_vouchers.py b/src/tests/api/test_vouchers.py index e97de65014..8707e41872 100644 --- a/src/tests/api/test_vouchers.py +++ b/src/tests/api/test_vouchers.py @@ -879,3 +879,134 @@ def test_redeemed_is_not_writable(token_client, organizer, event, item): }, ) assert v.redeemed == 0 + + +@pytest.mark.django_db +def test_create_multiple_vouchers(token_client, organizer, event, item): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/vouchers/batch_create/'.format(organizer.slug, event.slug), + data=[ + { + 'code': 'ABCDEFGHI', + 'max_usages': 1, + 'valid_until': None, + 'block_quota': False, + 'allow_ignore_quota': False, + 'price_mode': 'set', + 'value': '12.00', + 'item': item.pk, + 'variation': None, + 'quota': None, + 'tag': 'Foo', + 'comment': '', + 'subevent': None + }, + { + 'code': 'JKLMNOPQR', + 'max_usages': 1, + 'valid_until': None, + 'block_quota': True, + 'allow_ignore_quota': False, + 'price_mode': 'set', + 'value': '12.00', + 'item': item.pk, + 'variation': None, + 'quota': None, + 'tag': 'Foo', + 'comment': '', + 'subevent': None + } + ], format='json' + ) + assert resp.status_code == 201 + assert Voucher.objects.count() == 2 + assert resp.data[0]['code'] == 'ABCDEFGHI' + v1 = Voucher.objects.get(code='ABCDEFGHI') + assert not v1.block_quota + assert resp.data[1]['code'] == 'JKLMNOPQR' + v2 = Voucher.objects.get(code='JKLMNOPQR') + assert v2.block_quota + + +@pytest.mark.django_db +def test_create_multiple_vouchers_one_invalid(token_client, organizer, event, item): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/vouchers/batch_create/'.format(organizer.slug, event.slug), + data=[ + { + 'code': 'ABCDEFGHI', + 'max_usages': 1, + 'valid_until': None, + 'block_quota': False, + 'allow_ignore_quota': False, + 'price_mode': 'set', + 'value': '12.00', + 'item': item.pk, + 'variation': None, + 'quota': None, + 'tag': 'Foo', + 'comment': '', + 'subevent': None + }, + { + 'code': 'J', + 'max_usages': 1, + 'valid_until': None, + 'block_quota': True, + 'allow_ignore_quota': False, + 'price_mode': 'set', + 'value': '12.00', + 'item': item.pk, + 'variation': None, + 'quota': None, + 'tag': 'Foo', + 'comment': '', + 'subevent': None + } + ], format='json' + ) + assert resp.status_code == 400 + assert resp.data == [{}, {'code': ['Ensure this field has at least 5 characters.']}] + assert Voucher.objects.count() == 0 + + +@pytest.mark.django_db +def test_create_multiple_vouchers_duplicate_code(token_client, organizer, event, item): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/vouchers/batch_create/'.format(organizer.slug, event.slug), + data=[ + { + 'code': 'ABCDEFGHI', + 'max_usages': 1, + 'valid_until': None, + 'block_quota': False, + 'allow_ignore_quota': False, + 'price_mode': 'set', + 'value': '12.00', + 'item': item.pk, + 'variation': None, + 'quota': None, + 'tag': 'Foo', + 'comment': '', + 'subevent': None + }, + { + 'code': 'ABCDEFGHI', + 'max_usages': 1, + 'valid_until': None, + 'block_quota': True, + 'allow_ignore_quota': False, + 'price_mode': 'set', + 'value': '12.00', + 'item': item.pk, + 'variation': None, + 'quota': None, + 'tag': 'Foo', + 'comment': '', + 'subevent': None + } + ], format='json' + ) + assert resp.status_code == 400 + assert resp.data == [{}, {'code': ['Duplicate voucher code in request.']}] + assert Voucher.objects.count() == 0