diff --git a/doc/api/resources/invoices.rst b/doc/api/resources/invoices.rst index 85d11943c6..422eb5d23b 100644 --- a/doc/api/resources/invoices.rst +++ b/doc/api/resources/invoices.rst @@ -12,6 +12,7 @@ The invoice resource contains the following public fields: Field Type Description ===================================== ========================== ======================================================= number string Invoice number (with prefix) +event string The slug of the parent event order string Order code of the order this invoice belongs to is_cancellation boolean ``true``, if this invoice is the cancellation of a different invoice. @@ -121,9 +122,13 @@ internal_reference string Customer's refe The attribute ``lines.subevent`` has been added. +.. versionchanged:: 2023.8 -Endpoints ---------- + The ``event`` attribute has been added. The organizer-level endpoint has been added. + + +List of all invoices +-------------------- .. http:get:: /api/v1/organizers/(organizer)/events/(event)/invoices/ @@ -152,6 +157,7 @@ Endpoints "results": [ { "number": "SAMPLECONF-00001", + "event": "sampleconf", "order": "ABC12", "is_cancellation": false, "invoice_from_name": "Big Events LLC", @@ -221,6 +227,50 @@ Endpoints :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)/invoices/ + + Returns a list of all invoices within all events of a given organizer (with sufficient access permissions). + + Supported query parameters and output format of this endpoint are identical to the list endpoint within an event. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/invoices/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "number": "SAMPLECONF-00001", + "event": "sampleconf", + "order": "ABC12", + ... + ] + } + + :param organizer: The ``slug`` field of the organizer to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + + +Fetching individual invoices +---------------------------- + .. http:get:: /api/v1/organizers/(organizer)/events/(event)/invoices/(number)/ Returns information on one invoice, identified by its invoice number. @@ -243,6 +293,7 @@ Endpoints { "number": "SAMPLECONF-00001", + "event": "sampleconf", "order": "ABC12", "is_cancellation": false, "invoice_from_name": "Big Events LLC", @@ -337,6 +388,12 @@ Endpoints :statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few seconds. + +Modifying invoices +------------------ + +Invoices cannot be edited directly, but the following actions can be triggered: + .. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(invoice_no)/reissue/ Cancels the invoice and creates a new one. diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 66553bf48d..7c3420f381 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -20,6 +20,7 @@ The order resource contains the following public fields: Field Type Description ===================================== ========================== ======================================================= code string Order code +event string The slug of the parent event status string Order status, one of: * ``n`` – pending @@ -130,6 +131,10 @@ last_modified datetime Last modificati The ``valid_if_pending`` attribute has been added. +.. versionchanged:: 2023.8 + + The ``event`` attribute has been added. The organizer-level endpoint has been added. + .. _order-position-resource: @@ -289,6 +294,7 @@ List of all orders "results": [ { "code": "ABC12", + "event": "sampleconf", "status": "p", "testmode": false, "secret": "k24fiuwvu8kxz3y1", @@ -441,6 +447,48 @@ List of all orders :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)/orders/ + + Returns a list of all orders within all events of a given organizer (with sufficient access permissions). + + Supported query parameters and output format of this endpoint are identical to the list endpoint within an event, + with the exception that the ``pdf_data`` parameter is not supported here. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/orders/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + X-Page-Generated: 2017-12-01T10:00:00Z + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "code": "ABC12", + "event": "sampleconf", + ... + } + ] + } + + :param organizer: The ``slug`` field of the organizer to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + Fetching individual orders -------------------------- @@ -466,6 +514,7 @@ Fetching individual orders { "code": "ABC12", + "event": "sampleconf", "status": "p", "testmode": false, "secret": "k24fiuwvu8kxz3y1", diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index a34321a4d6..ae9b0c3349 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -614,7 +614,7 @@ class PaymentURLField(serializers.URLField): def to_representation(self, instance: OrderPayment): if instance.state != OrderPayment.PAYMENT_STATE_CREATED: return None - return build_absolute_uri(self.context['event'], 'presale:event.order.pay', kwargs={ + return build_absolute_uri(instance.order.event, 'presale:event.order.pay', kwargs={ 'order': instance.order.code, 'secret': instance.order.secret, 'payment': instance.pk, @@ -659,7 +659,7 @@ class OrderRefundSerializer(I18nAwareModelSerializer): class OrderURLField(serializers.URLField): def to_representation(self, instance: Order): - return build_absolute_uri(self.context['event'], 'presale:event.order', kwargs={ + return build_absolute_uri(instance.event, 'presale:event.order', kwargs={ 'order': instance.code, 'secret': instance.secret, }) @@ -694,6 +694,7 @@ class OrderListSerializer(serializers.ListSerializer): class OrderSerializer(I18nAwareModelSerializer): + event = SlugRelatedField(slug_field='slug', read_only=True) invoice_address = InvoiceAddressSerializer(allow_null=True) positions = OrderPositionSerializer(many=True, read_only=True) fees = OrderFeeSerializer(many=True, read_only=True) @@ -709,7 +710,7 @@ class OrderSerializer(I18nAwareModelSerializer): model = Order list_serializer_class = OrderListSerializer fields = ( - 'code', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date', + 'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date', 'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads', 'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'url', 'customer', 'valid_if_pending' @@ -1593,6 +1594,7 @@ class InlineInvoiceLineSerializer(I18nAwareModelSerializer): class InvoiceSerializer(I18nAwareModelSerializer): + event = SlugRelatedField(slug_field='slug', read_only=True) order = serializers.SlugRelatedField(slug_field='code', read_only=True) refers = serializers.SlugRelatedField(slug_field='full_invoice_no', read_only=True) lines = InlineInvoiceLineSerializer(many=True) @@ -1601,7 +1603,7 @@ class InvoiceSerializer(I18nAwareModelSerializer): class Meta: model = Invoice - fields = ('order', 'number', 'is_cancellation', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode', + fields = ('event', 'order', 'number', 'is_cancellation', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode', 'invoice_from_city', 'invoice_from_country', 'invoice_from_tax_id', 'invoice_from_vat_id', 'invoice_to', 'invoice_to_company', 'invoice_to_name', 'invoice_to_street', 'invoice_to_zipcode', 'invoice_to_city', 'invoice_to_state', 'invoice_to_country', 'invoice_to_vat_id', 'invoice_to_beneficiary', diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index b86ded3a2a..2434dd531d 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -61,6 +61,8 @@ orga_router.register(r'membershiptypes', organizer.MembershipTypeViewSet) orga_router.register(r'reusablemedia', media.ReusableMediaViewSet) orga_router.register(r'teams', organizer.TeamViewSet) orga_router.register(r'devices', organizer.DeviceViewSet) +orga_router.register(r'orders', order.OrganizerOrderViewSet) +orga_router.register(r'invoices', order.InvoiceViewSet) orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters') team_router = routers.DefaultRouter() @@ -77,7 +79,7 @@ event_router.register(r'questions', item.QuestionViewSet) event_router.register(r'discounts', discount.DiscountViewSet) event_router.register(r'quotas', item.QuotaViewSet) event_router.register(r'vouchers', voucher.VoucherViewSet) -event_router.register(r'orders', order.OrderViewSet) +event_router.register(r'orders', order.EventOrderViewSet) event_router.register(r'orderpositions', order.OrderPositionViewSet) event_router.register(r'invoices', order.InvoiceViewSet) event_router.register(r'revokedsecrets', order.RevokedSecretViewSet, basename='revokedsecrets') diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 573aae92da..456ea6e8d9 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -44,6 +44,7 @@ from rest_framework.exceptions import ( APIException, NotFound, PermissionDenied, ValidationError, ) from rest_framework.mixins import CreateModelMixin +from rest_framework.permissions import SAFE_METHODS from rest_framework.response import Response from pretix.api.models import OAuthAccessToken @@ -185,7 +186,7 @@ with scopes_disabled(): ) -class OrderViewSet(viewsets.ModelViewSet): +class OrderViewSetMixin: serializer_class = OrderSerializer queryset = Order.objects.none() filter_backends = (DjangoFilterBackend, TotalOrderingFilter) @@ -193,19 +194,12 @@ class OrderViewSet(viewsets.ModelViewSet): ordering_fields = ('datetime', 'code', 'status', 'last_modified') filterset_class = OrderFilter lookup_field = 'code' - permission = 'can_view_orders' - write_permission = 'can_change_orders' - def get_serializer_context(self): - ctx = super().get_serializer_context() - ctx['event'] = self.request.event - ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true' - ctx['exclude'] = self.request.query_params.getlist('exclude') - ctx['include'] = self.request.query_params.getlist('include') - return ctx + def get_base_queryset(self): + raise NotImplementedError() def get_queryset(self): - qs = self.request.event.orders + qs = self.get_base_queryset() if 'fees' not in self.request.GET.getlist('exclude'): if self.request.query_params.get('include_canceled_fees', 'false') == 'true': fqs = OrderFee.all @@ -227,11 +221,12 @@ class OrderViewSet(viewsets.ModelViewSet): opq = OrderPosition.all else: opq = OrderPosition.objects - if request.query_params.get('pdf_data', 'false') == 'true': + if request.query_params.get('pdf_data', 'false') == 'true' and getattr(request, 'event', None): prefetch_related_objects([request.organizer], 'meta_properties') prefetch_related_objects( [request.event], - Prefetch('meta_values', queryset=EventMetaValue.objects.select_related('property'), to_attr='meta_values_cached'), + Prefetch('meta_values', queryset=EventMetaValue.objects.select_related('property'), + to_attr='meta_values_cached'), 'questions', 'item_meta_properties', ) @@ -266,13 +261,12 @@ class OrderViewSet(viewsets.ModelViewSet): ) ) - def _get_output_provider(self, identifier): - responses = register_ticket_outputs.send(self.request.event) - for receiver, response in responses: - prov = response(self.request.event) - if prov.identifier == identifier: - return prov - raise NotFound('Unknown output provider.') + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['exclude'] = self.request.query_params.getlist('exclude') + ctx['include'] = self.request.query_params.getlist('include') + ctx['pdf_data'] = False + return ctx @scopes_disabled() # we are sure enough that get_queryset() is correct, so we save some perforamnce def list(self, request, **kwargs): @@ -289,6 +283,45 @@ class OrderViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(queryset, many=True) return Response(serializer.data, headers={'X-Page-Generated': date}) + +class OrganizerOrderViewSet(OrderViewSetMixin, viewsets.ReadOnlyModelViewSet): + def get_base_queryset(self): + perm = "can_view_orders" if self.request.method in SAFE_METHODS else "can_change_orders" + if isinstance(self.request.auth, (TeamAPIToken, Device)): + return Order.objects.filter( + event__organizer=self.request.organizer, + event__in=self.request.auth.get_events_with_permission(perm) + ) + elif self.request.user.is_authenticated: + return Order.objects.filter( + event__organizer=self.request.organizer, + event__in=self.request.user.get_events_with_permission(perm) + ) + else: + raise PermissionDenied() + + +class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet): + permission = 'can_view_orders' + write_permission = 'can_change_orders' + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['event'] = self.request.event + ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true' + return ctx + + def get_base_queryset(self): + return self.request.event.orders + + def _get_output_provider(self, identifier): + responses = register_ticket_outputs.send(self.request.event) + for receiver, response in responses: + prov = response(self.request.event) + if prov.identifier == identifier: + return prov + raise NotFound('Unknown output provider.') + @action(detail=True, url_name='download', url_path='download/(?P[^/]+)') def download(self, request, output, **kwargs): provider = self._get_output_provider(output) @@ -1782,11 +1815,24 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet): write_permission = 'can_change_orders' def get_queryset(self): - return self.request.event.invoices.prefetch_related('lines').select_related('order', 'refers').annotate( + perm = "can_view_orders" if self.request.method in SAFE_METHODS else "can_change_orders" + if getattr(self.request, 'event', None): + qs = self.request.event.invoices + elif isinstance(self.request.auth, (TeamAPIToken, Device)): + qs = Invoice.objects.filter( + event__organizer=self.request.organizer, + event__in=self.request.auth.get_events_with_permission(perm) + ) + elif self.request.user.is_authenticated: + qs = Invoice.objects.filter( + event__organizer=self.request.organizer, + event__in=self.request.user.get_events_with_permission(perm) + ) + return qs.prefetch_related('lines').select_related('order', 'refers').annotate( nr=Concat('prefix', 'invoice_no') ) - @action(detail=True, ) + @action(detail=True) def download(self, request, **kwargs): invoice = self.get_object() @@ -1805,7 +1851,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet): return resp @action(detail=True, methods=['POST']) - def regenerate(self, request, **kwarts): + def regenerate(self, request, **kwargs): inv = self.get_object() if inv.canceled: raise ValidationError('The invoice has already been canceled.') @@ -1815,7 +1861,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet): raise PermissionDenied('The invoice file is no longer stored on the server.') elif inv.sent_to_organizer: raise PermissionDenied('The invoice file has already been exported.') - elif now().astimezone(self.request.event.timezone).date() - inv.date > datetime.timedelta(days=1): + elif now().astimezone(inv.event.timezone).date() - inv.date > datetime.timedelta(days=1): raise PermissionDenied('The invoice file is too old to be regenerated.') else: inv = regenerate_invoice(inv) @@ -1830,7 +1876,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet): return Response(status=204) @action(detail=True, methods=['POST']) - def reissue(self, request, **kwarts): + def reissue(self, request, **kwargs): inv = self.get_object() if inv.canceled: raise ValidationError('The invoice has already been canceled.') diff --git a/src/tests/api/test_invoices.py b/src/tests/api/test_invoices.py index 1659a29348..0cf7f8d81e 100644 --- a/src/tests/api/test_invoices.py +++ b/src/tests/api/test_invoices.py @@ -137,6 +137,37 @@ def order(event, item, taxrule, question): return o +@pytest.fixture +def order2(event2, item2): + testtime = datetime.datetime(2017, 12, 1, 10, 0, 0, tzinfo=datetime.timezone.utc) + + with mock.patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = testtime + o = Order.objects.create( + code='BAR', event=event2, email='dummy@dummy.test', + status=Order.STATUS_PENDING, secret="asd436cvbfd1", + datetime=datetime.datetime(2017, 12, 1, 10, 0, 0, tzinfo=datetime.timezone.utc), + expires=datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=datetime.timezone.utc), + total=23, locale='en' + ) + o.payments.create( + provider='banktransfer', + state='pending', + amount=Decimal('23.00'), + ) + OrderPosition.objects.create( + order=o, + item=item2, + variation=None, + price=Decimal("23"), + attendee_name_parts={"full_name": "Peter", "_scheme": "full"}, + secret="asdlfksdgdfgxcbfgdhfg", + pseudonymization_id="AC892345", + positionid=1, + ) + return o + + @pytest.fixture def invoice(order): testtime = datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=datetime.timezone.utc) @@ -146,8 +177,18 @@ def invoice(order): return generate_invoice(order) +@pytest.fixture +def invoice2(order2): + testtime = datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=datetime.timezone.utc) + + with mock.patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = testtime + return generate_invoice(order2) + + TEST_INVOICE_RES = { "order": "FOO", + "event": "dummy", "number": "DUMMY-00001", "is_cancellation": False, "invoice_from_name": "", @@ -268,6 +309,34 @@ def test_invoice_list(token_client, organizer, event, order, item, invoice): assert [] == resp.data['results'] +@pytest.mark.django_db +def test_organizer_level(token_client, organizer, team, event, event2, invoice, invoice2): + resp = token_client.get('/api/v1/organizers/{}/invoices/'.format(organizer.slug)) + assert resp.status_code == 200 + assert len(resp.data['results']) == 2 + + resp = token_client.get('/api/v1/organizers/{}/invoices/{}/'.format(organizer.slug, invoice.number)) + assert resp.status_code == 200 + + resp = token_client.get('/api/v1/organizers/{}/invoices/{}/'.format(organizer.slug, invoice2.number)) + assert resp.status_code == 200 + + with scopes_disabled(): + team.all_events = False + team.save() + team.limit_events.set([event2]) + + resp = token_client.get('/api/v1/organizers/{}/invoices/'.format(organizer.slug)) + assert resp.status_code == 200 + assert len(resp.data['results']) == 1 + + resp = token_client.get('/api/v1/organizers/{}/invoices/{}/'.format(organizer.slug, invoice.number)) + assert resp.status_code == 404 + + resp = token_client.get('/api/v1/organizers/{}/invoices/{}/'.format(organizer.slug, invoice2.number)) + assert resp.status_code == 200 + + @pytest.mark.django_db def test_invoice_detail(token_client, organizer, event, item, invoice): res = dict(TEST_INVOICE_RES) diff --git a/src/tests/api/test_order_change.py b/src/tests/api/test_order_change.py index 192cba732b..693b06ec1f 100644 --- a/src/tests/api/test_order_change.py +++ b/src/tests/api/test_order_change.py @@ -420,6 +420,7 @@ def test_order_create_invoice(token_client, organizer, event, order): pos = order.positions.first() assert json.loads(json.dumps(resp.data)) == { 'order': 'FOO', + 'event': 'dummy', 'number': 'DUMMY-00001', 'is_cancellation': False, "invoice_from_name": "", diff --git a/src/tests/api/test_order_create.py b/src/tests/api/test_order_create.py index 06e4f95dc4..feae91eb76 100644 --- a/src/tests/api/test_order_create.py +++ b/src/tests/api/test_order_create.py @@ -309,6 +309,7 @@ def test_order_create_simulate(token_client, organizer, event, item, quota, ques del d['positions'][0]['secret'] assert d == { 'code': 'PREVIEW', + 'event': 'dummy', 'status': 'n', 'testmode': False, 'email': 'dummy@dummy.test', diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index ba1e5c06fa..b520c0dd4d 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -139,6 +139,37 @@ def order(event, item, taxrule, question): return o +@pytest.fixture +def order2(event2, item2): + testtime = datetime.datetime(2017, 12, 1, 10, 0, 0, tzinfo=datetime.timezone.utc) + + with mock.patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = testtime + o = Order.objects.create( + code='BAR', event=event2, email='dummy@dummy.test', + status=Order.STATUS_PENDING, secret="asd436cvbfd1", + datetime=datetime.datetime(2017, 12, 1, 10, 0, 0, tzinfo=datetime.timezone.utc), + expires=datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=datetime.timezone.utc), + total=23, locale='en' + ) + o.payments.create( + provider='banktransfer', + state='pending', + amount=Decimal('23.00'), + ) + OrderPosition.objects.create( + order=o, + item=item2, + variation=None, + price=Decimal("23"), + attendee_name_parts={"full_name": "Peter", "_scheme": "full"}, + secret="asdlfksdgdfgxcbfgdhfg", + pseudonymization_id="AC892345", + positionid=1, + ) + return o + + @pytest.fixture def clist_autocheckin(event): c = event.checkin_lists.create(name="Default", all_products=True, auto_checkin_sales_channels=['web']) @@ -228,6 +259,7 @@ TEST_REFUNDS_RES = [ ] TEST_ORDER_RES = { "code": "FOO", + "event": "dummy", "status": "n", "testmode": False, "secret": "k24fiuwvu8kxz3y1", @@ -460,6 +492,34 @@ def test_order_detail(token_client, organizer, event, order, item, taxrule, ques assert len(resp.data['fees']) == 2 +@pytest.mark.django_db +def test_organizer_level(token_client, organizer, team, event, event2, order, order2): + resp = token_client.get('/api/v1/organizers/{}/orders/'.format(organizer.slug)) + assert resp.status_code == 200 + assert len(resp.data['results']) == 2 + + resp = token_client.get('/api/v1/organizers/{}/orders/FOO/'.format(organizer.slug)) + assert resp.status_code == 200 + + resp = token_client.get('/api/v1/organizers/{}/orders/BAR/'.format(organizer.slug)) + assert resp.status_code == 200 + + with scopes_disabled(): + team.all_events = False + team.save() + team.limit_events.set([event2]) + + resp = token_client.get('/api/v1/organizers/{}/orders/'.format(organizer.slug)) + assert resp.status_code == 200 + assert len(resp.data['results']) == 1 + + resp = token_client.get('/api/v1/organizers/{}/orders/FOO/'.format(organizer.slug)) + assert resp.status_code == 404 + + resp = token_client.get('/api/v1/organizers/{}/orders/BAR/'.format(organizer.slug)) + assert resp.status_code == 200 + + @pytest.mark.django_db def test_include_exclude_fields(token_client, organizer, event, order, item, taxrule, question): resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/{}/?exclude=positions.secret'.format(