API: Support creating cart positions with vouchers (#2635)

This commit is contained in:
Raphael Michel
2022-05-10 12:19:04 +02:00
committed by GitHub
parent 25313bf044
commit 2fcd6bb3f5
5 changed files with 246 additions and 10 deletions

View File

@@ -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``

View File

@@ -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']

View File

@@ -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

View File

@@ -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

View File

@@ -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)