From ee260c82312eac63e5eca21b6df6b68feba403ac Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 5 Mar 2020 12:52:26 +0100 Subject: [PATCH] API: Allow to simulate orders --- doc/api/resources/orders.rst | 7 ++ src/pretix/api/serializers/order.py | 113 ++++++++++++++++++++++------ src/pretix/api/views/order.py | 3 + src/tests/api/test_orders.py | 107 +++++++++++++++++++++++++- 4 files changed, 207 insertions(+), 23 deletions(-) diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 3249fb10e..44c5652c1 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -891,6 +891,13 @@ Creating orders IDs in the ``addon_to`` field of another position. Note that all add_ons for a specific position need to come immediately after the position itself. + Starting with pretix 3.7, you can add ``"simulate": true`` to the body to do a "dry run" of your order. This will + validate your order and return you an order object with the resulting prices, but will not create an actual order. + You can use this for testing or to look up prices. In this case, some attributes are ignored, such as whether + to send an email or what payment provider will be used. Note that some returned fields will contain empty values + (e.g. all ``id`` fields of positions will be zero) and some will contain fake values (e.g. the order code will + always be ``PREVIEW``). pretix plugins will not be triggered, so some special behavior might be missing as well. + **Example request**: .. sourcecode:: http diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 55c951f48..4c938951e 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -25,6 +25,7 @@ from pretix.base.models.orders import ( ) from pretix.base.pdf import get_variables from pretix.base.services.cart import error_messages +from pretix.base.services.locking import NoLockManager 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 @@ -96,6 +97,11 @@ class AnswerQuestionOptionsIdentifierField(serializers.Field): return [o.identifier for o in instance.options.all()] +class AnswerQuestionOptionsField(serializers.Field): + def to_representation(self, instance: QuestionAnswer): + return [o.pk for o in instance.options.all()] + + class InlineSeatSerializer(I18nAwareModelSerializer): class Meta: @@ -106,6 +112,7 @@ class InlineSeatSerializer(I18nAwareModelSerializer): class AnswerSerializer(I18nAwareModelSerializer): question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True) option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True) + options = AnswerQuestionOptionsField(source='*', read_only=True) class Meta: model = QuestionAnswer @@ -585,6 +592,28 @@ class CompatibleJSONField(serializers.JSONField): return value +class WrappedList: + def __init__(self, data): + self._data = data + + def all(self): + return self._data + + +class WrappedModel: + def __init__(self, model): + self._wrapped = model + + def __getattr__(self, item): + return getattr(self._wrapped, item) + + def save(self, *args, **kwargs): + raise NotImplementedError + + def delete(self, *args, **kwargs): + raise NotImplementedError + + class OrderCreateSerializer(I18nAwareModelSerializer): invoice_address = InvoiceAddressSerializer(required=False) positions = OrderPositionCreateSerializer(many=True, required=True) @@ -605,6 +634,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): force = serializers.BooleanField(default=False, required=False) payment_date = serializers.DateTimeField(required=False, allow_null=True) send_mail = serializers.BooleanField(default=False, required=False) + simulate = serializers.BooleanField(default=False, required=False) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -614,7 +644,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): model = Order fields = ('code', 'status', 'testmode', 'email', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel', 'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'payment_date', 'consume_carts', - 'force', 'send_mail') + 'force', 'send_mail', 'simulate') def validate_payment_provider(self, pp): if pp is None: @@ -706,6 +736,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): payment_info = validated_data.pop('payment_info', '{}') payment_date = validated_data.pop('payment_date', now()) force = validated_data.pop('force', False) + simulate = validated_data.pop('simulate', False) self._send_mail = validated_data.pop('send_mail', False) if 'invoice_address' in validated_data: @@ -719,7 +750,10 @@ class OrderCreateSerializer(I18nAwareModelSerializer): else: ia = None - with self.context['event'].lock() as now_dt: + lockfn = self.context['event'].lock + if simulate: + lockfn = NoLockManager + with lockfn() as now_dt: free_seats = set() seats_seen = set() consume_carts = validated_data.pop('consume_carts', []) @@ -869,11 +903,20 @@ class OrderCreateSerializer(I18nAwareModelSerializer): order.set_expires(subevents=[p.get('subevent') for p in positions_data]) order.meta_info = "{}" order.total = Decimal('0.00') - order.save() + if simulate: + order = WrappedModel(order) + order.last_modified = now() + order.code = 'PREVIEW' + else: + order.save() if ia: - ia.order = order - ia.save() + if not simulate: + ia.order = order + ia.save() + else: + order.invoice_address = ia + ia.last_modified = now() pos_map = {} for pos_data in positions_data: @@ -885,7 +928,10 @@ class OrderCreateSerializer(I18nAwareModelSerializer): '_legacy': attendee_name } pos = OrderPosition(**pos_data) - pos.order = order + if simulate: + pos.order = order._wrapped + else: + pos.order = order if addon_to: pos.addon_to = pos_map[addon_to] @@ -916,19 +962,33 @@ class OrderCreateSerializer(I18nAwareModelSerializer): invoice_address=ia, ).gross - if pos.voucher: - Voucher.objects.filter(pk=pos.voucher.pk).update(redeemed=F('redeemed') + 1) - pos.save() + if simulate: + pos = WrappedModel(pos) + pos.id = 0 + answers = [] + for answ_data in answers_data: + options = answ_data.pop('options', []) + answ = WrappedModel(QuestionAnswer(**answ_data)) + answ.options = WrappedList(options) + answers.append(answ) + pos.answers = answers + pos.pseudonymization_id = "PREVIEW" + else: + if pos.voucher: + Voucher.objects.filter(pk=pos.voucher.pk).update(redeemed=F('redeemed') + 1) + pos.save() + for answ_data in answers_data: + options = answ_data.pop('options', []) + answ = pos.answers.create(**answ_data) + answ.options.add(*options) pos_map[pos.positionid] = pos - for answ_data in answers_data: - options = answ_data.pop('options', []) - answ = pos.answers.create(**answ_data) - answ.options.add(*options) - for cp in delete_cps: - cp.delete() + if not simulate: + for cp in delete_cps: + cp.delete() - order.total = sum([p.price for p in order.positions.all()]) + order.total = sum([p.price for p in pos_map.values()]) + fees = [] for fee_data in fees_data: is_percentage = fee_data.pop('_treat_value_as_percentage', False) if is_percentage: @@ -960,17 +1020,26 @@ class OrderCreateSerializer(I18nAwareModelSerializer): fee_data['tax_rule'] = tr fee_data['value'] = val f = OrderFee(**fee_data) - f.order = order + f.order = order._wrapped if simulate else order f._calculate_tax() - f.save() + fees.append(f) + if not simulate: + f.save() else: f = OrderFee(**fee_data) - f.order = order + f.order = order._wrapped if simulate else order f._calculate_tax() - f.save() + fees.append(f) + if not simulate: + f.save() - order.total += sum([f.value for f in order.fees.all()]) - order.save(update_fields=['total']) + order.total += sum([f.value for f in fees]) + if simulate: + order.fees = fees + order.positions = pos_map.values() + return order # ignore payments + else: + order.save(update_fields=['total']) if order.total == Decimal('0.00') and validated_data.get('status') == Order.STATUS_PAID and not payment_provider: payment_provider = 'free' diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 21001dc75..e905ef10b 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -466,6 +466,9 @@ class OrderViewSet(viewsets.ModelViewSet): send_mail = serializer._send_mail order = serializer.instance serializer = OrderSerializer(order, context=serializer.context) + if not order.pk: + # Simulation + return Response(serializer.data, status=status.HTTP_201_CREATED) order.log_action( 'pretix.event.order.placed', diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index b31d7e5cc..98785b604 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -19,7 +19,7 @@ from pretix.base.models import ( InvoiceAddress, Order, OrderPosition, Question, SeatingPlan, ) from pretix.base.models.orders import ( - CartPosition, OrderFee, OrderPayment, OrderRefund, + CartPosition, OrderFee, OrderPayment, OrderRefund, QuestionAnswer, ) from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, @@ -1539,6 +1539,111 @@ def test_order_create(token_client, organizer, event, item, quota, question): assert answ.answer == "S" +@pytest.mark.django_db +def test_order_create_simulate(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + question.type = Question.TYPE_CHOICE_MULTIPLE + question.save() + with scopes_disabled(): + opt = question.options.create(answer="L") + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['positions'][0]['answers'][0]['options'] = [opt.pk] + res['simulate'] = True + 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(): + assert Order.objects.count() == 0 + assert QuestionAnswer.objects.count() == 0 + assert OrderPosition.objects.count() == 0 + assert OrderFee.objects.count() == 0 + assert InvoiceAddress.objects.count() == 0 + d = resp.data + del d['last_modified'] + del d['secret'] + del d['url'] + del d['expires'] + del d['invoice_address']['last_modified'] + del d['positions'][0]['secret'] + assert d == { + 'code': 'PREVIEW', + 'status': 'n', + 'testmode': False, + 'email': 'dummy@dummy.test', + 'locale': 'en', + 'datetime': None, + 'payment_date': None, + 'payment_provider': None, + 'fees': [ + { + 'fee_type': 'payment', + 'value': '0.25', + 'description': '', + 'internal_type': '', + 'tax_rate': '0.00', + 'tax_value': '0.00', + 'tax_rule': None, + 'canceled': False + } + ], + 'total': '23.25', + 'comment': '', + 'invoice_address': { + 'is_business': False, + 'company': 'Sample company', + 'name': 'Fo', + 'name_parts': {'full_name': 'Fo', '_scheme': 'full'}, + 'street': 'Bar', + 'zipcode': '', + 'city': 'Sample City', + 'country': 'NZ', + 'state': '', + 'vat_id': '', + 'vat_id_validated': False, + 'internal_reference': '' + }, + 'positions': [ + { + 'id': 0, + 'order': '', + 'positionid': 1, + 'item': 1, + 'variation': None, + 'price': '23.00', + 'attendee_name': 'Peter', + 'attendee_name_parts': {'full_name': 'Peter', '_scheme': 'full'}, + 'attendee_email': None, + 'voucher': None, + 'tax_rate': '0.00', + 'tax_value': '0.00', + 'addon_to': None, + 'subevent': None, + 'checkins': [], + 'downloads': [], + 'answers': [ + {'question': question.pk, 'answer': 'L', 'question_identifier': 'ABC', + 'options': [opt.pk], + 'option_identifiers': [opt.identifier]} + ], + 'tax_rule': None, + 'pseudonymization_id': 'PREVIEW', + 'seat': None, + 'canceled': False + } + ], + 'downloads': [], + 'checkin_attention': False, + 'payments': [], + 'refunds': [], + 'require_approval': False, + 'sales_channel': 'web', + } + + @pytest.mark.django_db def test_order_create_autocheckin(token_client, organizer, event, item, quota, question, clist_autocheckin): res = copy.deepcopy(ORDER_CREATE_PAYLOAD)