API: New implementation for cart creation (#2833)

This commit is contained in:
Raphael Michel
2022-10-10 12:59:38 +02:00
committed by GitHub
parent 6e7af4c64b
commit 38969747f4
8 changed files with 746 additions and 240 deletions
+25 -8
View File
@@ -17,8 +17,8 @@ The cart position resource contains the following public fields:
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the cart position
cart_id string Identifier of the cart this belongs to. Needs to end
in "@api" for API-created positions.
cart_id string Identifier of the cart this belongs to, needs to end
in "@api" for API-created positions
datetime datetime Time of creation
expires datetime The cart position will expire at this time and no longer block quota
item integer ID of the item
@@ -29,14 +29,15 @@ attendee_name_parts object of strings Composition of
attendee_email string Specified attendee email address for this position (or ``null``)
voucher integer Internal ID of the voucher used for this position (or ``null``)
addon_to integer Internal ID of the position this position is an add-on for (or ``null``)
subevent integer ID of the date inside an event series this position belongs to (or ``null``).
is_bundled boolean If ``addon_to`` is set, this shows whether this is a bundled product or an addon product
subevent integer ID of the date inside an event series this position belongs to (or ``null``)
answers list of objects Answers to user-defined questions
├ question integer Internal ID of the answered question
├ answer string Text representation of the answer
├ question_identifier string The question's ``identifier`` field
├ options list of integers Internal IDs of selected option(s)s (only for choice types)
└ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
seat objects The assigned seat. Can be ``null``.
seat objects The assigned seat (or ``null``)
├ id integer Internal ID of the seat instance
├ name string Human-readable seat name
└ seat_guid string Identifier of the seat within the seating plan
@@ -46,6 +47,10 @@ seat objects The assigned se
This ``seat`` attribute has been added.
.. versionchanged:: 4.14
This ``is_bundled`` attribute has been added and the cart creation endpoints have been updated.
Cart position endpoints
-----------------------
@@ -87,6 +92,7 @@ Cart position endpoints
"attendee_email": null,
"voucher": null,
"addon_to": null,
"is_bundled": false,
"subevent": null,
"datetime": "2018-06-11T10:00:00Z",
"expires": "2018-06-11T10:00:00Z",
@@ -133,6 +139,7 @@ Cart position endpoints
"attendee_email": null,
"voucher": null,
"addon_to": null,
"is_bundled": false,
"subevent": null,
"datetime": "2018-06-11T10:00:00Z",
"expires": "2018-06-11T10:00:00Z",
@@ -168,7 +175,7 @@ Cart position endpoints
* does not validate if the event's ticket sales are already over or haven't started
* does not support add-on products at the moment
* does not validate constraints on add-on products at the moment
* does not check or calculate prices but believes any prices you send
@@ -176,6 +183,8 @@ Cart position endpoints
* does not support file upload questions
Note that more validation might be added in the future, so please do not rely on missing validation.
You can supply the following fields of the resource:
* ``cart_id`` (optional, needs to end in ``@api``)
@@ -190,6 +199,8 @@ Cart position endpoints
* ``includes_tax`` (optional, **deprecated**, do not use, will be removed)
* ``sales_channel`` (optional)
* ``voucher`` (optional, expect a voucher code)
* ``addons`` (optional, expect a list of nested objects of cart positions)
* ``bundled`` (optional, expect a list of nested objects of cart positions)
* ``answers``
* ``question``
@@ -221,6 +232,12 @@ Cart position endpoints
"options": []
}
],
"addons": [
{
"item": 2,
"variation": null,
}
],
"subevent": null
}
@@ -232,7 +249,7 @@ Cart position endpoints
Vary: Accept
Content-Type: application/json
(Full cart position resource, see above.)
(Full cart position resource, see above, with additional nested objects "addons" and "bundled".)
:param organizer: The ``slug`` field of the organizer of the event to create a position for
:param event: The ``slug`` field of the event to create a position for
@@ -244,8 +261,8 @@ Cart position endpoints
.. 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!
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.
+1 -1
View File
@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
__version__ = "4.14.0.dev0"
__version__ = "4.14.1.dev0"
+180 -137
View File
@@ -23,8 +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.db.models import prefetch_related_objects
from django.utils.timezone import now
from django.utils.translation import gettext_lazy
from rest_framework import serializers
@@ -34,7 +33,7 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import (
AnswerCreateSerializer, AnswerSerializer, InlineSeatSerializer,
)
from pretix.base.models import Quota, Seat, Voucher
from pretix.base.models import Seat, Voucher
from pretix.base.models.orders import CartPosition
@@ -52,148 +51,18 @@ class CartPositionSerializer(I18nAwareModelSerializer):
model = CartPosition
fields = ('id', 'cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'attendee_email', 'voucher', 'addon_to', 'subevent', 'datetime', 'expires', 'includes_tax',
'answers', 'seat')
'answers', 'seat', 'is_bundled')
class CartPositionCreateSerializer(I18nAwareModelSerializer):
class BaseCartPositionCreateSerializer(I18nAwareModelSerializer):
answers = AnswerCreateSerializer(many=True, required=False)
expires = serializers.DateTimeField(required=False)
attendee_name = serializers.CharField(required=False, allow_null=True)
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', 'voucher')
def create(self, validated_data):
answers_data = validated_data.pop('answers')
if not validated_data.get('cart_id'):
cid = "{}@api".format(get_random_string(48))
while CartPosition.objects.filter(cart_id=cid).exists():
cid = "{}@api".format(get_random_string(48))
validated_data['cart_id'] = cid
if not validated_data.get('expires'):
validated_data['expires'] = now() + timedelta(
minutes=self.context['event'].settings.get('reservation_time', as_type=int)
)
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('There is not enough quota available on quota "{}" to perform '
'the operation.').format(
quota.name
)
)
for quota in new_quotas:
oldsize = self.context['quota_cache'][quota.pk][1]
newsize = oldsize - 1 if oldsize is not None else None
self.context['quota_cache'][quota.pk] = (
Quota.AVAILABILITY_OK if newsize is None or newsize > 0 else Quota.AVAILABILITY_GONE,
newsize
)
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
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 (not validated_data.get('subevent') or voucher.subevent_id != validated_data['subevent'].pk):
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']
# todo: listed price, etc?
# currently does not matter because there is no way to transform an API cart position into an order that keeps
# prices, cart positions are just quota/voucher placeholders
validated_data['custom_price_input_is_net'] = not validated_data.pop('includes_tax', True)
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
for answ_data in answers_data:
options = answ_data.pop('options')
if isinstance(answ_data['answer'], File):
an = answ_data.pop('answer')
answ = cp.answers.create(**answ_data, answer='')
answ.file.save(os.path.basename(an.name), an, save=False)
answ.answer = 'file://' + answ.file.name
answ.save()
an.close()
else:
answ = cp.answers.create(**answ_data)
answ.options.add(*options)
return cp
def validate_cart_id(self, cid):
if cid and not cid.endswith('@api'):
raise ValidationError('Cart ID should end in @api or be empty.')
return cid
fields = ('item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'subevent', 'includes_tax', 'answers')
def validate_item(self, item):
if item.event != self.context['event']:
@@ -240,4 +109,178 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
raise ValidationError(
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
)
if not data.get('expires'):
data['expires'] = now() + timedelta(
minutes=self.context['event'].settings.get('reservation_time', as_type=int)
)
quotas_for_item_cache = self.context.get('quotas_for_item_cache', {})
quotas_for_variation_cache = self.context.get('quotas_for_variation_cache', {})
seated = data.get('item').seat_category_mappings.filter(subevent=data.get('subevent')).exists()
if data.get('seat'):
if not seated:
raise ValidationError({'seat': ['The specified product does not allow to choose a seat.']})
try:
seat = self.context['event'].seats.get(seat_guid=data['seat'], subevent=data.get('subevent'))
except Seat.DoesNotExist:
raise ValidationError({'seat': ['The specified seat does not exist.']})
except Seat.MultipleObjectsReturned:
raise ValidationError({'seat': ['The specified seat ID is not unique.']})
else:
data['seat'] = seat
elif seated:
raise ValidationError({'seat': ['The specified product requires to choose a seat.']})
if data.get('voucher'):
try:
voucher = self.context['event'].vouchers.get(code__iexact=data['voucher'])
except Voucher.DoesNotExist:
raise ValidationError({'voucher': ['The specified voucher does not exist.']})
if voucher and not voucher.applies_to(data['item'], data.get('variation')):
raise ValidationError({'voucher': ['The specified voucher is not valid for the given item and variation.']})
if voucher and voucher.seat and voucher.seat != data.get('seat'):
raise ValidationError({'voucher': ['The specified voucher is not valid for this seat.']})
if voucher and voucher.subevent_id and (not data.get('subevent') or voucher.subevent_id != data['subevent'].pk):
raise ValidationError({'voucher': ['The specified voucher is not valid for this subevent.']})
if voucher.valid_until is not None and voucher.valid_until < now():
raise ValidationError({'voucher': ['The specified voucher is expired.']})
data['voucher'] = voucher
if not data.get('voucher') or (not data['voucher'].allow_ignore_quota and not data['voucher'].block_quota):
if data.get('variation'):
if data['variation'].pk not in quotas_for_variation_cache:
quotas_for_variation_cache[data['variation'].pk] = data['variation'].quotas.filter(subevent=data.get('subevent'))
data['_quotas'] = quotas_for_variation_cache[data['variation'].pk]
else:
if data['item'].pk not in quotas_for_item_cache:
quotas_for_item_cache[data['item'].pk] = data['item'].quotas.filter(subevent=data.get('subevent'))
data['_quotas'] = quotas_for_item_cache[data['item'].pk]
if len(data['_quotas']) == 0:
raise ValidationError(
gettext_lazy('The product "{}" is not assigned to a quota.').format(
str(data.get('item'))
)
)
else:
data['_quotas'] = []
return data
def create(self, validated_data):
validated_data.pop('_quotas')
answers_data = validated_data.pop('answers')
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
}
# todo: does this make sense?
validated_data['custom_price_input'] = validated_data['price']
# todo: listed price, etc?
# currently does not matter because there is no way to transform an API cart position into an order that keeps
# prices, cart positions are just quota/voucher placeholders
validated_data['custom_price_input_is_net'] = not validated_data.pop('includes_tax', True)
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
for answ_data in answers_data:
options = answ_data.pop('options')
if isinstance(answ_data['answer'], File):
an = answ_data.pop('answer')
answ = cp.answers.create(**answ_data, answer='')
answ.file.save(os.path.basename(an.name), an, save=False)
answ.answer = 'file://' + answ.file.name
answ.save()
an.close()
else:
answ = cp.answers.create(**answ_data)
answ.options.add(*options)
return cp
class CartPositionCreateSerializer(BaseCartPositionCreateSerializer):
expires = serializers.DateTimeField(required=False)
addons = BaseCartPositionCreateSerializer(many=True, required=False)
bundled = BaseCartPositionCreateSerializer(many=True, required=False)
seat = serializers.CharField(required=False, allow_null=True)
sales_channel = serializers.CharField(required=False, default='sales_channel')
voucher = serializers.CharField(required=False, allow_null=True)
class Meta:
model = CartPosition
fields = BaseCartPositionCreateSerializer.Meta.fields + (
'cart_id', 'expires', 'addons', 'bundled', 'seat', 'sales_channel', 'voucher'
)
def validate_cart_id(self, cid):
if cid and not cid.endswith('@api'):
raise ValidationError('Cart ID should end in @api or be empty.')
return cid
def create(self, validated_data):
validated_data.pop('sales_channel')
addons_data = validated_data.pop('addons', None)
bundled_data = validated_data.pop('bundled', None)
cp = super().create(validated_data)
if addons_data:
for addon_data in addons_data:
addon_data['addon_to'] = cp
addon_data['is_bundled'] = False
super().create(addon_data)
if bundled_data:
for bundle_data in bundled_data:
bundle_data['addon_to'] = cp
bundle_data['is_bundled'] = True
super().create(bundle_data)
return cp
def validate(self, data):
data = super().validate(data)
# This is currently only a very basic validation of add-ons and bundled products, we don't validate their number
# or price. We can always go stricter, as the endpoint is documented as experimental.
# However, this serializer should always be *at least* as strict as the order creation serializer.
if data.get('item') and data.get('addons'):
prefetch_related_objects([data['item']], 'addons')
for sub_data in data['addons']:
if not any(a.addon_category_id == sub_data['item'].category_id for a in data['item'].addons.all()):
raise ValidationError({
'addons': [
'The product "{prod}" can not be used as an add-on product for "{main}".'.format(
prod=str(sub_data['item']),
main=str(data['item']),
)
]
})
if data.get('item') and data.get('bundled'):
prefetch_related_objects([data['item']], 'bundles')
for sub_data in data['bundled']:
if not any(
a.bundled_item_id == sub_data['item'].pk and
a.bundled_variation_id == (sub_data['variation'].pk if sub_data.get('variation') else None)
for a in data['item'].bundles.all()
):
raise ValidationError({
'bundled': [
'The product "{prod}" can not be used as an bundled product for "{main}".'.format(
prod=str(sub_data['item']),
main=str(data['item']),
)
]
})
return data
+4
View File
@@ -1086,6 +1086,10 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
seated = pos_data.get('item').seat_category_mappings.filter(subevent=pos_data.get('subevent')).exists()
if pos_data.get('seat'):
if pos_data.get('addon_to'):
errs[i]['seat'] = ['Seats are currently not supported for add-on products.']
continue
if not seated:
errs[i]['seat'] = ['The specified product does not allow to choose a seat.']
try:
+167 -43
View File
@@ -19,19 +19,28 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from collections import Counter
from typing import List
from django.db import transaction
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.translation import gettext as _
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 rest_framework.serializers import as_serializer_error
from pretix.api.serializers.cart import (
CartPositionCreateSerializer, CartPositionSerializer,
)
from pretix.base.models import CartPosition
from pretix.base.services.cart import (
_get_quota_availability, _get_voucher_availability, error_messages,
)
from pretix.base.services.locking import NoLockManager
@@ -54,18 +63,17 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['quota_cache'] = {}
ctx['quotas_for_item_cache'] = {}
ctx['quotas_for_variation_cache'] = {}
return ctx
def create(self, request, *args, **kwargs):
serializer = CartPositionCreateSerializer(data=request.data, context=self.get_serializer_context())
ctx = self.get_serializer_context()
serializer = CartPositionCreateSerializer(data=request.data, context=ctx)
serializer.is_valid(raise_exception=True)
with transaction.atomic(), self.request.event.lock():
self.perform_create(serializer)
cp = serializer.instance
serializer = CartPositionSerializer(cp, context=serializer.context)
results = self._create(serializers=[serializer], raise_exception=True, ctx=ctx)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
return Response(results[0]['data'], status=status.HTTP_201_CREATED, headers=headers)
@action(detail=False, methods=['POST'])
def bulk_create(self, request, *args, **kwargs):
@@ -73,42 +81,158 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
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,
})
serializers = [
CartPositionCreateSerializer(data=d, context=ctx)
for d in request.data
]
results = self._create(serializers=serializers, raise_exception=False, ctx=ctx)
return Response({'results': results}, status=status.HTTP_200_OK)
def perform_create(self, serializer):
serializer.save()
raise NotImplementedError()
def _require_locking(self, quota_diff, voucher_use_diff, seat_diff):
if voucher_use_diff or seat_diff:
# If any vouchers or seats are used, we lock to make sure we don't redeem them to often
return True
if quota_diff and any(q.size is not None for q in quota_diff):
# If any quotas are affected that are not unlimited, we lock
return True
return False
@cached_property
def _create_default_cart_id(self):
cid = "{}@api".format(get_random_string(48))
while CartPosition.objects.filter(cart_id=cid).exists():
cid = "{}@api".format(get_random_string(48))
return cid
def _create(self, serializers: List[CartPositionCreateSerializer], ctx, raise_exception=False):
voucher_use_diff = Counter()
quota_diff = Counter()
seat_diff = Counter()
results = [{} for pserializer in serializers]
for i, pserializer in enumerate(serializers):
if not pserializer.is_valid(raise_exception=raise_exception):
results[i] = {
'success': False,
'data': None,
'errors': pserializer.errors,
}
for pserializer in serializers:
if pserializer.errors:
continue
validated_data = pserializer.validated_data
if not validated_data.get('cart_id'):
validated_data['cart_id'] = self._create_default_cart_id
if validated_data.get('voucher'):
voucher_use_diff[validated_data['voucher']] += 1
if validated_data.get('seat'):
seat_diff[validated_data['seat']] += 1
for q in validated_data['_quotas']:
quota_diff[q] += 1
for sub_data in validated_data.get('addons', []) + validated_data.get('bundled', []):
for q in sub_data['_quotas']:
quota_diff[q] += 1
seats_seen = set()
lockfn = NoLockManager
if self._require_locking(quota_diff, voucher_use_diff, seat_diff):
lockfn = self.request.event.lock
with lockfn() as now_dt, transaction.atomic():
vouchers_ok, vouchers_depend_on_cart = _get_voucher_availability(
self.request.event,
voucher_use_diff,
now_dt,
exclude_position_ids=[],
)
quotas_ok = _get_quota_availability(quota_diff, now_dt)
for i, pserializer in enumerate(serializers):
if results[i]:
continue
try:
validated_data = pserializer.validated_data
if validated_data.get('seat'):
# Assumption: Add-ons currently can't have seats
if validated_data['seat'] in seats_seen:
raise ValidationError(error_messages['seat_multiple'])
seats_seen.add(validated_data['seat'])
quotas_needed = Counter()
for q in validated_data['_quotas']:
quotas_needed[q] += 1
for sub_data in validated_data.get('addons', []) + validated_data.get('bundled', []):
for q in sub_data['_quotas']:
quotas_needed[q] += 1
for q, needed in quotas_needed.items():
if quotas_ok[q] < needed:
raise ValidationError(
_('There is not enough quota available on quota "{}" to perform the operation.').format(
q.name
)
)
if validated_data.get('voucher'):
# Assumption: Add-ons currently can't have vouchers, thus we only need to check the main voucher
if vouchers_ok[validated_data['voucher']] < 1:
raise ValidationError(
{'voucher': [_('The specified voucher has already been used the maximum number of times.')]}
)
if validated_data.get('seat'):
# Assumption: Add-ons currently can't have seats, thus we only need to check the main product
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(
{'seat': [_('The selected seat "{seat}" is not available.').format(seat=validated_data['seat'].name)]}
)
for q, needed in quotas_needed.items():
quotas_ok[q] -= needed
if validated_data.get('voucher'):
vouchers_ok[validated_data['voucher']] -= 1
if any(qa < 0 for qa in quotas_ok.values()):
# Safeguard, should never happen because of conditions above
raise ValidationError(error_messages['unavailable'])
cp = pserializer.create(validated_data)
d = CartPositionSerializer(cp, context=ctx).data
addons = sorted(cp.addons.all(), key=lambda a: a.pk) # order of creation, safe since they are created in the same transaction
d['addons'] = CartPositionSerializer([a for a in addons if not a.is_bundled], many=True, context=ctx).data
d['bundled'] = CartPositionSerializer([a for a in addons if a.is_bundled], many=True, context=ctx).data
results[i] = {
'success': True,
'data': d,
'errors': None,
}
except ValidationError as e:
if raise_exception:
raise
results[i] = {
'success': False,
'data': None,
'errors': as_serializer_error(e),
}
return results
+45 -36
View File
@@ -147,6 +147,45 @@ error_messages = {
}
def _get_quota_availability(quota_diff, now_dt):
quotas_ok = defaultdict(int)
qa = QuotaAvailability()
qa.queue(*[k for k, v in quota_diff.items() if v > 0])
qa.compute(now_dt=now_dt)
for quota, count in quota_diff.items():
if count <= 0:
quotas_ok[quota] = 0
break
avail = qa.results[quota]
if avail[1] is not None and avail[1] < count:
quotas_ok[quota] = min(count, avail[1])
else:
quotas_ok[quota] = count
return quotas_ok
def _get_voucher_availability(event, voucher_use_diff, now_dt, exclude_position_ids):
vouchers_ok = {}
_voucher_depend_on_cart = set()
for voucher, count in voucher_use_diff.items():
voucher.refresh_from_db()
if voucher.valid_until is not None and voucher.valid_until < now_dt:
raise CartError(error_messages['voucher_expired'])
redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=voucher) & Q(event=event) &
Q(expires__gte=now_dt)
).exclude(pk__in=exclude_position_ids)
cart_count = redeemed_in_carts.count()
v_avail = voucher.max_usages - voucher.redeemed - cart_count
if cart_count > 0:
_voucher_depend_on_cart.add(voucher)
vouchers_ok[voucher] = v_avail
return vouchers_ok, _voucher_depend_on_cart
class CartManager:
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'voucher', 'quotas',
'addon_to', 'subevent', 'bundled', 'seat', 'listed_price',
@@ -819,43 +858,13 @@ class CartManager:
self._quota_diff.update(quota_diff)
self._operations += operations
def _get_quota_availability(self):
quotas_ok = defaultdict(int)
qa = QuotaAvailability()
qa.queue(*[k for k, v in self._quota_diff.items() if v > 0])
qa.compute(now_dt=self.now_dt)
for quota, count in self._quota_diff.items():
if count <= 0:
quotas_ok[quota] = 0
break
avail = qa.results[quota]
if avail[1] is not None and avail[1] < count:
quotas_ok[quota] = min(count, avail[1])
else:
quotas_ok[quota] = count
return quotas_ok
def _get_voucher_availability(self):
vouchers_ok = {}
self._voucher_depend_on_cart = set()
for voucher, count in self._voucher_use_diff.items():
voucher.refresh_from_db()
if voucher.valid_until is not None and voucher.valid_until < self.now_dt:
raise CartError(error_messages['voucher_expired'])
redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=voucher) & Q(event=self.event) &
Q(expires__gte=self.now_dt)
).exclude(pk__in=[
vouchers_ok, self._voucher_depend_on_cart = _get_voucher_availability(
self.event, self._voucher_use_diff, self.now_dt,
exclude_position_ids=[
op.position.id for op in self._operations if isinstance(op, self.ExtendOperation)
])
cart_count = redeemed_in_carts.count()
v_avail = voucher.max_usages - voucher.redeemed - cart_count
if cart_count > 0:
self._voucher_depend_on_cart.add(voucher)
vouchers_ok[voucher] = v_avail
]
)
return vouchers_ok
def _check_min_max_per_product(self):
@@ -908,7 +917,7 @@ class CartManager:
def _perform_operations(self):
vouchers_ok = self._get_voucher_availability()
quotas_ok = self._get_quota_availability()
quotas_ok = _get_quota_availability(self._quota_diff, self.now_dt)
err = None
new_cart_positions = []
+323 -14
View File
@@ -82,6 +82,7 @@ TEST_CARTPOSITION_RES = {
'attendee_email': None,
'voucher': None,
'addon_to': None,
'is_bundled': False,
'subevent': None,
'datetime': '2018-06-11T10:00:00Z',
'expires': '2018-06-11T10:00:00Z',
@@ -352,7 +353,7 @@ def test_cartpos_create_item_validation(token_client, organizer, event, item, it
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == ['The product "Budget Ticket" is not assigned to a quota.']
assert resp.data == {'non_field_errors': ['The product "Budget Ticket" is not assigned to a quota.']}
quota.variations.add(var1)
resp = token_client.post(
@@ -704,7 +705,7 @@ def test_cartpos_create_with_blocked_seat(token_client, organizer, event, item,
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == ['The selected seat "Seat A1" is not available.']
assert resp.data == {'seat': ['The selected seat "Seat A1" is not available.']}
@pytest.mark.django_db
@@ -739,7 +740,7 @@ def test_cartpos_create_with_used_seat(token_client, organizer, event, item, quo
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == ['The selected seat "Seat A1" is not available.']
assert resp.data == {'seat': ['The selected seat "Seat A1" is not available.']}
@pytest.mark.django_db
@@ -753,7 +754,7 @@ def test_cartpos_create_with_unknown_seat(token_client, organizer, event, item,
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == ['The specified seat does not exist.']
assert resp.data == {'seat': ['The specified seat does not exist.']}
@pytest.mark.django_db
@@ -766,7 +767,7 @@ def test_cartpos_create_require_seat(token_client, organizer, event, item, quota
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == ['The specified product requires to choose a seat.']
assert resp.data == {'seat': ['The specified product requires to choose a seat.']}
@pytest.mark.django_db
@@ -783,7 +784,7 @@ def test_cartpos_create_unseated(token_client, organizer, event, item, quota, se
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == ['The specified product does not allow to choose a seat.']
assert resp.data == {'seat': ['The specified product does not allow to choose a seat.']}
@pytest.mark.django_db
@@ -882,7 +883,7 @@ def test_cartpos_create_bulk_partial_seat_failure(token_client, organizer, event
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.']}
assert resp.data['results'][1]['errors'] == {'non_field_errors': ['You can not select the same seat multiple times.']}
with scopes_disabled():
assert CartPosition.objects.count() == 1
@@ -921,7 +922,7 @@ def test_cartpos_create_with_voucher_unknown(token_client, organizer, event, ite
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == ['The specified voucher does not exist.']
assert resp.data == {'voucher': ['The specified voucher does not exist.']}
@pytest.mark.django_db
@@ -938,7 +939,7 @@ def test_cartpos_create_with_voucher_invalid_item(token_client, organizer, event
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == ['The specified voucher is not valid for the given item and variation.']
assert resp.data == {'voucher': ['The specified voucher is not valid for the given item and variation.']}
@pytest.mark.django_db
@@ -956,7 +957,7 @@ def test_cartpos_create_with_voucher_invalid_seat(token_client, organizer, event
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == ['The specified voucher is not valid for this seat.']
assert resp.data == {'voucher': ['The specified voucher is not valid for this seat.']}
@pytest.mark.django_db
@@ -976,7 +977,7 @@ def test_cartpos_create_with_voucher_invalid_subevent(token_client, organizer, e
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == ['The specified voucher is not valid for this subevent.']
assert resp.data == {'voucher': ['The specified voucher is not valid for this subevent.']}
@pytest.mark.django_db
@@ -992,7 +993,7 @@ def test_cartpos_create_with_voucher_expired(token_client, organizer, event, ite
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == ['The specified voucher is expired.']
assert resp.data == {'voucher': ['The specified voucher is expired.']}
@pytest.mark.django_db
@@ -1008,7 +1009,7 @@ def test_cartpos_create_with_voucher_redeemed(token_client, organizer, event, it
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == ['The specified voucher has already been used the maximum number of times.']
assert resp.data == {'voucher': ['The specified voucher has already been used the maximum number of times.']}
@pytest.mark.django_db
@@ -1060,9 +1061,317 @@ def test_cartpos_create_bulk_with_voucher_redeemed(token_client, organizer, even
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.']}
assert resp.data['results'][1]['errors'] == {'voucher': ['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
@pytest.mark.django_db
def test_cartpos_create_bulk_with_addon(token_client, organizer, event, item, quota):
with scopes_disabled():
addon_cat = event.categories.create(name='Addons')
addon_item = event.items.create(name='Workshop', default_price=2, category=addon_cat)
item.addons.create(addon_category=addon_cat)
q = event.quotas.create(name="Addon Quota", size=200)
q.items.add(addon_item)
res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
res['item'] = item.pk
res['expires'] = (now() + datetime.timedelta(days=1)).isoformat()
res['addons'] = [
{
'item': addon_item.pk,
'variation': None,
'price': '1.00',
'attendee_name_parts': {'full_name': 'Peter\'s friend'},
'attendee_email': None,
'subevent': None,
'includes_tax': True,
'answers': []
}
]
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() == 4
cp1 = CartPosition.objects.get(pk=resp.data['results'][0]['data']['id'])
cp1a = cp1.addons.get()
assert cp1a.pk == resp.data['results'][0]['data']['addons'][0]['id']
assert cp1a.item == addon_item
assert not cp1a.is_bundled
assert cp1a.attendee_name == "Peter's friend"
@pytest.mark.django_db
def test_cartpos_create_bulk_with_addon_partially_available(token_client, organizer, event, item, quota):
with scopes_disabled():
addon_cat = event.categories.create(name='Addons')
addon_item = event.items.create(name='Workshop', default_price=2, category=addon_cat)
item.addons.create(addon_category=addon_cat)
q = event.quotas.create(name="Addon Quota", size=1)
q.items.add(addon_item)
res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
res['item'] = item.pk
res['expires'] = (now() + datetime.timedelta(days=1)).isoformat()
res['addons'] = [
{
'item': addon_item.pk,
'variation': None,
'price': '1.00',
'attendee_name_parts': {'full_name': 'Peter\'s friend'},
'attendee_email': None,
'subevent': None,
'includes_tax': True,
'answers': []
}
]
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']
with scopes_disabled():
assert CartPosition.objects.count() == 2
cp1 = CartPosition.objects.get(pk=resp.data['results'][0]['data']['id'])
cp1a = cp1.addons.get()
assert cp1a.item == addon_item
assert not cp1a.is_bundled
assert cp1a.attendee_name == "Peter's friend"
@pytest.mark.django_db
def test_cartpos_create_bulk_with_bundled(token_client, organizer, event, item, quota):
with scopes_disabled():
bundled_item = event.items.create(name='Workshop', default_price=2)
item.bundles.create(bundled_item=bundled_item)
q = event.quotas.create(name="Addon Quota", size=200)
q.items.add(bundled_item)
res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
res['item'] = item.pk
res['expires'] = (now() + datetime.timedelta(days=1)).isoformat()
res['bundled'] = [
{
'item': bundled_item.pk,
'variation': None,
'price': '1.00',
'attendee_name_parts': {'full_name': 'Peter\'s friend'},
'attendee_email': None,
'subevent': None,
'includes_tax': True,
'answers': []
}
]
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() == 4
cp1 = CartPosition.objects.get(pk=resp.data['results'][0]['data']['id'])
cp1a = cp1.addons.get()
assert cp1a.pk == resp.data['results'][0]['data']['bundled'][0]['id']
assert cp1a.item == bundled_item
assert cp1a.is_bundled
assert cp1a.attendee_name == "Peter's friend"
@pytest.mark.django_db
def test_cartpos_create_bulk_with_bundled_partially_available(token_client, organizer, event, item, quota):
with scopes_disabled():
bundled_item = event.items.create(name='Workshop', default_price=2)
item.bundles.create(bundled_item=bundled_item)
q = event.quotas.create(name="Addon Quota", size=1)
q.items.add(bundled_item)
res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
res['item'] = item.pk
res['expires'] = (now() + datetime.timedelta(days=1)).isoformat()
res['bundled'] = [
{
'item': bundled_item.pk,
'variation': None,
'price': '1.00',
'attendee_name_parts': {'full_name': 'Peter\'s friend'},
'attendee_email': None,
'subevent': None,
'includes_tax': True,
'answers': []
}
]
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']
with scopes_disabled():
assert CartPosition.objects.count() == 2
cp1 = CartPosition.objects.get(pk=resp.data['results'][0]['data']['id'])
cp1a = cp1.addons.get()
assert cp1a.item == bundled_item
assert cp1a.is_bundled
assert cp1a.attendee_name == "Peter's friend"
@pytest.mark.django_db
def test_cartpos_create_bulk_with_bundled_without_configuration(token_client, organizer, event, item, quota):
with scopes_disabled():
bundled_item = event.items.create(name='Workshop', default_price=2)
q = event.quotas.create(name="Addon Quota", size=1)
q.items.add(bundled_item)
res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
res['item'] = item.pk
res['expires'] = (now() + datetime.timedelta(days=1)).isoformat()
res['bundled'] = [
{
'item': bundled_item.pk,
'variation': None,
'price': '1.00',
'attendee_name_parts': {'full_name': 'Peter\'s friend'},
'attendee_email': None,
'subevent': None,
'includes_tax': True,
'answers': []
}
]
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/bulk_create/'.format(
organizer.slug, event.slug
), format='json', data=[
res
]
)
assert resp.status_code == 200
assert resp.data == {
'results': [
{
'data': None,
'success': False,
'errors': {
'bundled': ['The product "Workshop" can not be used as an bundled product for "Budget Ticket".']
}
}
]
}
@pytest.mark.django_db
def test_cartpos_create_bulk_with_addon_without_configuration(token_client, organizer, event, item, quota):
with scopes_disabled():
bundled_item = event.items.create(name='Workshop', default_price=2)
q = event.quotas.create(name="Addon Quota", size=1)
q.items.add(bundled_item)
res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
res['item'] = item.pk
res['expires'] = (now() + datetime.timedelta(days=1)).isoformat()
res['addons'] = [
{
'item': bundled_item.pk,
'variation': None,
'price': '1.00',
'attendee_name_parts': {'full_name': 'Peter\'s friend'},
'attendee_email': None,
'subevent': None,
'includes_tax': True,
'answers': []
}
]
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/bulk_create/'.format(
organizer.slug, event.slug
), format='json', data=[
res
]
)
assert resp.status_code == 200
assert resp.data == {
'results': [
{
'data': None,
'success': False,
'errors': {
'addons': ['The product "Workshop" can not be used as an add-on product for "Budget Ticket".']
}
}
]
}
@pytest.mark.django_db
def test_cartpos_create_bulk_validation_error_in_addon(token_client, organizer, event, item, quota):
res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
res['item'] = item.pk
res['expires'] = (now() + datetime.timedelta(days=1)).isoformat()
res['addons'] = [
{
'item': -1,
'variation': None,
'price': '1.00',
'attendee_name_parts': {'full_name': 'Peter\'s friend'},
'attendee_email': None,
'subevent': None,
'includes_tax': True,
'answers': []
}
]
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/bulk_create/'.format(
organizer.slug, event.slug
), format='json', data=[
res
]
)
assert resp.status_code == 200
assert resp.data == {
'results': [
{
'data': None,
'success': False,
'errors': {
'addons': [{'item': ['Invalid pk "-1" - object does not exist.']}]
}
}
]
}
+1 -1
View File
@@ -1986,7 +1986,7 @@ def test_order_create_with_duplicate_seat(token_client, organizer, event, item,
"price": "23.00",
"attendee_name_parts": {"full_name": "Peter"},
"attendee_email": None,
"addon_to": 1,
"addon_to": None,
"answers": [],
"subevent": None,
"seat": seat.seat_guid