From bbefb59036acaeed25f6ec251c907525694d35c4 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Fri, 11 Oct 2024 18:58:55 +0200 Subject: [PATCH] Add ticket renderer RPC API --- src/pretix/plugins/ticketoutputpdf/api.py | 137 +++++++++++++++++++- src/pretix/plugins/ticketoutputpdf/tasks.py | 99 +++++++++++++- src/pretix/plugins/ticketoutputpdf/urls.py | 3 +- 3 files changed, 235 insertions(+), 4 deletions(-) diff --git a/src/pretix/plugins/ticketoutputpdf/api.py b/src/pretix/plugins/ticketoutputpdf/api.py index efcbba438..3d3a80c8d 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,124 @@ 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("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', + 'percentage': res.result.get('value', None) if res.result else None, + }, + 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 39ff685a4..ccfd2f9dc 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,92 @@ 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.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 e3b158012..224935374 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')