From b2f92acbf667ba0f1113b0a2c848435c10f150d3 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Sun, 29 Apr 2018 14:29:03 +0200 Subject: [PATCH] Refs #654 -- API: Writable invoice operations (#886) * Invoices * Update invoices.rst --- doc/api/resources/invoices.rst | 56 +++++++++++++++++++++++++++++++ src/pretix/api/views/order.py | 47 ++++++++++++++++++++++++-- src/tests/api/test_orders.py | 27 +++++++++++++++ src/tests/api/test_permissions.py | 3 ++ 4 files changed, 131 insertions(+), 2 deletions(-) diff --git a/doc/api/resources/invoices.rst b/doc/api/resources/invoices.rst index 3a176c26f4..28b657dc44 100644 --- a/doc/api/resources/invoices.rst +++ b/doc/api/resources/invoices.rst @@ -223,3 +223,59 @@ Endpoints :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. :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)/invoices/(invoice_no)/reissue/ + + Cancels the invoice and creates a new one. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/invoices/00001/regenerate/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 204 No Content + Vary: Accept + Content-Type: application/pdf + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param invoice_no: The ``invoice_no`` field of the invoice to regenerate + :statuscode 200: no error + :statuscode 400: The invoice has already been canceled + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource. + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(invoice_no)/reissue/ + + Re-generates the invoice from order data. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/invoices/00001/reissue/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 204 No Content + Vary: Accept + Content-Type: application/pdf + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param invoice_no: The ``invoice_no`` field of the invoice to reissue + :statuscode 200: no error + :statuscode 400: The invoice has already been canceled + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource. diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index cc56444f61..2705b96e22 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -9,7 +9,9 @@ from django.utils.timezone import make_aware from django_filters.rest_framework import DjangoFilterBackend, FilterSet from rest_framework import serializers, status, viewsets from rest_framework.decorators import detail_route -from rest_framework.exceptions import APIException, NotFound, PermissionDenied +from rest_framework.exceptions import ( + APIException, NotFound, PermissionDenied, ValidationError, +) from rest_framework.filters import OrderingFilter from rest_framework.response import Response @@ -18,7 +20,9 @@ from pretix.api.serializers.order import ( ) from pretix.base.models import Invoice, Order, OrderPosition, Quota from pretix.base.models.organizer import TeamAPIToken -from pretix.base.services.invoices import invoice_pdf +from pretix.base.services.invoices import ( + generate_cancellation, generate_invoice, invoice_pdf, regenerate_invoice, +) from pretix.base.services.mail import SendMailException from pretix.base.services.orders import ( OrderError, cancel_order, extend_order, mark_order_expired, @@ -326,6 +330,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet): permission = 'can_view_orders' lookup_url_kwarg = 'number' lookup_field = 'nr' + write_permission = 'can_change_orders' def get_queryset(self): return self.request.event.invoices.prefetch_related('lines').select_related('order', 'refers').annotate( @@ -346,3 +351,41 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet): resp = FileResponse(invoice.file.file, content_type='application/pdf') resp['Content-Disposition'] = 'attachment; filename="{}.pdf"'.format(invoice.number) return resp + + @detail_route(methods=['POST']) + def regenerate(self, request, **kwarts): + inv = self.get_object() + if inv.canceled: + raise ValidationError('The invoice has already been canceled.') + else: + inv = regenerate_invoice(inv) + inv.order.log_action( + 'pretix.event.order.invoice.regenerated', + data={ + 'invoice': inv.pk + }, + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + ) + return Response(status=204) + + @detail_route(methods=['POST']) + def reissue(self, request, **kwarts): + inv = self.get_object() + if inv.canceled: + raise ValidationError('The invoice has already been canceled.') + else: + c = generate_cancellation(inv) + if inv.order.status not in (Order.STATUS_CANCELED, Order.STATUS_REFUNDED): + inv = generate_invoice(inv.order) + else: + inv = c + inv.order.log_action( + 'pretix.event.order.invoice.reissued', + data={ + 'invoice': inv.pk + }, + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + ) + return Response(status=204) diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index 4a7f7ef9a3..2583c9ef30 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -430,6 +430,33 @@ def test_invoice_detail(token_client, organizer, event, invoice): assert res == resp.data +@pytest.mark.django_db +def test_invoice_regenerate(token_client, organizer, event, invoice): + InvoiceAddress.objects.filter(order=invoice.order).update(company="ACME Ltd") + + resp = token_client.post('/api/v1/organizers/{}/events/{}/invoices/{}/regenerate/'.format( + organizer.slug, event.slug, invoice.number + )) + assert resp.status_code == 204 + invoice.refresh_from_db() + assert "ACME Ltd" in invoice.invoice_to + + +@pytest.mark.django_db +def test_invoice_reissue(token_client, organizer, event, invoice): + InvoiceAddress.objects.filter(order=invoice.order).update(company="ACME Ltd") + + resp = token_client.post('/api/v1/organizers/{}/events/{}/invoices/{}/reissue/'.format( + organizer.slug, event.slug, invoice.number + )) + assert resp.status_code == 204 + invoice.refresh_from_db() + assert "ACME Ltd" not in invoice.invoice_to + assert invoice.order.invoices.count() == 3 + invoice = invoice.order.invoices.last() + assert "ACME Ltd" in invoice.invoice_to + + @pytest.mark.django_db def test_order_mark_paid_pending(token_client, organizer, event, order): resp = token_client.post( diff --git a/src/tests/api/test_permissions.py b/src/tests/api/test_permissions.py index 83e554deeb..c3ca75652a 100644 --- a/src/tests/api/test_permissions.py +++ b/src/tests/api/test_permissions.py @@ -25,6 +25,9 @@ event_permission_sub_urls = [ ('get', 'can_view_orders', 'orderpositions/', 200), ('get', 'can_view_vouchers', 'vouchers/', 200), ('get', 'can_view_orders', 'invoices/', 200), + ('get', 'can_view_orders', 'invoices/1/', 404), + ('post', 'can_change_orders', 'invoices/1/regenerate/', 404), + ('post', 'can_change_orders', 'invoices/1/reissue/', 404), ('get', 'can_view_orders', 'waitinglistentries/', 200), ('get', 'can_view_orders', 'waitinglistentries/1/', 404), ('post', 'can_change_orders', 'waitinglistentries/', 400),