From 4a0884aaca1429390a5786d36563ccbb9d82916e Mon Sep 17 00:00:00 2001 From: Lukas Bockstaller Date: Mon, 26 Jan 2026 14:23:16 +0100 Subject: [PATCH] initial implementation --- doc/api/resources/orders.rst | 56 +++++++++++++ src/pretix/api/urls.py | 1 + src/pretix/api/views/order.py | 8 +- src/tests/api/test_orders.py | 143 +++++++++++++--------------------- 4 files changed, 116 insertions(+), 92 deletions(-) diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index f863a6df45..1193a589b9 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -1719,6 +1719,13 @@ List of all order positions :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. +.. http:get:: /api/v1/organizers/(organizer)/orderpositions/ + + Returns a list of all order positions. + It works in the same way as :http:get:`/api/v1/organizers/(organizer)/events/(event)/orderpositions/` + but does not make use of the event parameter, operating across all order positions of an organizer. + + Fetching individual positions ----------------------------- @@ -1820,6 +1827,12 @@ Fetching individual positions :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. :statuscode 404: The requested order position does not exist. +.. http:get:: /api/v1/organizers/(organizer)/orderpositions/(id)/ + + Returns information on one order position, identified by its internal ID. + It works in the same way as :http:get:`/api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)` + but does not make use of the event parameter, operating across all order positions of an organizer. + .. _`order-position-ticket-download`: Order position ticket download @@ -1872,6 +1885,12 @@ Order position ticket download :statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few seconds. +.. http:get:: /api/v1/organizers/(organizer)/orderpositions/(id)/download/(output)/ + + Download tickets for one order position, identified by its internal ID. + It works in the same way as :http:get:`/api/v1/organizers/(organizer)/orderpositions/(id)/download/(output)/` + but does not make use of the event parameter, operating across all order positions of an organizer. + .. _rest-orderpositions-manipulate: Manipulating individual positions @@ -1964,6 +1983,12 @@ Manipulating individual positions :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order. +.. http:patch:: /api/v1/organizers/(organizer)/orderpositions/(id)/ + + Updates specific fields of an order position. + It works in the same way as :http:patch:`/api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/` + but does not make use of the event parameter, operating across all order positions of an organizer. + .. http:post:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/ Adds a new position to an order. Currently, only the following fields are supported: @@ -2043,6 +2068,12 @@ Manipulating individual positions :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this position. +.. http:post:: /api/v1/organizers/(organizer)/orderpositions/ + + Adds a new position to an order. + It works in the same way as :http:post:`/api/v1/organizers/(organizer)/events/(event)/orderpositions/` + but does not make use of the event parameter, operating across all order positions of an organizer. + .. http:delete:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/ Cancels an order position, identified by its internal ID. @@ -2071,6 +2102,12 @@ Manipulating individual positions :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. :statuscode 404: The requested order position does not exist. +.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/ + + Cancels an order position, identified by its internal ID. + It works in the same way as :http:delete:`/api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/` + but does not make use of the event parameter, operating across all order positions of an organizer. + .. http:post:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/add_block/ Blocks an order position from being used. The block name either needs to be ``"admin"`` or start with ``"api:"``. It @@ -2109,6 +2146,12 @@ Manipulating individual positions :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order position. +.. http:post:: /api/v1/organizers/(organizer)/orderpositions/(id)/add_block/ + + Blocks an order position from being used. + It works in the same way as :http:post:`/api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/add_block/` + but does not make use of the event parameter, operating across all order positions of an organizer. + .. http:post:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/remove_block/ Unblocks an order position from being used. The block name either needs to be ``"admin"`` or start with ``"api:"``. It @@ -2147,6 +2190,12 @@ Manipulating individual positions :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order position. +.. http:post:: /api/v1/organizers/(organizer)/orderpositions/(id)/remove_block/ + + Unblocks an order position from being used. + It works in the same way as :http:post:`/api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/remove_block/` + but does not make use of the event parameter, operating across all order positions of an organizer. + .. http:post:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/printlog/ Creates a print log, stating that this ticket has been printed. @@ -2200,6 +2249,13 @@ Manipulating individual positions :statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few seconds. +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/printlog/ + + Creates a print log, stating that this ticket has been printed. + It works in the same way as :http:post:`/api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/printlog/` + but does not make use of the event parameter, operating across all order positions of an organizer. + + Changing order contents ----------------------- diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index a4db75dfdd..21662413ba 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -67,6 +67,7 @@ orga_router.register(r'invoices', order.InvoiceViewSet) orga_router.register(r'scheduled_exports', exporters.ScheduledOrganizerExportViewSet) orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters') orga_router.register(r'transactions', order.OrganizerTransactionViewSet) +orga_router.register(r'orderpositions', order.OrderPositionViewSet) team_router = routers.DefaultRouter() team_router.register(r'members', organizer.TeamMemberViewSet) diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 2b5a7f82bd..dd31abf4cc 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -1093,7 +1093,8 @@ class OrderPositionViewSet(viewsets.ModelViewSet): def get_serializer_context(self): ctx = super().get_serializer_context() - ctx['event'] = self.request.event + if hasattr(self.request, 'event'): + ctx['event'] = self.request.event ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false').lower() == 'true' ctx['check_quotas'] = self.request.query_params.get('check_quotas', 'true').lower() == 'true' return ctx @@ -1104,7 +1105,10 @@ class OrderPositionViewSet(viewsets.ModelViewSet): else: qs = OrderPosition.objects - qs = qs.filter(order__event=self.request.event) + if hasattr(self.request, 'event'): + qs = qs.filter(order__event=self.request.event) + if hasattr(self.request, 'organizer'): + qs = qs.filter(order__event__organizer=self.request.organizer) if self.request.query_params.get('pdf_data', 'false').lower() == 'true': prefetch_related_objects([self.request.organizer], 'meta_properties') prefetch_related_objects( diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index 6f41ac82b5..49dbf3f4b5 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -987,8 +987,11 @@ def test_refund_cancel(token_client, organizer, event, order): assert resp.status_code == 400 +@pytest.mark.parametrize("endpoint_template", [('/api/v1/organizers/{}/events/{}/orderpositions/'),('/api/v1/organizers/{}/orderpositions/')]) @pytest.mark.django_db -def test_orderposition_list(token_client, organizer, device, event, order, item, subevent, subevent2, question, django_assert_num_queries): +def test_orderposition_list(endpoint_template, token_client, organizer, device, event, order, item, subevent, subevent2, question, django_assert_num_queries): + endpoint = '/api/v1/organizers/{}/events/{}/orderpositions/'.format(organizer.slug, event.slug) + i2 = copy.copy(item) i2.pk = None i2.save() @@ -1006,87 +1009,61 @@ def test_orderposition_list(token_client, organizer, device, event, order, item, res["print_logs"][0]["id"] = op.print_logs.first().pk res["print_logs"][0]["device_id"] = device.device_id - resp = token_client.get('/api/v1/organizers/{}/events/{}/orderpositions/'.format(organizer.slug, event.slug)) + resp = token_client.get(endpoint) assert resp.status_code == 200 assert [res] == resp.data['results'] - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?order__status=n'.format(organizer.slug, event.slug)) + resp = token_client.get(endpoint+'?order__status=n') assert [res] == resp.data['results'] - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?order__status=p'.format(organizer.slug, event.slug)) + resp = token_client.get(endpoint+'?order__status=p') assert [] == resp.data['results'] - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?item={}'.format(organizer.slug, event.slug, item.pk)) + resp = token_client.get(endpoint+'?item={}'.format(item.pk)) assert [res] == resp.data['results'] - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?item__in={},{}'.format( - organizer.slug, event.slug, item.pk, i2.pk - )) + resp = token_client.get(endpoint+'?item__in={},{}'.format(item.pk, i2.pk)) assert [res] == resp.data['results'] - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?item={}'.format(organizer.slug, event.slug, i2.pk)) + resp = token_client.get(endpoint+'?item={}'.format( i2.pk)) assert [] == resp.data['results'] - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?variation={}'.format(organizer.slug, event.slug, var.pk)) + resp = token_client.get(endpoint+'?variation={}'.format(var.pk)) assert [res] == resp.data['results'] - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?variation={}'.format(organizer.slug, event.slug, var2.pk)) + resp = token_client.get(endpoint+'?variation={}'.format(var2.pk)) assert [] == resp.data['results'] - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?attendee_name=Peter'.format(organizer.slug, event.slug)) + resp = token_client.get(endpoint+'?attendee_name=Peter') assert [res] == resp.data['results'] - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?attendee_name=peter'.format(organizer.slug, event.slug)) + resp = token_client.get(endpoint+'?attendee_name=peter') assert [res] == resp.data['results'] - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?attendee_name=Mark'.format(organizer.slug, event.slug)) + resp = token_client.get(endpoint+'?attendee_name=Mark') assert [] == resp.data['results'] - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?secret=z3fsn8jyufm5kpk768q69gkbyr5f4h6w'.format( - organizer.slug, event.slug)) + resp = token_client.get(endpoint+'?secret=z3fsn8jyufm5kpk768q69gkbyr5f4h6w') assert [res] == resp.data['results'] - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?secret=abc123'.format(organizer.slug, event.slug)) + resp = token_client.get(endpoint+'?secret=abc123') assert [] == resp.data['results'] - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?pseudonymization_id=ABCDEFGHKL'.format( - organizer.slug, event.slug)) + resp = token_client.get(endpoint+'?pseudonymization_id=ABCDEFGHKL') assert [res] == resp.data['results'] - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?pseudonymization_id=FOO'.format(organizer.slug, event.slug)) + resp = token_client.get(endpoint+'?pseudonymization_id=FOO') assert [] == resp.data['results'] - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?search=FO'.format(organizer.slug, event.slug)) + resp = token_client.get(endpoint+'?search=FO') assert [res] == resp.data['results'] - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?search=z3fsn8j'.format(organizer.slug, event.slug)) + resp = token_client.get(endpoint+'?search=z3fsn8j') assert [res] == resp.data['results'] - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?search=Peter'.format(organizer.slug, event.slug)) + resp = token_client.get(endpoint+'?search=Peter') assert [res] == resp.data['results'] - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?search=5f4h6w'.format(organizer.slug, event.slug)) + resp = token_client.get(endpoint+'?search=5f4h6w') assert [] == resp.data['results'] - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?order=FOO'.format(organizer.slug, event.slug)) + resp = token_client.get(endpoint+'?order=FOO') assert [res] == resp.data['results'] - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?order=BAR'.format(organizer.slug, event.slug)) + resp = token_client.get(endpoint+'?order=BAR') assert [] == resp.data['results'] - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?has_checkin=false'.format(organizer.slug, event.slug)) + resp = token_client.get(endpoint+'?has_checkin=false') assert [res] == resp.data['results'] - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?has_checkin=true'.format(organizer.slug, event.slug)) + resp = token_client.get(endpoint+'?has_checkin=true') assert [] == resp.data['results'] with scopes_disabled(): @@ -1104,37 +1081,30 @@ def test_orderposition_list(token_client, organizer, device, event, order, item, 'type': 'entry' }] with django_assert_num_queries(16): - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?has_checkin=true'.format(organizer.slug, event.slug) - ) + resp = token_client.get(endpoint+'?has_checkin=true') assert [res] == resp.data['results'] op.subevent = subevent op.save() res['subevent'] = subevent.pk - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?subevent={}'.format(organizer.slug, event.slug, subevent.pk)) + resp = token_client.get(endpoint+'?subevent={}'.format(subevent.pk)) assert [res] == resp.data['results'] - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?subevent__in={},{}'.format(organizer.slug, event.slug, - subevent.pk, subevent2.pk)) + resp = token_client.get(endpoint+'?subevent__in={},{}'.format(subevent.pk, subevent2.pk)) assert [res] == resp.data['results'] - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?subevent={}'.format(organizer.slug, event.slug, - subevent.pk + 1)) + resp = token_client.get(endpoint+'?subevent={}'.format(subevent.pk + 1)) assert [] == resp.data['results'] - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?include_canceled_positions=false'.format(organizer.slug, event.slug)) + resp = token_client.get(endpoint+'?include_canceled_positions=false') assert len(resp.data['results']) == 1 - resp = token_client.get( - '/api/v1/organizers/{}/events/{}/orderpositions/?include_canceled_positions=true'.format(organizer.slug, event.slug)) + resp = token_client.get(endpoint+'?include_canceled_positions=true') assert len(resp.data['results']) == 2 - +@pytest.mark.parametrize("endpoint_template", [('/api/v1/organizers/{}/events/{}/orderpositions/'),('/api/v1/organizers/{}/orderpositions/')]) @pytest.mark.django_db -def test_orderposition_detail(token_client, organizer, event, order, item, question): +def test_orderposition_detail(endpoint_template, token_client, organizer, event, order, item, question): + endpoint = endpoint_template.format(organizer.slug, event.slug) + res = dict(TEST_ORDERPOSITION_RES) with scopes_disabled(): op = order.positions.first() @@ -1142,38 +1112,34 @@ def test_orderposition_detail(token_client, organizer, event, order, item, quest res["id"] = op.pk res["item"] = item.pk res["answers"][0]["question"] = question.pk - resp = token_client.get('/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format(organizer.slug, event.slug, - op.pk)) + resp = token_client.get(endpoint + '{}/'.format(op.pk)) assert resp.status_code == 200 assert res == resp.data order.status = 'p' order.save() event.settings.ticketoutput_pdf__enabled = True - resp = token_client.get('/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format(organizer.slug, event.slug, - op.pk)) + resp = token_client.get(endpoint+'{}/'.format(op.pk)) assert len(resp.data['downloads']) == 1 - +@pytest.mark.parametrize("endpoint_template", [('/api/v1/organizers/{}/events/{}/orderpositions/'),('/api/v1/organizers/{}/orderpositions/')]) @pytest.mark.django_db -def test_orderposition_detail_canceled(token_client, organizer, event, order, item, question): +def test_orderposition_detail_canceled(endpoint_template, token_client, organizer, event, order, item, question): + endpoint = endpoint_template.format(organizer.slug, event.slug) with scopes_disabled(): op = order.all_positions.filter(canceled=True).first() - resp = token_client.get('/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format(organizer.slug, event.slug, - op.pk)) + resp = token_client.get(endpoint+'{}/'.format(op.pk)) assert resp.status_code == 404 - resp = token_client.get('/api/v1/organizers/{}/events/{}/orderpositions/{}/?include_canceled_positions=true'.format( - organizer.slug, event.slug, op.pk)) + resp = token_client.get(endpoint+'{}/?include_canceled_positions=true'.format(op.pk)) assert resp.status_code == 200 - +@pytest.mark.parametrize("endpoint_template", [('/api/v1/organizers/{}/events/{}/orderpositions/'),('/api/v1/organizers/{}/orderpositions/')]) @pytest.mark.django_db -def test_orderposition_delete(token_client, organizer, event, order, item, question): +def test_orderposition_delete(endpoint_template, token_client, organizer, event, order, item, question): + endpoint = endpoint_template.format(organizer.slug, event.slug) with scopes_disabled(): op = order.positions.first() - resp = token_client.delete('/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( - organizer.slug, event.slug, op.pk - )) + resp = token_client.delete(endpoint+'{}/'.format(op.pk)) assert resp.status_code == 400 assert resp.data == ['This operation would leave the order empty. Please cancel the order itself instead.'] @@ -1192,9 +1158,7 @@ def test_orderposition_delete(token_client, organizer, event, order, item, quest order.save() assert order.positions.count() == 2 - resp = token_client.delete('/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format( - organizer.slug, event.slug, op2.pk - )) + resp = token_client.delete(endpoint+'{}/'.format(op2.pk)) assert resp.status_code == 204 with scopes_disabled(): assert order.positions.count() == 1 @@ -1202,14 +1166,13 @@ def test_orderposition_delete(token_client, organizer, event, order, item, quest order.refresh_from_db() assert order.total == Decimal('23.25') - +@pytest.mark.parametrize("endpoint_template", [('/api/v1/organizers/{}/events/{}/orderpositions/'),('/api/v1/organizers/{}/orderpositions/')]) @pytest.mark.django_db -def test_orderposition_printlog(token_client, team, organizer, event, order, item, question): +def test_orderposition_printlog(endpoint_template, token_client, team, organizer, event, order, item, question): + endpoint = endpoint_template.format(organizer.slug, event.slug) with scopes_disabled(): op = order.positions.first() - resp = token_client.post('/api/v1/organizers/{}/events/{}/orderpositions/{}/printlog/'.format( - organizer.slug, event.slug, op.pk - ), data={ + resp = token_client.post(endpoint+'{}/printlog/'.format(op.pk), data={ "datetime": "2023-09-04T12:23:45+02:00", "source": "pretixscan", "type": "badge",