diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 23ea2954a8..4c4c57a1b4 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -1926,6 +1926,7 @@ Manipulating individual positions (Full order position resource, see above.) + :query boolean check_quotas: Whether to check quotas before committing item changes, default is ``true`` :param organizer: The ``slug`` field of the organizer of the event :param event: The ``slug`` field of the event :param id: The ``id`` field of the order position to update @@ -2005,6 +2006,7 @@ Manipulating individual positions (Full order position resource, see above.) + :query boolean check_quotas: Whether to check quotas before creating the new position, default is ``true`` :param organizer: The ``slug`` field of the organizer of the event :param event: The ``slug`` field of the event @@ -2291,6 +2293,7 @@ otherwise, such as splitting an order or changing fees. (Full order position resource, see above.) + :query boolean check_quotas: Whether to check quotas before patching or creating positions, default is ``true`` :param organizer: The ``slug`` field of the organizer of the event :param event: The ``slug`` field of the event :param code: The ``code`` field of the order to update diff --git a/src/pretix/api/serializers/orderchange.py b/src/pretix/api/serializers/orderchange.py index 8c3cb896ad..e8fb7820da 100644 --- a/src/pretix/api/serializers/orderchange.py +++ b/src/pretix/api/serializers/orderchange.py @@ -83,6 +83,7 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize def create(self, validated_data): ocm = self.context['ocm'] + check_quotas = self.context.get('check_quotas', True) try: ocm.add_position( @@ -96,7 +97,7 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize valid_until=validated_data.get('valid_until'), ) if self.context.get('commit', True): - ocm.commit() + ocm.commit(check_quotas=check_quotas) return validated_data['order'].positions.order_by('-positionid').first() else: return OrderPosition() # fake to appease DRF @@ -310,6 +311,7 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer): def update(self, instance, validated_data): ocm = self.context['ocm'] + check_quotas = self.context.get('check_quotas', True) current_seat = {'seat_guid': instance.seat.seat_guid} if instance.seat else None item = validated_data.get('item', instance.item) variation = validated_data.get('variation', instance.variation) @@ -356,7 +358,7 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer): ocm.change_ticket_secret(instance, secret) if self.context.get('commit', True): - ocm.commit() + ocm.commit(check_quotas=check_quotas) instance.refresh_from_db() except OrderError as e: raise ValidationError(str(e)) diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index f7d7a7ed16..427d1fd193 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -943,6 +943,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet): @action(detail=True, methods=['POST']) def change(self, request, **kwargs): order = self.get_object() + check_quotas = self.request.query_params.get('check_quotas', 'true') == 'true' serializer = OrderChangeOperationSerializer( context={'order': order, **self.get_serializer_context()}, @@ -1008,7 +1009,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet): elif serializer.validated_data.get('recalculate_taxes') == 'keep_gross': ocm.recalculate_taxes(keep='gross') - ocm.commit() + ocm.commit(check_quotas=check_quotas) except OrderError as e: raise ValidationError(str(e)) @@ -1087,6 +1088,7 @@ class OrderPositionViewSet(viewsets.ModelViewSet): ctx = super().get_serializer_context() ctx['event'] = self.request.event ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true' + ctx['check_quotas'] = self.request.query_params.get('check_quotas', 'true').lower() == 'true' return ctx def get_queryset(self): diff --git a/src/tests/api/test_order_change.py b/src/tests/api/test_order_change.py index ec9394f68d..588ba6b70f 100644 --- a/src/tests/api/test_order_change.py +++ b/src/tests/api/test_order_change.py @@ -1204,6 +1204,106 @@ def test_position_update_change_item_no_quota(token_client, organizer, event, or assert 'quota' in str(resp.data) +@pytest.mark.django_db +def test_position_update_change_item_empty_quota(token_client, organizer, event, order): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + q = event.quotas.create(name="No Quota", size=0) + q.items.add(item2) + q.save() + op = order.positions.first() + payload = { + 'item': item2.pk, + } + assert op.item != item2 + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'quota' in str(resp.data) + + +@pytest.mark.django_db +def test_position_update_change_item_no_quota_check_quota_false(token_client, organizer, event, order): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + op = order.positions.first() + payload = { + 'item': item2.pk, + } + assert op.item != item2 + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/?check_quotas=false'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'quota' in str(resp.data) + + +@pytest.mark.django_db +def test_position_update_change_item_empty_quota_check_quota_false(token_client, organizer, event, order): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + q = event.quotas.create(name="No Quota", size=0) + q.items.add(item2) + q.save() + op = order.positions.first() + payload = { + 'item': item2.pk, + } + assert op.item != item2 + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/?check_quotas=false'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 200 + op.refresh_from_db() + assert op.item == item2 + + +@pytest.mark.django_db +def test_position_update_change_item_no_quota_check_quota_true(token_client, organizer, event, order): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + op = order.positions.first() + payload = { + 'item': item2.pk, + } + assert op.item != item2 + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/?check_quotas=true'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'quota' in str(resp.data) + + +@pytest.mark.django_db +def test_position_update_change_item_empty_quota_check_quota_true(token_client, organizer, event, order): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + q = event.quotas.create(name="No Quota", size=0) + q.items.add(item2) + q.save() + op = order.positions.first() + payload = { + 'item': item2.pk, + } + assert op.item != item2 + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/?check_quotas=true'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'quota' in str(resp.data) + + @pytest.mark.django_db def test_position_update_change_item_variation(token_client, organizer, event, order, quota): with scopes_disabled(): @@ -1318,6 +1418,49 @@ def test_position_update_change_subevent_quota_empty(token_client, organizer, ev assert 'quota' in str(resp.data) +@pytest.mark.django_db +def test_position_update_change_subevent_quota_empty_check_quota_false(token_client, organizer, event, order, quota, item, subevent): + with scopes_disabled(): + se2 = event.subevents.create(name="Foobar", date_from=datetime.datetime(2017, 12, 27, 10, 0, 0, tzinfo=datetime.timezone.utc)) + q2 = se2.quotas.create(name="foo", size=0, event=event) + q2.items.add(item) + op = order.positions.first() + op.subevent = subevent + op.save() + payload = { + 'subevent': se2.pk, + } + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/?check_quotas=false'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 200 + op.refresh_from_db() + assert op.subevent == se2 + + +@pytest.mark.django_db +def test_position_update_change_subevent_quota_empty_check_quota_true(token_client, organizer, event, order, quota, item, subevent): + with scopes_disabled(): + se2 = event.subevents.create(name="Foobar", date_from=datetime.datetime(2017, 12, 27, 10, 0, 0, tzinfo=datetime.timezone.utc)) + q2 = se2.quotas.create(name="foo", size=0, event=event) + q2.items.add(item) + op = order.positions.first() + op.subevent = subevent + op.save() + payload = { + 'subevent': se2.pk, + } + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/orderpositions/{}/?check_quotas=true'.format( + organizer.slug, event.slug, op.pk + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'quota' in str(resp.data) + + @pytest.mark.django_db def test_position_update_change_seat(token_client, organizer, event, order, quota, item, seat): with scopes_disabled(): @@ -1600,6 +1743,85 @@ def test_position_add_quota_empty(token_client, organizer, event, order, quota, assert 'quota' in str(resp.data) +@pytest.mark.django_db +def test_position_add_no_quota(token_client, organizer, event, order): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + assert order.positions.count() == 1 + payload = { + 'order': order.code, + 'item': item2.pk, + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orderpositions/'.format( + organizer.slug, event.slug, + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'quota' in str(resp.data) + + +@pytest.mark.django_db +def test_position_add_no_quota_check_quota_false(token_client, organizer, event, order): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + assert order.positions.count() == 1 + payload = { + 'order': order.code, + 'item': item2.pk, + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orderpositions/?check_quotas=false'.format( + organizer.slug, event.slug, + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'quota' in str(resp.data) + + +@pytest.mark.django_db +def test_position_add_quota_empty_check_quota_false(token_client, organizer, event, order, quota, item): + with scopes_disabled(): + assert order.positions.count() == 1 + quota.size = 1 + quota.save() + payload = { + 'order': order.code, + 'item': item.pk, + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orderpositions/?check_quotas=false'.format( + organizer.slug, event.slug, + ), format='json', data=payload + ) + assert resp.status_code == 201 + with scopes_disabled(): + assert order.positions.count() == 2 + op = order.positions.last() + assert op.item == item + assert op.price == item.default_price + assert op.positionid == 3 + + +@pytest.mark.django_db +def test_position_add_quota_empty_check_quota_true(token_client, organizer, event, order, quota, item): + with scopes_disabled(): + assert order.positions.count() == 1 + quota.size = 1 + quota.save() + payload = { + 'order': order.code, + 'item': item.pk, + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orderpositions/?check_quotas=true'.format( + organizer.slug, event.slug, + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'quota' in str(resp.data) + + @pytest.mark.django_db def test_position_add_seat(token_client, organizer, event, order, quota, item, seat): with scopes_disabled(): @@ -1803,6 +2025,283 @@ def test_order_change_patch(token_client, organizer, event, order, quota): assert order.total == Decimal('109.44') +@pytest.mark.django_db +def test_order_change_patch_no_quota(token_client, organizer, event, order): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + p = order.positions.first() + payload = { + 'patch_positions': [ + { + 'position': p.pk, + 'body': { + 'item': item2.pk, + 'price': '99.44', + }, + }, + ] + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/change/'.format( + organizer.slug, event.slug, order.code, + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'quota' in str(resp.data) + + +@pytest.mark.django_db +def test_order_change_patch_no_quota_check_quota_false(token_client, organizer, event, order): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + p = order.positions.first() + payload = { + 'patch_positions': [ + { + 'position': p.pk, + 'body': { + 'item': item2.pk, + 'price': '99.44', + }, + }, + ] + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/change/?check_quotas=false'.format( + organizer.slug, event.slug, order.code, + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'quota' in str(resp.data) + + +@pytest.mark.django_db +def test_order_change_patch_quota_empty(token_client, organizer, event, order): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + q = event.quotas.create(name="No Quota", size=0) + q.items.add(item2) + q.save() + p = order.positions.first() + payload = { + 'patch_positions': [ + { + 'position': p.pk, + 'body': { + 'item': item2.pk, + 'price': '99.44', + }, + }, + ] + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/change/'.format( + organizer.slug, event.slug, order.code, + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'quota' in str(resp.data) + + +@pytest.mark.django_db +def test_order_change_patch_quota_empty_check_quota_false(token_client, organizer, event, order): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + q = event.quotas.create(name="No Quota", size=0) + q.items.add(item2) + q.save() + p = order.positions.first() + payload = { + 'patch_positions': [ + { + 'position': p.pk, + 'body': { + 'item': item2.pk, + 'price': '99.44', + }, + }, + ] + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/change/?check_quotas=false'.format( + organizer.slug, event.slug, order.code, + ), format='json', data=payload + ) + assert resp.status_code == 200 + with scopes_disabled(): + p.refresh_from_db() + assert p.price == Decimal('99.44') + assert p.item == item2 + + +@pytest.mark.django_db +def test_order_change_patch_quota_empty_check_quota_true(token_client, organizer, event, order): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + q = event.quotas.create(name="No Quota", size=0) + q.items.add(item2) + q.save() + p = order.positions.first() + payload = { + 'patch_positions': [ + { + 'position': p.pk, + 'body': { + 'item': item2.pk, + 'price': '99.44', + }, + }, + ] + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/change/?check_quotas=true'.format( + organizer.slug, event.slug, order.code, + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'quota' in str(resp.data) + + +@pytest.mark.django_db +def test_order_change_create_position(token_client, organizer, event, order, quota, item): + with scopes_disabled(): + assert order.positions.count() == 1 + payload = { + 'create_positions': [ + { + 'item': item.pk, + }, + ] + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/change/'.format( + organizer.slug, event.slug, order.code, + ), format='json', data=payload + ) + assert resp.status_code == 200 + with scopes_disabled(): + assert order.positions.count() == 2 + op = order.positions.last() + assert op.item == item + assert op.price == item.default_price + assert op.positionid == 3 + + +@pytest.mark.django_db +def test_order_change_create_position_no_quota(token_client, organizer, event, order): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + payload = { + 'create_positions': [ + { + 'item': item2.pk, + }, + ] + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/change/'.format( + organizer.slug, event.slug, order.code, + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'quota' in str(resp.data) + + +@pytest.mark.django_db +def test_order_change_create_position_no_quota_check_quota_false(token_client, organizer, event, order): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + payload = { + 'create_positions': [ + { + 'item': item2.pk, + }, + ] + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/change/?check_quotas=false'.format( + organizer.slug, event.slug, order.code, + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'quota' in str(resp.data) + + +@pytest.mark.django_db +def test_order_change_create_position_quota_empty(token_client, organizer, event, order): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + q = event.quotas.create(name="No Quota", size=0) + q.items.add(item2) + q.save() + payload = { + 'create_positions': [ + { + 'item': item2.pk, + }, + ] + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/change/'.format( + organizer.slug, event.slug, order.code, + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'quota' in str(resp.data) + + +@pytest.mark.django_db +def test_order_change_create_position_quota_empty_check_quota_false(token_client, organizer, event, order): + with scopes_disabled(): + assert order.positions.count() == 1 + item2 = event.items.create(name="Budget Ticket", default_price=23) + q = event.quotas.create(name="No Quota", size=0) + q.items.add(item2) + q.save() + payload = { + 'create_positions': [ + { + 'item': item2.pk, + }, + ] + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/change/?check_quotas=false'.format( + organizer.slug, event.slug, order.code, + ), format='json', data=payload + ) + assert resp.status_code == 200 + with scopes_disabled(): + assert order.positions.count() == 2 + op = order.positions.last() + assert op.item == item2 + assert op.price == item2.default_price + assert op.positionid == 3 + + +@pytest.mark.django_db +def test_order_change_create_position_quota_empty_check_quota_true(token_client, organizer, event, order): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + q = event.quotas.create(name="No Quota", size=0) + q.items.add(item2) + q.save() + payload = { + 'create_positions': [ + { + 'item': item2.pk, + }, + ] + } + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/change/?check_quotas=true'.format( + organizer.slug, event.slug, order.code, + ), format='json', data=payload + ) + assert resp.status_code == 400 + assert 'quota' in str(resp.data) + + @pytest.mark.django_db def test_order_change_cancel_and_create(token_client, organizer, event, order, quota, item): with scopes_disabled():