Add ticket renderer RPC API (Z#23165429) (#4525)

---------

Co-authored-by: Mira Weller <weller@rami.io>
This commit is contained in:
Raphael Michel
2024-10-15 12:11:09 +02:00
committed by GitHub
parent 0d645fc4c5
commit 99ce7effde
5 changed files with 425 additions and 10 deletions

View File

@@ -19,21 +19,33 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
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<asyncid>[^/]+)/(?P<cfid>[^/]+)')
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)

View File

@@ -19,18 +19,26 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
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

View File

@@ -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')