diff --git a/doc/plugins/ticketoutputpdf.rst b/doc/plugins/ticketoutputpdf.rst index e82fe5cc3..2fdd9b55a 100644 --- a/doc/plugins/ticketoutputpdf.rst +++ b/doc/plugins/ticketoutputpdf.rst @@ -29,8 +29,8 @@ item_assignments list of objects Products this l ===================================== ========================== ======================================================= -Endpoints ---------- +Layout endpoints +---------------- .. http:get:: /api/v1/organizers/(organizer)/events/(event)/ticketlayouts/ @@ -268,5 +268,75 @@ Endpoints :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource. +API ticket rendering +-------------------- + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/ticketpdfrenderer/render_batch/ + + With this API call, you can instruct the system to render a set of tickets into one combined PDF file. To specify + which tickets to render, you need to submit a list of "parts". For every part, the following fields are supported: + + * ``orderposition`` (``integer``, required): The ID of the order position to render. + * ``override_channel`` (``string``, optional): The sales channel ID to be used for layout selection instead of the + original channel of the order. + * ``override_layout`` (``integer``, optional): The ticket layout ID to be used instead of the auto-selected one. + + If your input parameters validate correctly, a ``202 Accepted`` status code is returned. + The body points you to the download URL of the result. Running a ``GET`` request on that result URL will + yield one of the following status codes: + + * ``200 OK`` – The export succeeded. The body will be your resulting file. Might be large! + * ``409 Conflict`` – Your export is still running. The body will be JSON with the structure ``{"status": "running", "percentage": 40}``. ``percentage`` can be ``null`` if it is not known and ``status`` can be ``waiting`` before the task is actually being processed. Please retry, but wait at least one second before you do. + * ``410 Gone`` – Running the export has failed permanently. The body will be JSON with the structure ``{"status": "failed", "message": "Error message"}`` + * ``404 Not Found`` – The export does not exist / is expired. + + .. warning:: This endpoint is considered **experimental**. It might change at any time without prior notice. + + .. note:: To avoid performance issues, a maximum number of 1000 parts is currently allowed. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/ticketpdfrenderer/render_batch/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "parts": [ + { + "orderposition": 55412 + }, + { + "orderposition": 55412, + "override_channel": "web" + }, + { + "orderposition": 55412, + "override_layout": 56 + } + ] + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "download": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/ticketpdfrenderer/download/29891ede-196f-4942-9e26-d055a36e98b8/3f279f13-c198-4137-b49b-9b360ce9fcce/" + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :statuscode 202: no error + :statuscode 400: Invalid input options + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + .. _here: https://github.com/pretix/pretix/blob/master/src/pretix/static/schema/pdf-layout.schema.json diff --git a/src/pretix/plugins/ticketoutputpdf/api.py b/src/pretix/plugins/ticketoutputpdf/api.py index 3d3a80c8d..f02be6893 100644 --- a/src/pretix/plugins/ticketoutputpdf/api.py +++ b/src/pretix/plugins/ticketoutputpdf/api.py @@ -201,7 +201,7 @@ class RenderJobSerializer(serializers.Serializer): def validate(self, attrs): if len(attrs["parts"]) > 1000: - raise ValidationError("Please do not submit more than 1000 parts.") + raise ValidationError({"parts": ["Please do not submit more than 1000 parts."]}) return super().validate(attrs) diff --git a/src/pretix/plugins/ticketoutputpdf/tasks.py b/src/pretix/plugins/ticketoutputpdf/tasks.py index ccfd2f9dc..c28f8e300 100644 --- a/src/pretix/plugins/ticketoutputpdf/tasks.py +++ b/src/pretix/plugins/ticketoutputpdf/tasks.py @@ -140,6 +140,7 @@ def bulk_render(event: Event, fileid: int, parts: list) -> int: merger.close() outbuffer.seek(0) + file.type = "application/pdf" file.file.save(cachedfile_name(file, file.filename), ContentFile(outbuffer.getvalue())) file.save() return file.pk diff --git a/src/tests/plugins/ticketoutputpdf/test_api.py b/src/tests/plugins/ticketoutputpdf/test_api.py index 187678ae5..b90d5bb47 100644 --- a/src/tests/plugins/ticketoutputpdf/test_api.py +++ b/src/tests/plugins/ticketoutputpdf/test_api.py @@ -21,6 +21,8 @@ # import copy import json +from datetime import timedelta +from decimal import Decimal import pytest from django.core.files.base import ContentFile @@ -28,7 +30,9 @@ from django.utils.timezone import now from django_scopes import scopes_disabled from rest_framework.test import APIClient -from pretix.base.models import Event, Item, Organizer, Team +from pretix.base.models import ( + Event, Item, Order, OrderPosition, Organizer, Team, +) from pretix.plugins.ticketoutputpdf.models import TicketLayoutItem @@ -39,14 +43,35 @@ def env(): organizer=o, name='Dummy', slug='dummy', date_from=now(), plugins='pretix.plugins.banktransfer' ) - t = Team.objects.create(organizer=event.organizer) + t = Team.objects.create(organizer=event.organizer, can_view_orders=True) t.limit_events.add(event) item1 = Item.objects.create(event=event, name="Ticket", default_price=23) - tl = event.ticket_layouts.create(name="Foo", default=True, layout='[{"a": 2}]') + tl = event.ticket_layouts.create( + name="Foo", + default=True, + layout='[{"type": "poweredby", "left": "0", "bottom": "0", "size": "1.00", "content": "dark"}]', + ) TicketLayoutItem.objects.create(layout=tl, item=item1, sales_channel=o.sales_channels.get(identifier="web")) return event, tl, item1 +@pytest.fixture +def position(env): + item = env[0].items.create(name="Ticket", default_price=3, admission=True) + order = Order.objects.create( + code='FOO', event=env[0], email='dummy@dummy.test', + status=Order.STATUS_PAID, locale='en', + datetime=now() - timedelta(days=4), + expires=now() - timedelta(hours=4) + timedelta(days=10), + total=Decimal('23.00'), + sales_channel=env[0].organizer.sales_channels.get(identifier="web"), + ) + return OrderPosition.objects.create( + order=order, item=item, variation=None, + price=Decimal("23.00"), attendee_name_parts={"full_name": "Peter"}, positionid=1 + ) + + @pytest.fixture def client(): return APIClient() @@ -213,3 +238,92 @@ def test_api_delete(env, token_client): ) assert resp.status_code == 204 assert not env[0].ticket_layouts.exists() + + +@pytest.mark.django_db +def test_renderer_batch_valid(env, token_client, position): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/ticketpdfrenderer/render_batch/'.format(env[0].slug, env[0].slug), + { + "parts": [ + { + "orderposition": position.pk, + }, + { + "orderposition": position.pk, + "override_channel": "web", + }, + { + "orderposition": position.pk, + "override_layout": env[1].pk, + }, + ] + }, + format='json', + ) + assert resp.status_code == 202 + assert "download" in resp.data + resp = token_client.get("/" + resp.data["download"].split("/", 3)[3]) + assert resp.status_code == 200 + assert resp["Content-Type"] == "application/pdf" + + +@pytest.mark.django_db +def test_renderer_batch_invalid(env, token_client, position): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/ticketpdfrenderer/render_batch/'.format(env[0].slug, env[0].slug), + { + "parts": [ + { + "orderposition": -2, + }, + ] + }, + format='json', + ) + assert resp.status_code == 400 + assert resp.data == {"parts": [{"orderposition": ["Invalid pk \"-2\" - object does not exist."]}]} + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/ticketpdfrenderer/render_batch/'.format(env[0].slug, env[0].slug), + { + "parts": [ + { + "orderposition": position.pk, + "override_layout": -2, + }, + ] + }, + format='json', + ) + assert resp.status_code == 400 + assert resp.data == {"parts": [{"override_layout": ["Invalid pk \"-2\" - object does not exist."]}]} + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/ticketpdfrenderer/render_batch/'.format(env[0].slug, env[0].slug), + { + "parts": [ + { + "orderposition": position.pk, + "override_channel": "magic", + }, + ] + }, + format='json', + ) + assert resp.status_code == 400 + assert resp.data == {"parts": [{"override_channel": ["Object with identifier=magic does not exist."]}]} + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/ticketpdfrenderer/render_batch/'.format(env[0].slug, env[0].slug), + { + "parts": [ + { + "orderposition": position.pk, + } + ] * 1002 + }, + format='json', + ) + assert resp.status_code == 400 + assert resp.data == {"parts": ["Please do not submit more than 1000 parts."]}