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
co-authored by Mira Weller
parent 0d645fc4c5
commit 99ce7effde
5 changed files with 425 additions and 10 deletions
+134 -2
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)
+99 -1
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
+2 -1
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')
+118 -4
View File
@@ -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."]}