From 2fcd6bb3f52d77f5e00682e668d97dc62570bb84 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Tue, 10 May 2022 12:19:04 +0200 Subject: [PATCH] API: Support creating cart positions with vouchers (#2635) --- doc/api/resources/carts.rst | 3 +- src/pretix/api/serializers/cart.py | 48 ++++++-- src/pretix/api/views/voucher.py | 3 +- src/tests/api/test_cart.py | 177 +++++++++++++++++++++++++++++ src/tests/api/test_order_create.py | 25 ++++ 5 files changed, 246 insertions(+), 10 deletions(-) diff --git a/doc/api/resources/carts.rst b/doc/api/resources/carts.rst index 73b202600..f1877c57c 100644 --- a/doc/api/resources/carts.rst +++ b/doc/api/resources/carts.rst @@ -172,8 +172,6 @@ Cart position endpoints * does not check or calculate prices but believes any prices you send - * does not support the redemption of vouchers - * does not prevent you from buying items that can only be bought with a voucher * does not support file upload questions @@ -191,6 +189,7 @@ Cart position endpoints * ``expires`` (optional) * ``includes_tax`` (optional, **deprecated**, do not use, will be removed) * ``sales_channel`` (optional) + * ``voucher`` (optional, expect a voucher code) * ``answers`` * ``question`` diff --git a/src/pretix/api/serializers/cart.py b/src/pretix/api/serializers/cart.py index b26cfd34c..b49acd3c4 100644 --- a/src/pretix/api/serializers/cart.py +++ b/src/pretix/api/serializers/cart.py @@ -23,6 +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.utils.timezone import now from django.utils.translation import gettext_lazy @@ -33,7 +34,7 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.order import ( AnswerCreateSerializer, AnswerSerializer, InlineSeatSerializer, ) -from pretix.base.models import Quota, Seat +from pretix.base.models import Quota, Seat, Voucher from pretix.base.models.orders import CartPosition @@ -61,11 +62,12 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer): 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') + 'subevent', 'expires', 'includes_tax', 'answers', 'seat', 'sales_channel', 'voucher') def create(self, validated_data): answers_data = validated_data.pop('answers') @@ -125,14 +127,46 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer): 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.') + 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 voucher.subevent_id != validated_data.get('subevent'): + 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'] diff --git a/src/pretix/api/views/voucher.py b/src/pretix/api/views/voucher.py index 059154ac0..bea1e07fc 100644 --- a/src/pretix/api/views/voucher.py +++ b/src/pretix/api/views/voucher.py @@ -25,7 +25,7 @@ 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, + BooleanFilter, CharFilter, DjangoFilterBackend, FilterSet, ) from django_scopes import scopes_disabled from rest_framework import status, viewsets @@ -40,6 +40,7 @@ from pretix.base.models import Voucher with scopes_disabled(): class VoucherFilter(FilterSet): active = BooleanFilter(method='filter_active') + code = CharFilter(lookup_expr='iexact') class Meta: model = Voucher diff --git a/src/tests/api/test_cart.py b/src/tests/api/test_cart.py index b2fff8cc6..0a98643a7 100644 --- a/src/tests/api/test_cart.py +++ b/src/tests/api/test_cart.py @@ -888,3 +888,180 @@ def test_cartpos_create_bulk_partial_seat_failure(token_client, organizer, event 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_with_voucher_by_code(token_client, organizer, event, item, quota, seat): + with scopes_disabled(): + voucher = event.vouchers.create(code="FOOBAR", seat=seat) + res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD) + res['item'] = item.pk + res['voucher'] = voucher.code + res['seat'] = seat.seat_guid + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/cartpositions/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + cp1 = CartPosition.objects.get(pk=resp.data['id']) + assert cp1.voucher == voucher + assert cp1.seat == seat + + +@pytest.mark.django_db +def test_cartpos_create_with_voucher_unknown(token_client, organizer, event, item, quota): + res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD) + res['item'] = item.pk + res['voucher'] = 'TEST' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/cartpositions/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == ['The specified voucher does not exist.'] + + +@pytest.mark.django_db +def test_cartpos_create_with_voucher_invalid_item(token_client, organizer, event, item, quota): + with scopes_disabled(): + item2 = event.items.create(name="item2") + voucher = event.vouchers.create(code="FOOBAR", item=item2) + res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD) + res['item'] = item.pk + res['voucher'] = voucher.code + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/cartpositions/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == ['The specified voucher is not valid for the given item and variation.'] + + +@pytest.mark.django_db +def test_cartpos_create_with_voucher_invalid_seat(token_client, organizer, event, item, quota, seat): + with scopes_disabled(): + seat2 = event.seats.create(seat_number="A2", product=item, seat_guid="A2") + voucher = event.vouchers.create(code="FOOBAR", item=item, seat=seat2) + res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD) + res['item'] = item.pk + res['voucher'] = voucher.code + res['seat'] = seat.seat_guid + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/cartpositions/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == ['The specified voucher is not valid for this seat.'] + + +@pytest.mark.django_db +def test_cartpos_create_with_voucher_invalid_subevent(token_client, organizer, event, item, quota, subevent): + with scopes_disabled(): + voucher = event.vouchers.create(code="FOOBAR", item=item, subevent=subevent) + quota.subevent = subevent + quota.save() + res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD) + res['item'] = item.pk + res['voucher'] = voucher.code + res['subevent'] = subevent.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/cartpositions/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == ['The specified voucher is not valid for this subevent.'] + + +@pytest.mark.django_db +def test_cartpos_create_with_voucher_expired(token_client, organizer, event, item, quota): + with scopes_disabled(): + voucher = event.vouchers.create(code="FOOBAR", item=item, valid_until=now() - datetime.timedelta(days=1)) + res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD) + res['item'] = item.pk + res['voucher'] = voucher.code + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/cartpositions/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == ['The specified voucher is expired.'] + + +@pytest.mark.django_db +def test_cartpos_create_with_voucher_redeemed(token_client, organizer, event, item, quota): + with scopes_disabled(): + voucher = event.vouchers.create(code="FOOBAR", item=item, redeemed=1) + res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD) + res['item'] = item.pk + res['voucher'] = voucher.code + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/cartpositions/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == ['The specified voucher has already been used the maximum number of times.'] + + +@pytest.mark.django_db +def test_cartpos_create_bulk_with_voucher(token_client, organizer, event, item, quota): + with scopes_disabled(): + voucher = event.vouchers.create(code="FOOBAR", item=item, max_usages=3, redeemed=1) + res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD) + res['item'] = item.pk + res['expires'] = (now() + datetime.timedelta(days=1)).isoformat() + res['voucher'] = voucher.code + 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.voucher == voucher + assert cp2.voucher == voucher + + +@pytest.mark.django_db +def test_cartpos_create_bulk_with_voucher_redeemed(token_client, organizer, event, item, quota): + with scopes_disabled(): + voucher = event.vouchers.create(code="FOOBAR", item=item, max_usages=3, redeemed=2) + res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD) + res['item'] = item.pk + res['expires'] = (now() + datetime.timedelta(days=1)).isoformat() + res['voucher'] = voucher.code + 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 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 diff --git a/src/tests/api/test_order_create.py b/src/tests/api/test_order_create.py index 420f58733..1bb7e0f0c 100644 --- a/src/tests/api/test_order_create.py +++ b/src/tests/api/test_order_create.py @@ -2026,6 +2026,31 @@ def test_order_create_with_seat_consumed_from_cart(token_client, organizer, even assert p.seat == seat +@pytest.mark.django_db +def test_order_create_with_voucher_consumed_from_cart(token_client, organizer, event, item, quota, question): + with scopes_disabled(): + voucher = event.vouchers.create(code="FOOBAR", item=item, max_usages=3, redeemed=2) + CartPosition.objects.create( + event=event, cart_id='aaa', item=item, voucher=voucher, + price=21.5, expires=now() + datetime.timedelta(minutes=10), + ) + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['voucher'] = voucher.code + res['positions'][0]['answers'][0]['question'] = question.pk + res['consume_carts'] = ['aaa'] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + p = o.positions.first() + assert p.voucher == voucher + + @pytest.mark.django_db def test_order_create_send_no_emails(token_client, organizer, event, item, quota, question): res = copy.deepcopy(ORDER_CREATE_PAYLOAD)