API: Trust discounts assigned by pretixPOS, do not assign differently (#5531)

This commit is contained in:
Raphael Michel
2025-10-21 18:35:06 +02:00
committed by GitHub
parent 5563183255
commit 40db7d939f
3 changed files with 148 additions and 14 deletions

View File

@@ -1058,6 +1058,7 @@ Creating orders
* ``valid_until`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product)
* ``requested_valid_from`` (optional, can be set **instead** of ``valid_from`` and ``valid_until`` to signal a user choice for the start time that may or may not be respected)
* ``use_reusable_medium`` (optional, causes the new ticket to take over the given reusable medium, identified by its ID)
* ``discount`` (optional, only possible if ``price`` is set; attention: if this is set to not-``null`` on any position, automatic calculation of discounts will not run)
* ``answers``
* ``question``

View File

@@ -1005,7 +1005,7 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'company', 'street', 'zipcode', 'city', 'country', 'state', 'is_bundled',
'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher', 'valid_from', 'valid_until',
'requested_valid_from', 'use_reusable_medium')
'requested_valid_from', 'use_reusable_medium', 'discount')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -1101,6 +1101,10 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
{'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]}
)
if data.get('price') is None and data.get('discount'):
raise ValidationError(
{'discount': ['You can only specify a discount if you do the price computation, but price is not set.']}
)
return data
@@ -1160,6 +1164,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['positions'].child.fields['voucher'].queryset = self.context['event'].vouchers.all()
self.fields['positions'].child.fields['discount'].queryset = self.context['event'].discounts.all()
self.fields['customer'].queryset = self.context['event'].organizer.customers.all()
self.fields['expires'].required = False
self.fields["sales_channel"].queryset = self.context["event"].organizer.sales_channels.all()
@@ -1567,19 +1572,22 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
pos.voucher_budget_use = max(listed_price - price_after_voucher, Decimal('0.00'))
order_positions = [pos_data['__instance'] for pos_data in positions_data]
discount_results = apply_discounts(
self.context['event'],
order.sales_channel,
[
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.price,
bool(cp.addon_to), cp.is_bundled, pos._voucher_discount)
for cp in order_positions
]
)
for cp, (new_price, discount) in zip(order_positions, discount_results):
if new_price != pos.price and pos._auto_generated_price:
pos.price = new_price
pos.discount = discount
if not any([p.get("discount") for p in positions_data]):
# If any discount is set by the client (i.e. pretixPOS), we do not recalculate but believe the client
# to avoid differences in end results.
discount_results = apply_discounts(
self.context['event'],
order.sales_channel,
[
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.price,
bool(cp.addon_to), cp.is_bundled, pos._voucher_discount)
for cp in order_positions
]
)
for cp, (new_price, discount) in zip(order_positions, discount_results):
if new_price != pos.price and pos._auto_generated_price:
pos.price = new_price
pos.discount = discount
# Save instances
for pos_data in positions_data:

View File

@@ -3134,3 +3134,128 @@ def test_order_create_create_medium(token_client, organizer, event, item, quota,
m = organizer.reusable_media.get(identifier=i)
assert m.linked_orderposition == o.positions.first()
assert m.type == "barcode"
@pytest.mark.django_db
def test_order_create_auto_pricing_discount(token_client, organizer, event, item, quota, question, taxrule):
with scopes_disabled():
event.discounts.create(
condition_min_count=2,
benefit_discount_matching_percent=50,
benefit_only_apply_to_cheapest_n_matches=1,
)
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
del res['positions'][0]['positionid']
del res['positions'][0]['price']
res['positions'].append(dict(res['positions'][0]))
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
with scopes_disabled():
o = Order.objects.get(code=resp.data['code'])
p1 = o.positions.first()
p2 = o.positions.last()
assert p1.price == Decimal('23')
assert p2.price == Decimal('11.50')
assert o.total == Decimal('34.75')
@pytest.mark.django_db
def test_order_create_auto_pricing_do_not_discount_if_price_explcitly_set(token_client, organizer, event, item, quota, question, taxrule):
with scopes_disabled():
event.discounts.create(
condition_min_count=2,
benefit_discount_matching_percent=50,
benefit_only_apply_to_cheapest_n_matches=1,
)
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
del res['positions'][0]['positionid']
res['positions'].append(dict(res['positions'][0]))
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
with scopes_disabled():
o = Order.objects.get(code=resp.data['code'])
p1 = o.positions.first()
p2 = o.positions.last()
assert p1.price == Decimal('23.00')
assert p2.price == Decimal('23.00')
assert o.total == Decimal('46.25')
@pytest.mark.django_db
def test_order_create_auto_pricing_believe_wrong_discounts_by_client(token_client, organizer, event, item, quota, question, taxrule):
with scopes_disabled():
discount = event.discounts.create(
condition_min_count=2,
benefit_discount_matching_percent=50,
benefit_only_apply_to_cheapest_n_matches=1,
)
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
del res['positions'][0]['positionid']
res['positions'].append(dict(res['positions'][0]))
res['positions'][0]['price'] = Decimal("10.00")
res['positions'][1]['price'] = Decimal("7.00")
res['positions'][1]['discount'] = discount.pk
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
with scopes_disabled():
o = Order.objects.get(code=resp.data['code'])
p1 = o.positions.first()
p2 = o.positions.last()
assert p1.price == Decimal('10.00')
assert p1.discount is None
assert p2.price == Decimal('7.00')
assert p2.discount == discount
assert o.total == Decimal('17.25')
@pytest.mark.django_db
def test_order_create_auto_pricing_explicit_discount_not_allowed(token_client, organizer, event, item, quota, question, taxrule):
with scopes_disabled():
discount = event.discounts.create(
condition_min_count=2,
benefit_discount_matching_percent=50,
benefit_only_apply_to_cheapest_n_matches=1,
)
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
del res['positions'][0]['positionid']
del res['positions'][0]['price']
res['positions'].append(dict(res['positions'][0]))
res['positions'][1]['discount'] = discount.pk
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {
"positions": [
{},
{
"discount": ["You can only specify a discount if you do the price computation, but price is not set."]
}
]
}