Compare commits

...

4 Commits

Author SHA1 Message Date
Mira Weller
1263f4359f Remove unused percentage result field 2024-10-15 11:46:10 +02:00
Raphael Michel
645df63e66 Fix failing test 2024-10-14 17:35:57 +02:00
Raphael Michel
3416366763 Tests and docs 2024-10-14 15:37:27 +02:00
Raphael Michel
bbefb59036 Add ticket renderer RPC API 2024-10-11 18:58:55 +02:00
5 changed files with 425 additions and 10 deletions

View File

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

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

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."]}