API: Allow to simulate orders

This commit is contained in:
Raphael Michel
2020-03-05 16:37:55 +01:00
parent f7fddc05dd
commit ee260c8231
4 changed files with 207 additions and 23 deletions
+7
View File
@@ -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
+91 -22
View File
@@ -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'
+3
View File
@@ -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',
+106 -1
View File
@@ -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)