diff --git a/doc/api/resources/index.rst b/doc/api/resources/index.rst index 265f9f8764..d65cc1b793 100644 --- a/doc/api/resources/index.rst +++ b/doc/api/resources/index.rst @@ -20,3 +20,4 @@ Resources and endpoints vouchers checkinlists waitinglist + carts diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index af1766e013..aba01e31ba 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -490,6 +490,9 @@ Order endpoints ``"n"`` for pending or ``"p"`` for paid. If you create a paid order, the ``order_paid`` signal will **not** be sent out to plugins and no email will be sent. If you want that behavior, create an unpaid order and then call the ``mark_paid`` API method. + * ``consume_carts`` (optional) – A list of cart IDs. All cart positions with these IDs will be deleted if the + order creation is successful. Any quotas that become free by this operation will be credited to your order + creation. * ``email`` * ``locale`` * ``payment_provider`` – The identifier of the payment provider set for this order. This needs to be an existing @@ -580,11 +583,11 @@ Order endpoints { "positionid": 1, "item": 1, - "variation": None, + "variation": null, "price": "23.00", "attendee_name": "Peter", - "attendee_email": None, - "addon_to": None, + "attendee_email": null, + "addon_to": null, "answers": [ { "question": 1, @@ -592,7 +595,7 @@ Order endpoints "options": [] } ], - "subevent": None + "subevent": null } ], } diff --git a/src/pretix/api/serializers/cart.py b/src/pretix/api/serializers/cart.py new file mode 100644 index 0000000000..e684bdc87b --- /dev/null +++ b/src/pretix/api/serializers/cart.py @@ -0,0 +1,115 @@ +from datetime import timedelta + +from django.utils.crypto import get_random_string +from django.utils.timezone import now +from django.utils.translation import ugettext_lazy +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + +from pretix.api.serializers.i18n import I18nAwareModelSerializer +from pretix.api.serializers.order import ( + AnswerCreateSerializer, AnswerSerializer, +) +from pretix.base.models import Quota +from pretix.base.models.orders import CartPosition + + +class CartPositionSerializer(I18nAwareModelSerializer): + answers = AnswerSerializer(many=True) + + class Meta: + model = CartPosition + fields = ('id', 'cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_email', + 'voucher', 'addon_to', 'subevent', 'datetime', 'expires', 'includes_tax', + 'answers',) + + +class CartPositionCreateSerializer(I18nAwareModelSerializer): + answers = AnswerCreateSerializer(many=True, required=False) + expires = serializers.DateTimeField(required=False) + + class Meta: + model = CartPosition + fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_email', + 'subevent', 'expires', 'includes_tax', 'answers',) + + 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) + ) + + with self.context['event'].lock(): + 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'))) + for quota in new_quotas: + avail = quota.availability() + if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < 1): + raise ValidationError( + ugettext_lazy('There is not enough quota available on quota "{}" to perform ' + 'the operation.').format( + quota.name + ) + ) + cp = CartPosition.objects.create(event=self.context['event'], **validated_data) + + for answ_data in answers_data: + options = answ_data.pop('options') + 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.') + + def validate_item(self, item): + if item.event != self.context['event']: + raise ValidationError( + 'The specified item does not belong to this event.' + ) + if not item.active: + raise ValidationError( + 'The specified item is not active.' + ) + return item + + def validate_subevent(self, subevent): + if self.context['event'].has_subevents: + if not subevent: + raise ValidationError( + 'You need to set a subevent.' + ) + if subevent.event != self.context['event']: + raise ValidationError( + 'The specified subevent does not belong to this event.' + ) + elif subevent: + raise ValidationError( + 'You cannot set a subevent for this event.' + ) + return subevent + + def validate(self, data): + if data.get('item'): + if data.get('item').has_variations: + if not data.get('variation'): + raise ValidationError('You should specify a variation for this item.') + else: + if data.get('variation').item != data.get('item'): + raise ValidationError( + 'The specified variation does not belong to the specified item.' + ) + elif data.get('variation'): + raise ValidationError( + 'You cannot specify a variation for this item.' + ) + return data diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 7384d1d0d6..532cec97b5 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -13,7 +13,7 @@ from pretix.base.models import ( Checkin, Invoice, InvoiceAddress, InvoiceLine, Order, OrderPosition, Question, QuestionAnswer, Quota, ) -from pretix.base.models.orders import OrderFee +from pretix.base.models.orders import CartPosition, OrderFee from pretix.base.pdf import get_variables from pretix.base.signals import register_ticket_outputs @@ -340,11 +340,12 @@ class OrderCreateSerializer(I18nAwareModelSerializer): comment = serializers.CharField(required=False, allow_blank=True) payment_provider = serializers.CharField(required=True) payment_info = CompatibleJSONField(required=False) + consume_carts = serializers.ListField(child=serializers.CharField(), required=False) class Meta: model = Order fields = ('code', 'status', 'email', 'locale', 'payment_provider', 'fees', 'comment', - 'invoice_address', 'positions', 'checkin_attention', 'payment_info') + 'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'consume_carts') def validate_payment_provider(self, pp): if pp not in self.context['event'].get_payment_providers(): @@ -399,15 +400,27 @@ class OrderCreateSerializer(I18nAwareModelSerializer): else: ia = None - with self.context['event'].lock(): + with self.context['event'].lock() as now_dt: quotadiff = Counter() + + consume_carts = validated_data.pop('consume_carts', []) + delete_cps = [] + if consume_carts: + for cp in CartPosition.objects.filter(event=self.context['event'], cart_id__in=consume_carts): + quotas = (cp.variation.quotas.filter(subevent=cp.subevent) + if cp.variation else cp.item.quotas.filter(subevent=cp.subevent)) + if cp.expires > now_dt: + quotadiff.subtract(quotas) + delete_cps.append(cp) + for pos_data in positions_data: 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'))) quotadiff.update(new_quotas) - for quota, diff in quotadiff.items(): + for quota, diff in quotadiff.items(): + if diff > 0: avail = quota.availability() if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < diff): raise ValidationError( @@ -445,6 +458,9 @@ class OrderCreateSerializer(I18nAwareModelSerializer): options = answ_data.pop('options') answ = pos.answers.create(**answ_data) answ.options.add(*options) + + for cp in delete_cps: + cp.delete() for fee_data in fees_data: f = OrderFee(**fee_data) f.order = order diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index 34e8eab620..e7739790e2 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -4,6 +4,8 @@ from django.apps import apps from django.conf.urls import include, url from rest_framework import routers +from pretix.api.views import cart + from .views import ( checkin, event, item, oauth, order, organizer, voucher, waitinglist, ) @@ -28,6 +30,7 @@ event_router.register(r'invoices', order.InvoiceViewSet) event_router.register(r'taxrules', event.TaxRuleViewSet) event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet) event_router.register(r'checkinlists', checkin.CheckinListViewSet) +event_router.register(r'cartpositions', cart.CartPositionViewSet) checkinlist_router = routers.DefaultRouter() checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet) diff --git a/src/pretix/api/views/cart.py b/src/pretix/api/views/cart.py new file mode 100644 index 0000000000..4ada236d16 --- /dev/null +++ b/src/pretix/api/views/cart.py @@ -0,0 +1,46 @@ +from django.db import transaction +from rest_framework import status, viewsets +from rest_framework.filters import OrderingFilter +from rest_framework.mixins import CreateModelMixin, DestroyModelMixin +from rest_framework.response import Response + +from pretix.api.serializers.cart import ( + CartPositionCreateSerializer, CartPositionSerializer, +) +from pretix.base.models import CartPosition + + +class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet): + serializer_class = CartPositionSerializer + queryset = CartPosition.objects.none() + filter_backends = (OrderingFilter,) + ordering = ('datetime',) + ordering_fields = ('datetime', 'cart_id') + lookup_field = 'id' + permission = 'can_view_orders' + write_permission = 'can_change_orders' + + def get_queryset(self): + return CartPosition.objects.filter( + event=self.request.event, + cart_id__endswith="@api" + ) + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['event'] = self.request.event + return ctx + + def create(self, request, *args, **kwargs): + serializer = CartPositionCreateSerializer(data=request.data, context=self.get_serializer_context()) + serializer.is_valid(raise_exception=True) + with transaction.atomic(): + self.perform_create(serializer) + cp = serializer.instance + serializer = CartPositionSerializer(cp, context=serializer.context) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + def perform_create(self, serializer): + serializer.save() diff --git a/src/pretix/plugins/ticketoutputpdf/exporters.py b/src/pretix/plugins/ticketoutputpdf/exporters.py index dc40f1fb77..9f9115c6a3 100644 --- a/src/pretix/plugins/ticketoutputpdf/exporters.py +++ b/src/pretix/plugins/ticketoutputpdf/exporters.py @@ -25,7 +25,8 @@ class AllTicketsPDF(BaseExporter): continue if not op.item.admission and not self.event.settings.ticket_download_nonadm: continue - o._draw_page(p, op, op.order) + layout = o.layout_map.get(op.item_id, o.default_layout) + o._draw_page(layout, p, op, op.order) p.save() outbuffer = o._render_with_background(buffer) diff --git a/src/requirements/production.txt b/src/requirements/production.txt index 07e5f480d2..a43f752163 100644 --- a/src/requirements/production.txt +++ b/src/requirements/production.txt @@ -48,3 +48,4 @@ django-countries pyuca # for better sorting of country names in django-countries defusedcsv>=1.0.1 vat_moss==0.11.0 +idna==2.6 # required by current requests diff --git a/src/tests/api/test_cart.py b/src/tests/api/test_cart.py new file mode 100644 index 0000000000..400712bdc5 --- /dev/null +++ b/src/tests/api/test_cart.py @@ -0,0 +1,554 @@ +import copy +import datetime +from decimal import Decimal +from unittest import mock + +import pytest +from django.utils.timezone import now +from pytz import UTC + +from pretix.base.models import Question +from pretix.base.models.orders import CartPosition + + +@pytest.fixture +def item(event): + return event.items.create(name="Budget Ticket", default_price=23) + + +@pytest.fixture +def item2(event2): + return event2.items.create(name="Budget Ticket", default_price=23) + + +@pytest.fixture +def taxrule(event): + return event.tax_rules.create(rate=Decimal('19.00')) + + +@pytest.fixture +def question(event, item): + q = event.questions.create(question="T-Shirt size", type="S", identifier="ABC") + q.items.add(item) + q.options.create(answer="XL", identifier="LVETRWVU") + return q + + +@pytest.fixture +def question2(event2, item2): + q = event2.questions.create(question="T-Shirt size", type="S", identifier="ABC") + q.items.add(item2) + return q + + +@pytest.fixture +def quota(event, item): + q = event.quotas.create(name="Budget Quota", size=200) + q.items.add(item) + return q + + +TEST_CARTPOSITION_RES = { + 'id': 1, + 'cart_id': 'aaa@api', + 'item': 1, + 'variation': None, + 'price': '23.00', + 'attendee_name': None, + 'attendee_email': None, + 'voucher': None, + 'addon_to': None, + 'subevent': None, + 'datetime': '2018-06-11T10:00:00Z', + 'expires': '2018-06-11T10:00:00Z', + 'includes_tax': True, + 'answers': [] +} + + +@pytest.mark.django_db +def test_cp_list(token_client, organizer, event, item, taxrule, question): + testtime = datetime.datetime(2018, 6, 11, 10, 0, 0, 0, tzinfo=UTC) + + with mock.patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = testtime + cr = CartPosition.objects.create( + event=event, cart_id="aaa", item=item, + price=23, + datetime=datetime.datetime(2018, 6, 11, 10, 0, 0, 0), + expires=datetime.datetime(2018, 6, 11, 10, 0, 0, 0) + ) + res = dict(TEST_CARTPOSITION_RES) + res["id"] = cr.pk + res["item"] = item.pk + + resp = token_client.get('/api/v1/organizers/{}/events/{}/cartpositions/'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [] == resp.data['results'] + + +@pytest.mark.django_db +def test_cp_list_api(token_client, organizer, event, item, taxrule, question): + testtime = datetime.datetime(2018, 6, 11, 10, 0, 0, 0, tzinfo=UTC) + + with mock.patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = testtime + cr = CartPosition.objects.create( + event=event, cart_id="aaa@api", item=item, + price=23, + datetime=datetime.datetime(2018, 6, 11, 10, 0, 0, 0), + expires=datetime.datetime(2018, 6, 11, 10, 0, 0, 0) + ) + res = dict(TEST_CARTPOSITION_RES) + res["id"] = cr.pk + res["item"] = item.pk + + resp = token_client.get('/api/v1/organizers/{}/events/{}/cartpositions/'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + +@pytest.mark.django_db +def test_cp_detail(token_client, organizer, event, item, taxrule, question): + testtime = datetime.datetime(2018, 6, 11, 10, 0, 0, 0, tzinfo=UTC) + + with mock.patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = testtime + cr = CartPosition.objects.create( + event=event, cart_id="aaa@api", item=item, + price=23, + datetime=datetime.datetime(2018, 6, 11, 10, 0, 0, 0), + expires=datetime.datetime(2018, 6, 11, 10, 0, 0, 0) + ) + res = dict(TEST_CARTPOSITION_RES) + res["id"] = cr.pk + res["item"] = item.pk + resp = token_client.get('/api/v1/organizers/{}/events/{}/cartpositions/{}/'.format(organizer.slug, event.slug, + cr.pk)) + assert resp.status_code == 200 + assert res == resp.data + + +@pytest.mark.django_db +def test_cp_delete(token_client, organizer, event, item, taxrule, question): + testtime = datetime.datetime(2018, 6, 11, 10, 0, 0, 0, tzinfo=UTC) + + with mock.patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = testtime + cr = CartPosition.objects.create( + event=event, cart_id="aaa@api", item=item, + price=23, + datetime=datetime.datetime(2018, 6, 11, 10, 0, 0, 0), + expires=datetime.datetime(2018, 6, 11, 10, 0, 0, 0) + ) + res = dict(TEST_CARTPOSITION_RES) + res["id"] = cr.pk + res["item"] = item.pk + resp = token_client.delete('/api/v1/organizers/{}/events/{}/cartpositions/{}/'.format(organizer.slug, event.slug, + cr.pk)) + assert resp.status_code == 204 + + +CARTPOS_CREATE_PAYLOAD = { + 'cart_id': 'aaa@api', + 'item': 1, + 'variation': None, + 'price': '23.00', + 'attendee_name': None, + 'attendee_email': None, + 'addon_to': None, + 'subevent': None, + 'expires': '2018-06-11T10:00:00Z', + 'includes_tax': True, + 'answers': [] +} + + +@pytest.mark.django_db +def test_cartpos_create(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD) + res['item'] = item.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/cartpositions/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + cp = CartPosition.objects.get(pk=resp.data['id']) + assert cp.price == Decimal('23.00') + assert cp.item == item + + +@pytest.mark.django_db +def test_cartpos_cart_id_noapi(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD) + res['item'] = item.pk + res['cart_id'] = 'aaa' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/cartpositions/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + + +@pytest.mark.django_db +def test_cartpos_cart_id_optional(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD) + res['item'] = item.pk + del res['cart_id'] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/cartpositions/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + cp = CartPosition.objects.get(pk=resp.data['id']) + assert cp.price == Decimal('23.00') + assert cp.item == item + assert len(cp.cart_id) > 48 + + +@pytest.mark.django_db +def test_cartpos_create_subevent_validation(token_client, organizer, event, item, subevent, subevent2, quota, question): + res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD) + res['item'] = item.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 == {'subevent': ['You need to set a subevent.']} + + res['subevent'] = subevent2.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 == {'subevent': ['The specified subevent does not belong to this event.']} + + +@pytest.mark.django_db +def test_cartpos_create_item_validation(token_client, organizer, event, item, item2, quota, question): + item.active = False + item.save() + res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD) + res['item'] = item.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 == {'item': ['The specified item is not active.']} + item.active = True + item.save() + + res['item'] = item2.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 == {'item': ['The specified item does not belong to this event.']} + + var2 = item2.variations.create(value="A") + + res['item'] = item.pk + res['variation'] = var2.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 == {'non_field_errors': ['You cannot specify a variation for this item.']} + + var1 = item.variations.create(value="A") + res['item'] = item.pk + res['variation'] = var1.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/cartpositions/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + + res['variation'] = var2.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 == {'non_field_errors': ['The specified variation does not belong to the specified item.']} + + res['variation'] = None + 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 == {'non_field_errors': ['You should specify a variation for this item.']} + + +@pytest.mark.django_db +def test_cartpos_expires_optional(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD) + res['item'] = item.pk + del res['expires'] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/cartpositions/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + cp = CartPosition.objects.get(pk=resp.data['id']) + assert cp.price == Decimal('23.00') + assert cp.item == item + assert cp.expires - now() > datetime.timedelta(minutes=25) + assert cp.expires - now() < datetime.timedelta(minutes=35) + + +@pytest.mark.django_db +def test_cartpos_create_answer_validation(token_client, organizer, event, item, quota, question, question2): + res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD) + res['answers'] = [{ + "question": 1, + "answer": "S", + "options": [] + }] + + res['item'] = item.pk + res['answers'][0]['question'] = question2.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 == {'answers': [{'question': ['The specified question does not belong to this event.']}]} + + res['answers'][0]['question'] = question.pk + res['answers'][0]['options'] = [question.options.first().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 == { + 'answers': [{'non_field_errors': ['You should not specify options if the question is not of a choice type.']}]} + + question.type = Question.TYPE_CHOICE + question.save() + res['answers'][0]['options'] = [] + 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 == { + 'answers': [{'non_field_errors': ['You need to specify options if the question is of a choice type.']}]} + + question.options.create(answer="L") + res['answers'][0]['options'] = [ + question.options.first().pk, + question.options.last().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 == {'answers': [{'non_field_errors': ['You can specify at most one option for this question.']}]} + + question.type = Question.TYPE_FILE + question.save() + 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 == {'answers': [{'non_field_errors': ['File uploads are currently not supported via the API.']}]} + + question.type = Question.TYPE_CHOICE_MULTIPLE + question.save() + res['answers'][0]['options'] = [ + question.options.first().pk, + question.options.last().pk, + ] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/cartpositions/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + pos = CartPosition.objects.get(pk=resp.data['id']) + answ = pos.answers.first() + assert answ.question == question + assert answ.answer == "XL, L" + + question.type = Question.TYPE_NUMBER + question.save() + res['answers'][0]['options'] = [] + res['answers'][0]['answer'] = '3.45' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/cartpositions/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + pos = CartPosition.objects.get(pk=resp.data['id']) + answ = pos.answers.first() + assert answ.answer == "3.45" + + question.type = Question.TYPE_NUMBER + question.save() + res['answers'][0]['options'] = [] + res['answers'][0]['answer'] = 'foo' + 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 == {'answers': [{'non_field_errors': ['A valid number is required.']}]} + + question.type = Question.TYPE_BOOLEAN + question.save() + res['answers'][0]['options'] = [] + res['answers'][0]['answer'] = 'True' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/cartpositions/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + pos = CartPosition.objects.get(pk=resp.data['id']) + answ = pos.answers.first() + assert answ.answer == "True" + + question.type = Question.TYPE_BOOLEAN + question.save() + res['answers'][0]['answer'] = '0' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/cartpositions/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + pos = CartPosition.objects.get(pk=resp.data['id']) + answ = pos.answers.first() + assert answ.answer == "False" + + question.type = Question.TYPE_BOOLEAN + question.save() + res['answers'][0]['answer'] = 'bla' + 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 == {'answers': [{'non_field_errors': ['Please specify "true" or "false" for boolean questions.']}]} + + question.type = Question.TYPE_DATE + question.save() + res['answers'][0]['answer'] = '2018-05-14' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/cartpositions/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + pos = CartPosition.objects.get(pk=resp.data['id']) + answ = pos.answers.first() + assert answ.answer == "2018-05-14" + + question.type = Question.TYPE_DATE + question.save() + res['answers'][0]['answer'] = 'bla' + 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 == { + 'answers': [{'non_field_errors': ['Date has wrong format. Use one of these formats instead: YYYY[-MM[-DD]].']}]} + + question.type = Question.TYPE_DATETIME + question.save() + res['answers'][0]['answer'] = '2018-05-14T13:00:00Z' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/cartpositions/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + pos = CartPosition.objects.get(pk=resp.data['id']) + answ = pos.answers.first() + assert answ.answer == "2018-05-14 13:00:00+00:00" + + question.type = Question.TYPE_DATETIME + question.save() + res['answers'][0]['answer'] = 'bla' + 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 == {'answers': [{'non_field_errors': [ + 'Datetime has wrong format. Use one of these formats instead: ' + 'YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z].']}]} + + question.type = Question.TYPE_TIME + question.save() + res['answers'][0]['answer'] = '13:00:00' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/cartpositions/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + pos = CartPosition.objects.get(pk=resp.data['id']) + answ = pos.answers.first() + assert answ.answer == "13:00:00" + + question.type = Question.TYPE_TIME + question.save() + res['answers'][0]['answer'] = 'bla' + 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 == {'answers': [ + {'non_field_errors': ['Time has wrong format. Use one of these formats instead: hh:mm[:ss[.uuuuuu]].']}]} + + +@pytest.mark.django_db +def test_cartpos_create_quota_validation(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD) + res['item'] = item.pk + + quota.size = 0 + quota.save() + 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 == ['There is not enough quota available on quota "Budget Quota" to perform the operation.'] diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index 1f940b6bbc..e128403418 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -11,7 +11,7 @@ from django_countries.fields import Country from pytz import UTC from pretix.base.models import InvoiceAddress, Order, OrderPosition, Question -from pretix.base.models.orders import OrderFee +from pretix.base.models.orders import CartPosition, OrderFee from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, ) @@ -1618,6 +1618,38 @@ def test_order_create_quota_validation(token_client, organizer, event, item, quo assert resp.data == ['There is not enough quota available on quota "Budget Quota" to perform the operation.'] +@pytest.mark.django_db +def test_order_create_quota_consume_cart(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 + + cr = CartPosition.objects.create( + event=event, cart_id="uxLJBUMEcnxOLI2EuxLYN1hWJq9GKu4yWL9FEgs2m7M0vdFi@api", item=item, + price=23, + expires=now() + datetime.timedelta(hours=3) + ) + + quota.size = 1 + 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 + assert resp.data == ['There is not enough quota available on quota "Budget Quota" to perform the operation.'] + + res['consume_carts'] = [cr.cart_id] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + assert not CartPosition.objects.filter(pk=cr.pk).exists() + + @pytest.mark.django_db def test_order_create_free(token_client, organizer, event, item, quota, question): res = copy.deepcopy(ORDER_CREATE_PAYLOAD)