diff --git a/doc/plugins/ticketoutputpdf.rst b/doc/plugins/ticketoutputpdf.rst index e82fe5cc32..d784d89bcf 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. +Ticket rendering endpoint +----------------------------- + +.. 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"}``. ``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 efcbba4380..b526ccf59f 100644 --- a/src/pretix/plugins/ticketoutputpdf/api.py +++ b/src/pretix/plugins/ticketoutputpdf/api.py @@ -19,21 +19,33 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # +from datetime import timedelta + +from celery.result import AsyncResult from django.conf import settings from django.db import transaction from django.db.models import QuerySet +from django.http import Http404 +from django.shortcuts import get_object_or_404 from django.utils.functional import lazy -from rest_framework import serializers, viewsets +from django.utils.timezone import now +from django_scopes import scopes_disabled +from rest_framework import serializers, status, viewsets +from rest_framework.decorators import action from rest_framework.exceptions import ValidationError +from rest_framework.response import Response +from rest_framework.reverse import reverse from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.order import CompatibleJSONField from ...api.serializers.fields import UploadedFileField -from ...base.models import SalesChannel +from ...base.models import CachedFile, OrderPosition, SalesChannel from ...base.pdf import PdfLayoutValidator +from ...helpers.http import ChunkBasedFileResponse from ...multidomain.utils import static_absolute from .models import TicketLayout, TicketLayoutItem +from .tasks import bulk_render class ItemAssignmentSerializer(I18nAwareModelSerializer): @@ -156,3 +168,123 @@ class TicketLayoutItemViewSet(viewsets.ReadOnlyModelViewSet): **super().get_serializer_context(), 'event': self.request.event, } + + +with scopes_disabled(): + class RenderJobPartSerializer(serializers.Serializer): + orderposition = serializers.PrimaryKeyRelatedField( + queryset=OrderPosition.objects.none(), + required=True, + allow_null=False, + ) + override_layout = serializers.PrimaryKeyRelatedField( + queryset=TicketLayout.objects.none(), + required=False, + allow_null=True, + ) + override_channel = serializers.SlugRelatedField( + queryset=SalesChannel.objects.none(), + slug_field='identifier', + required=False, + allow_null=True, + ) + + +class RenderJobSerializer(serializers.Serializer): + parts = RenderJobPartSerializer(many=True) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['parts'].child.fields['orderposition'].queryset = OrderPosition.objects.filter(order__event=self.context['event']) + self.fields['parts'].child.fields['override_layout'].queryset = self.context['event'].ticket_layouts.all() + self.fields['parts'].child.fields['override_channel'].queryset = self.context['event'].organizer.sales_channels.all() + + def validate(self, attrs): + if len(attrs["parts"]) > 1000: + raise ValidationError({"parts": ["Please do not submit more than 1000 parts."]}) + return super().validate(attrs) + + +class TicketRendererViewSet(viewsets.ViewSet): + permission = 'can_view_orders' + + def get_serializer_kwargs(self): + return {} + + def list(self, request, *args, **kwargs): + raise Http404() + + def retrieve(self, request, *args, **kwargs): + raise Http404() + + def update(self, request, *args, **kwargs): + raise Http404() + + def partial_update(self, request, *args, **kwargs): + raise Http404() + + def destroy(self, request, *args, **kwargs): + raise Http404() + + @action(detail=False, methods=['GET'], url_name='download', url_path='download/(?P[^/]+)/(?P[^/]+)') + def download(self, *args, **kwargs): + cf = get_object_or_404(CachedFile, id=kwargs['cfid']) + if cf.file: + resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type) + resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename).encode("ascii", "ignore") + return resp + elif not settings.HAS_CELERY: + return Response( + {'status': 'failed', 'message': 'Unknown file ID or export failed'}, + status=status.HTTP_410_GONE + ) + + res = AsyncResult(kwargs['asyncid']) + if res.failed(): + if isinstance(res.info, dict) and res.info['exc_type'] == 'ExportError': + msg = res.info['exc_message'] + else: + msg = 'Internal error' + return Response( + {'status': 'failed', 'message': msg}, + status=status.HTTP_410_GONE + ) + + return Response( + { + 'status': 'running' if res.state in ('PROGRESS', 'STARTED', 'SUCCESS') else 'waiting', + }, + status=status.HTTP_409_CONFLICT + ) + + @action(detail=False, methods=['POST']) + def render_batch(self, *args, **kwargs): + serializer = RenderJobSerializer(data=self.request.data, context={ + "event": self.request.event, + }) + serializer.is_valid(raise_exception=True) + + cf = CachedFile(web_download=False) + cf.date = now() + cf.expires = now() + timedelta(hours=24) + cf.save() + async_result = bulk_render.apply_async(args=( + self.request.event.id, + str(cf.id), + [ + { + "orderposition": r["orderposition"].id, + "override_layout": r["override_layout"].id if r.get("override_layout") else None, + "override_channel": r["override_channel"].id if r.get("override_channel") else None, + } for r in serializer.validated_data["parts"] + ] + )) + + url_kwargs = { + 'asyncid': str(async_result.id), + 'cfid': str(cf.id), + } + url_kwargs.update(self.kwargs) + return Response({ + 'download': reverse('api-v1:ticketpdfrenderer-download', kwargs=url_kwargs, request=self.request) + }, status=status.HTTP_202_ACCEPTED) diff --git a/src/pretix/plugins/ticketoutputpdf/tasks.py b/src/pretix/plugins/ticketoutputpdf/tasks.py index 39ff685a47..c28f8e3004 100644 --- a/src/pretix/plugins/ticketoutputpdf/tasks.py +++ b/src/pretix/plugins/ticketoutputpdf/tasks.py @@ -19,18 +19,26 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # +import json import logging +from io import BytesIO from django.core.files.base import ContentFile +from django.db.models import Prefetch, prefetch_related_objects +from pypdf import PdfWriter from pretix.base.models import ( - CachedFile, Event, OrderPosition, cachedfile_name, + CachedFile, Checkin, Event, EventMetaValue, ItemMetaValue, + ItemVariationMetaValue, OrderPosition, SalesChannel, SubEventMetaValue, + cachedfile_name, ) from pretix.base.services.orders import OrderError from pretix.base.services.tasks import EventTask from pretix.celery_app import app +from ...base.i18n import language from ...base.services.export import ExportError +from .models import TicketLayout from .ticketoutput import PdfTicketOutput logger = logging.getLogger(__name__) @@ -46,3 +54,93 @@ def tickets_create_pdf(event: Event, fileid: int, position: int, channel) -> int file.file.save(cachedfile_name(file, file.filename), ContentFile(data)) file.save() return file.pk + + +@app.task(base=EventTask, throws=(OrderError, ExportError,)) +def bulk_render(event: Event, fileid: int, parts: list) -> int: + file = CachedFile.objects.get(id=fileid) + + channels = SalesChannel.objects.in_bulk([p["override_channel"] for p in parts if p.get("override_channel")]) + layouts = TicketLayout.objects.in_bulk([p["override_layout"] for p in parts if p.get("override_layout")]) + + positions = OrderPosition.objects.all() + prefetch_related_objects([event.organizer], 'meta_properties') + prefetch_related_objects( + [event], + Prefetch('meta_values', queryset=EventMetaValue.objects.select_related('property'), + to_attr='meta_values_cached'), + 'questions', + 'item_meta_properties', + ) + positions = positions.prefetch_related( + Prefetch('checkins', queryset=Checkin.objects.select_related("device")), + Prefetch('item', queryset=event.items.prefetch_related( + Prefetch('meta_values', ItemMetaValue.objects.select_related('property'), + to_attr='meta_values_cached') + )), + 'variation', + 'answers', 'answers__options', 'answers__question', + 'item__category', + 'addon_to__answers', 'addon_to__answers__options', 'addon_to__answers__question', + Prefetch('addons', positions.select_related('item', 'variation')), + Prefetch('subevent', queryset=event.subevents.prefetch_related( + Prefetch('meta_values', to_attr='meta_values_cached', + queryset=SubEventMetaValue.objects.select_related('property')) + )), + 'linked_media', + Prefetch('order', event.orders.select_related('invoice_address').prefetch_related( + Prefetch( + 'positions', + positions.prefetch_related( + Prefetch('checkins', queryset=Checkin.objects.select_related('device')), + Prefetch('item', queryset=event.items.prefetch_related( + Prefetch('meta_values', ItemMetaValue.objects.select_related('property'), + to_attr='meta_values_cached') + )), + Prefetch('variation', queryset=event.items.prefetch_related( + Prefetch('meta_values', ItemVariationMetaValue.objects.select_related('property'), + to_attr='meta_values_cached') + )), + 'answers', 'answers__options', 'answers__question', + 'item__category', + Prefetch('subevent', queryset=event.subevents.prefetch_related( + Prefetch('meta_values', to_attr='meta_values_cached', + queryset=SubEventMetaValue.objects.select_related('property')) + )), + Prefetch('addons', positions.select_related('item', 'variation', 'seat')) + ).select_related('addon_to', 'seat', 'addon_to__seat') + ) + )) + ).select_related( + 'addon_to', 'seat', 'addon_to__seat' + ) + positions = positions.in_bulk([p["orderposition"] for p in parts]) + + merger = PdfWriter() + for part in parts: + p = positions[part["orderposition"]] + p.order.event = event # performance optimization + with (language(p.order.locale)): + kwargs = {} + if part.get("override_channel"): + kwargs["override_channel"] = channels[part["override_channel"]].identifier + if part.get("override_layout"): + l = layouts[part["override_layout"]] + kwargs["override_layout"] = json.loads(l.layout) + kwargs["override_background"] = l.background + prov = PdfTicketOutput( + event, + **kwargs, + ) + filename, ctype, data = prov.generate(p) + merger.append(ContentFile(data)) + + outbuffer = BytesIO() + merger.write(outbuffer) + 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/pretix/plugins/ticketoutputpdf/urls.py b/src/pretix/plugins/ticketoutputpdf/urls.py index e3b1580124..2249353747 100644 --- a/src/pretix/plugins/ticketoutputpdf/urls.py +++ b/src/pretix/plugins/ticketoutputpdf/urls.py @@ -23,7 +23,7 @@ from django.urls import re_path from pretix.api.urls import event_router from pretix.plugins.ticketoutputpdf.api import ( - TicketLayoutItemViewSet, TicketLayoutViewSet, + TicketLayoutItemViewSet, TicketLayoutViewSet, TicketRendererViewSet, ) from pretix.plugins.ticketoutputpdf.views import ( LayoutCreate, LayoutDelete, LayoutEditorView, LayoutGetDefault, @@ -48,3 +48,4 @@ urlpatterns = [ ] event_router.register('ticketlayouts', TicketLayoutViewSet) event_router.register('ticketlayoutitems', TicketLayoutItemViewSet) +event_router.register('ticketpdfrenderer', TicketRendererViewSet, basename='ticketpdfrenderer') diff --git a/src/tests/plugins/ticketoutputpdf/test_api.py b/src/tests/plugins/ticketoutputpdf/test_api.py index 187678ae51..7f6551e0b8 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() @@ -65,7 +90,7 @@ RES_LAYOUT = { 'name': 'Foo', 'default': True, 'item_assignments': [{'item': 1, 'sales_channel': 'web'}], - 'layout': [{'a': 2}], + 'layout': [{"type": "poweredby", "left": "0", "bottom": "0", "size": "1.00", "content": "dark"}], 'background': 'http://example.com/static/pretixpresale/pdf/ticket_default_a4.pdf' } @@ -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."]}