Order creation API: Allow to pass vouchers

This commit is contained in:
Raphael Michel
2019-08-14 09:22:58 +02:00
parent bc9d8f5bd8
commit 5cda04a994
3 changed files with 236 additions and 24 deletions
+3 -4
View File
@@ -724,15 +724,13 @@ Creating orders
* does not validate the number of items per order or the number of times an item can be included in an order
* does not validate any requirements related to add-on products
* does not validate any requirements related to add-on products and does not add bundled products automatically
* does not check 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 calculate fees
* does not calculate fees automatically
* does not allow to pass data to plugins and will therefore cause issues with some plugins like the shipping
module
@@ -788,6 +786,7 @@ Creating orders
* ``price`` (optional, if set to ``null`` or missing the price will be computed from the given product)
* ``seat`` (The ``seat_guid`` attribute of a seat. Required when the specified ``item`` requires a seat, otherwise must be ``null``.)
* ``attendee_name`` **or** ``attendee_name_parts``
* ``voucher`` (optional, the ``code`` attribute of a valid voucher)
* ``attendee_email``
* ``secret`` (optional)
* ``addon_to`` (optional, see below)
+63 -19
View File
@@ -1,7 +1,9 @@
import json
from collections import Counter
from decimal import Decimal
import pycountry
from django.db.models import F, Q
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy
from django_countries.fields import Country
@@ -15,12 +17,13 @@ from pretix.base.channels import get_all_sales_channels
from pretix.base.i18n import language
from pretix.base.models import (
Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order,
OrderPosition, Question, QuestionAnswer, Seat, SubEvent,
OrderPosition, Question, QuestionAnswer, Seat, SubEvent, Voucher,
)
from pretix.base.models.orders import (
CartPosition, OrderFee, OrderPayment, OrderRefund,
)
from pretix.base.pdf import get_variables
from pretix.base.services.cart import error_messages
from pretix.base.services.pricing import get_price
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
from pretix.base.signals import register_ticket_outputs
@@ -460,11 +463,13 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
seat = serializers.CharField(required=False, allow_null=True)
price = serializers.DecimalField(required=False, allow_null=True, decimal_places=2,
max_digits=10)
voucher = serializers.SlugRelatedField(slug_field='code', queryset=Voucher.objects.none(),
required=False, allow_null=True)
class Meta:
model = OrderPosition
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'secret', 'addon_to', 'subevent', 'answers', 'seat')
'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher')
def validate_secret(self, secret):
if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists():
@@ -557,6 +562,10 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
payment_date = serializers.DateTimeField(required=False, allow_null=True)
send_mail = serializers.BooleanField(default=False, required=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['positions'].child.fields['voucher'].queryset = self.context['event'].vouchers.all()
class Meta:
model = Order
fields = ('code', 'status', 'testmode', 'email', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
@@ -656,6 +665,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
consume_carts = validated_data.pop('consume_carts', [])
delete_cps = []
quota_avail_cache = {}
voucher_usage = Counter()
if consume_carts:
for cp in CartPosition.objects.filter(
event=self.context['event'], cart_id__in=consume_carts, expires__gt=now()
@@ -667,6 +677,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
quota_avail_cache[quota] = list(quota.availability())
if quota_avail_cache[quota][1] is not None:
quota_avail_cache[quota][1] += 1
if cp.voucher:
voucher_usage[cp.voucher] -= 1
if cp.expires > now_dt:
if cp.seat:
free_seats.add(cp.seat)
@@ -674,8 +686,55 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
errs = [{} for p in positions_data]
for i, pos_data in enumerate(positions_data):
if pos_data.get('voucher'):
v = pos_data['voucher']
if not v.applies_to(pos_data['item'], pos_data.get('variation')):
errs[i]['voucher'] = [error_messages['voucher_invalid_item']]
continue
if v.subevent_id and pos_data.get('subevent').pk != v.subevent_id:
errs[i]['voucher'] = [error_messages['voucher_invalid_subevent']]
continue
if v.valid_until is not None and v.valid_until < now_dt:
errs[i]['voucher'] = [error_messages['voucher_expired']]
continue
voucher_usage[v] += 1
if voucher_usage[v] > 0:
redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=pos_data['voucher']) & Q(event=self.context['event']) & Q(expires__gte=now_dt)
).exclude(pk__in=[cp.pk for cp in delete_cps])
v_avail = v.max_usages - v.redeemed - redeemed_in_carts.count()
if v_avail < voucher_usage[v]:
errs[i]['voucher'] = [
'The voucher has already been used the maximum number of times.'
]
seated = pos_data.get('item').seat_category_mappings.filter(subevent=pos_data.get('subevent')).exists()
if pos_data.get('seat'):
if not seated:
errs[i]['seat'] = ['The specified product does not allow to choose a seat.']
try:
seat = self.context['event'].seats.get(seat_guid=pos_data['seat'], subevent=pos_data.get('subevent'))
except Seat.DoesNotExist:
errs[i]['seat'] = ['The specified seat does not exist.']
else:
pos_data['seat'] = seat
if (seat not in free_seats and not seat.is_available()) or seat in seats_seen:
errs[i]['seat'] = [ugettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)]
seats_seen.add(seat)
elif seated:
errs[i]['seat'] = ['The specified product requires to choose a seat.']
if not force:
for i, pos_data in enumerate(positions_data):
if pos_data.get('voucher'):
if pos_data['voucher'].allow_ignore_quota or pos_data['voucher'].block_quota:
continue
new_quotas = (pos_data.get('variation').quotas.filter(subevent=pos_data.get('subevent'))
if pos_data.get('variation')
else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent')))
@@ -697,23 +756,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
)
]
for i, pos_data in enumerate(positions_data):
seated = pos_data.get('item').seat_category_mappings.filter(subevent=pos_data.get('subevent')).exists()
if pos_data.get('seat'):
if not seated:
errs[i]['seat'] = ['The specified product does not allow to choose a seat.']
try:
seat = self.context['event'].seats.get(seat_guid=pos_data['seat'], subevent=pos_data.get('subevent'))
except Seat.DoesNotExist:
errs[i]['seat'] = ['The specified seat does not exist.']
else:
pos_data['seat'] = seat
if (seat not in free_seats and not seat.is_available()) or seat in seats_seen:
errs[i]['seat'] = [ugettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)]
seats_seen.add(seat)
elif seated:
errs[i]['seat'] = ['The specified product requires to choose a seat.']
if any(errs):
raise ValidationError({'positions': errs})
@@ -759,6 +801,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
pos.tax_rule = pos.item.tax_rule
else:
pos._calculate_tax()
if pos.voucher:
Voucher.objects.filter(pk=pos.voucher.pk).update(redeemed=F('redeemed') + 1)
pos.save()
pos_map[pos.positionid] = pos
for answ_data in answers_data:
+170 -1
View File
@@ -2857,8 +2857,8 @@ def test_order_create_send_emails_paid(token_client, organizer, event, item, quo
def test_order_create_auto_pricing(token_client, organizer, event, item, quota, question):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
del res['positions'][0]['price']
djmail.outbox = []
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
@@ -2872,6 +2872,175 @@ def test_order_create_auto_pricing(token_client, organizer, event, item, quota,
assert o.total == item.default_price + Decimal('0.25')
@pytest.mark.django_db
def test_order_create_voucher_price(token_client, organizer, event, item, quota, question):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
del res['positions'][0]['price']
with scopes_disabled():
voucher = event.vouchers.create(price_mode="set", value=15, item=item)
res['positions'][0]['voucher'] = voucher.code
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
voucher.refresh_from_db()
assert voucher.redeemed == 1
assert p.price == Decimal('15.00')
assert o.total == Decimal('15.25')
@pytest.mark.django_db
def test_order_create_voucher_unknown_code(token_client, organizer, event, item, quota, question):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
del res['positions'][0]['price']
with scopes_disabled():
event.vouchers.create(price_mode="set", value=15, item=item)
res['positions'][0]['voucher'] = "FOOBAR"
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {
'positions': [
{'voucher': ['Object with code=FOOBAR does not exist.']},
]
}
@pytest.mark.django_db
def test_order_create_voucher_redeemed(token_client, organizer, event, item, quota, question):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
del res['positions'][0]['price']
res['positions'][0]['answers'][0]['question'] = question.pk
with scopes_disabled():
voucher = event.vouchers.create(price_mode="set", value=15, item=item, redeemed=1)
res['positions'][0]['voucher'] = voucher.code
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {
'positions': [
{'voucher': ['The voucher has already been used the maximum number of times.']},
]
}
@pytest.mark.django_db
def test_order_create_voucher_redeemed_partially(token_client, organizer, event, item, quota, question):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['answers'][0]['question'] = question.pk
res['positions'][0]['item'] = item.pk
del res['positions'][0]['price']
del res['positions'][0]['positionid']
with scopes_disabled():
voucher = event.vouchers.create(price_mode="set", value=15, item=item, redeemed=1, max_usages=2)
res['positions'][0]['voucher'] = voucher.code
res['positions'].append(copy.copy(res['positions'][0]))
res['positions'].append(copy.copy(res['positions'][0]))
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {
'positions': [
{},
{'voucher': ['The voucher has already been used the maximum number of times.']},
{'voucher': ['The voucher has already been used the maximum number of times.']},
]
}
@pytest.mark.django_db
def test_order_create_voucher_item_mismatch(token_client, organizer, event, item, quota, question):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
del res['positions'][0]['price']
with scopes_disabled():
item2 = event.items.create(name="Budget Ticket", default_price=23)
voucher = event.vouchers.create(price_mode="set", value=15, item=item2, redeemed=0)
res['positions'][0]['voucher'] = voucher.code
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {
'positions': [
{'voucher': ['This voucher is not valid for this product.']},
]
}
@pytest.mark.django_db
def test_order_create_voucher_expired(token_client, organizer, event, item, quota, question):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
del res['positions'][0]['price']
with scopes_disabled():
voucher = event.vouchers.create(price_mode="set", value=15, item=item, redeemed=0,
valid_until=now() - datetime.timedelta(days=1))
res['positions'][0]['voucher'] = voucher.code
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {
'positions': [
{'voucher': ['This voucher is expired.']},
]
}
@pytest.mark.django_db
def test_order_create_voucher_block_quota(token_client, organizer, event, item, quota, question):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
del res['positions'][0]['price']
quota.size = 0
quota.save()
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
with scopes_disabled():
voucher = event.vouchers.create(price_mode="set", value=15, item=item, redeemed=0,
block_quota=True)
res['positions'][0]['voucher'] = voucher.code
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
REFUND_CREATE_PAYLOAD = {
"state": "created",
"provider": "manual",