Allow ticket output providers to handle downloads externally (#1402)

* TicketOutput-Providers: Make preview optional; download/attachable optional; optional specific target; update doc

* Spelling fixes in doc

* Changes after code-review

* Changes after code-review

* Commit missing template file

* Allow for redirects instead of files

* Return HTTPResponse with Content-Type text/uri-list on API

* Update API-doc

* Add viewable to spellinglist, fixing doc-test
This commit is contained in:
Martin Gross
2019-10-21 14:05:09 +02:00
committed by Raphael Michel
parent 27538d220e
commit 03c760c2bb
14 changed files with 123 additions and 39 deletions

View File

@@ -219,6 +219,11 @@ pdf_data object Data object req
The value ``auto_checked_in`` has been added to the ``checkins``-attribute. The value ``auto_checked_in`` has been added to the ``checkins``-attribute.
.. versionchanged:: 3.3
The ``url`` of a ticket ``download`` can now also return a ``text/uri-list`` instead of a file. See
:ref:`order-position-ticket-download` for details.
.. _order-payment-resource: .. _order-payment-resource:
Order payment resource Order payment resource
@@ -1483,6 +1488,8 @@ Fetching individual positions
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order position does not exist. :statuscode 404: The requested order position does not exist.
.. _`order-position-ticket-download`:
Order position ticket download Order position ticket download
------------------------------ ------------------------------
@@ -1492,6 +1499,11 @@ Order position ticket download
Depending on the chosen output, the response might be a ZIP file, PDF file or something else. The order details Depending on the chosen output, the response might be a ZIP file, PDF file or something else. The order details
response contains a list of output options for this particular order position. response contains a list of output options for this particular order position.
Be aware that the output does not have to be a file, but can also be a regular HTTP response with a ``Content-Type``
set to ``text/uri-list``. In this case, the user is expected to navigate to that URL in order to access their ticket.
The referenced URL can provide a download or a regular, human-viewable website - so it is advised to open this URL
in a webbrowser and leave it up to the user to handle the result.
Tickets can be only downloaded if the order is paid and if ticket downloads are active. Also, depending on event Tickets can be only downloaded if the order is paid and if ticket downloads are active. Also, depending on event
configuration downloads might be only unavailable for add-on products or non-admission products. configuration downloads might be only unavailable for add-on products or non-admission products.
Note that in some cases the ticket file might not yet have been created. In that case, you will receive a status Note that in some cases the ticket file might not yet have been created. In that case, you will receive a status

View File

@@ -69,3 +69,9 @@ The output class
.. automethod:: generate_order .. automethod:: generate_order
.. autoattribute:: download_button_text .. autoattribute:: download_button_text
.. autoattribute:: download_button_icon
.. autoattribute:: preview_allowed
.. autoattribute:: javascript_required

View File

@@ -31,6 +31,7 @@ deprovision
discoverable discoverable
django django
dockerfile dockerfile
downloadable
durations durations
eu eu
filename filename
@@ -140,6 +141,7 @@ username
url url
versa versa
versioning versioning
viewable
viewset viewset
viewsets viewsets
waitinglist waitinglist

View File

@@ -6,7 +6,7 @@ import pytz
from django.db import transaction from django.db import transaction
from django.db.models import F, Prefetch, Q from django.db.models import F, Prefetch, Q
from django.db.models.functions import Coalesce, Concat from django.db.models.functions import Coalesce, Concat
from django.http import FileResponse from django.http import FileResponse, HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.timezone import make_aware, now from django.utils.timezone import make_aware, now
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
@@ -148,12 +148,16 @@ class OrderViewSet(viewsets.ModelViewSet):
generate.apply_async(args=('order', order.pk, provider.identifier)) generate.apply_async(args=('order', order.pk, provider.identifier))
raise RetryException() raise RetryException()
else: else:
resp = FileResponse(ct.file.file, content_type=ct.type) if ct.type == 'text/uri-list':
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format( resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list')
self.request.event.slug.upper(), order.code, return resp
provider.identifier, ct.extension else:
) resp = FileResponse(ct.file.file, content_type=ct.type)
return resp resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(
self.request.event.slug.upper(), order.code,
provider.identifier, ct.extension
)
return resp
@action(detail=True, methods=['POST']) @action(detail=True, methods=['POST'])
def mark_paid(self, request, **kwargs): def mark_paid(self, request, **kwargs):
@@ -759,12 +763,16 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
generate.apply_async(args=('orderposition', pos.pk, provider.identifier)) generate.apply_async(args=('orderposition', pos.pk, provider.identifier))
raise RetryException() raise RetryException()
else: else:
resp = FileResponse(ct.file.file, content_type=ct.type) if ct.type == 'text/uri-list':
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format( resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list')
self.request.event.slug.upper(), pos.order.code, pos.positionid, return resp
provider.identifier, ct.extension else:
) resp = FileResponse(ct.file.file, content_type=ct.type)
return resp resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
self.request.event.slug.upper(), pos.order.code, pos.positionid,
provider.identifier, ct.extension
)
return resp
def perform_destroy(self, instance): def perform_destroy(self, instance):
try: try:

View File

@@ -47,6 +47,9 @@ def generate_order(order: int, provider: str):
prov = response(order.event) prov = response(order.event)
if prov.identifier == provider: if prov.identifier == provider:
filename, ttype, data = prov.generate_order(order) filename, ttype, data = prov.generate_order(order)
if ttype == 'text/uri-list':
continue
path, ext = os.path.splitext(filename) path, ext = os.path.splitext(filename)
for ct in CachedCombinedTicket.objects.filter(order=order, provider=provider): for ct in CachedCombinedTicket.objects.filter(order=order, provider=provider):
ct.delete() ct.delete()
@@ -124,6 +127,9 @@ def get_tickets_for_order(order, base_position=None):
if not p.is_enabled: if not p.is_enabled:
continue continue
if p.download_handled_by_frontend:
continue
if p.multi_download_enabled and not base_position: if p.multi_download_enabled and not base_position:
try: try:
if len(positions) == 0: if len(positions) == 0:

View File

@@ -46,12 +46,19 @@ class BaseTicketOutput:
filename, a file type and file content. The extension will be taken from the filename filename, a file type and file content. The extension will be taken from the filename
which is otherwise ignored. which is otherwise ignored.
Alternatively, you can pass a tuple consisting of an arbitrary string, ``text/uri-list``
and a single URL. In this case, the user will be redirected to this link instead of
being asked to download a generated file.
.. note:: If the event uses the event series feature (internally called subevents) .. note:: If the event uses the event series feature (internally called subevents)
and your generated ticket contains information like the event name or date, and your generated ticket contains information like the event name or date,
you probably want to display the properties of the subevent. A common pattern you probably want to display the properties of the subevent. A common pattern
to do this would be a declaration ``ev = position.subevent or position.order.event`` to do this would be a declaration ``ev = position.subevent or position.order.event``
and then access properties that are present on both classes like ``ev.name`` or and then access properties that are present on both classes like ``ev.name`` or
``ev.date_from``. ``ev.date_from``.
.. note:: Should you elect to use the URI redirection feature instead of offering downloads,
you should also set the ``multi_download_enabled``-property to ``False``.
""" """
raise NotImplementedError() raise NotImplementedError()
@@ -161,3 +168,21 @@ class BaseTicketOutput:
The Font Awesome icon on the download button in the frontend. The Font Awesome icon on the download button in the frontend.
""" """
return 'fa-download' return 'fa-download'
@property
def preview_allowed(self) -> bool:
"""
By default, the ``generate()`` method is called for generating a preview in the pretix backend.
In case your plugin cannot generate previews for any reason, you can manually disable it here.
"""
return True
@property
def javascript_required(self) -> bool:
"""
If this property is set to true, the download-button for this ticket-type will not be displayed
when the user's browser has JavaScript disabled.
Defaults to ``False``
"""
return False

View File

@@ -29,7 +29,7 @@
<div class="panel panel-default ticketoutput-panel"> <div class="panel panel-default ticketoutput-panel">
<div class="panel-heading"> <div class="panel-heading">
<a href="{% url "control:event.settings.tickets.preview" event=request.event.slug organizer=request.organizer.slug output=provider.identifier %}" <a href="{% url "control:event.settings.tickets.preview" event=request.event.slug organizer=request.organizer.slug output=provider.identifier %}"
class="btn btn-default btn-sm pull-right flip {% if not provider.preview_allowed %}disabled{% endif %}" class="btn btn-default btn-sm pull-right flip {% if not provider.evaluated_preview_allowed %}disabled{% endif %}"
target="_blank"> target="_blank">
{% trans "Preview" %} {% trans "Preview" %}
</a> </a>

View File

@@ -287,7 +287,7 @@
{% for b in download_buttons %} {% for b in download_buttons %}
<form action="{% url "control:event.order.download.ticket" code=order.code event=request.event.slug organizer=request.event.organizer.slug position=line.pk output=b.identifier %}" <form action="{% url "control:event.order.download.ticket" code=order.code event=request.event.slug organizer=request.event.organizer.slug position=line.pk output=b.identifier %}"
method="post" data-asynctask data-asynctask-download method="post" data-asynctask data-asynctask-download
class="form-inline helper-display-inline"> class="form-inline helper-display-inline{% if b.javascript_required %} requirejs{% endif %}">
{% csrf_token %} {% csrf_token %}
<button type="submit" <button type="submit"
class="btn btn-xs btn-default"> class="btn btn-xs btn-default">

View File

@@ -736,11 +736,14 @@ class TicketSettings(EventSettingsViewMixin, EventPermissionRequiredMixin, FormV
provider.settings_content = provider.settings_content_render(self.request) provider.settings_content = provider.settings_content_render(self.request)
provider.form.prepare_fields() provider.form.prepare_fields()
provider.preview_allowed = True provider.evaluated_preview_allowed = True
for k, v in provider.settings_form_fields.items(): if not provider.preview_allowed:
if v.required and not self.request.event.settings.get('ticketoutput_%s_%s' % (provider.identifier, k)): provider.evaluated_preview_allowed = False
provider.preview_allowed = False else:
break for k, v in provider.settings_form_fields.items():
if v.required and not self.request.event.settings.get('ticketoutput_%s_%s' % (provider.identifier, k)):
provider.evaluated_preview_allowed = False
break
providers.append(provider) providers.append(provider)
return providers return providers

View File

@@ -16,7 +16,8 @@ from django.db.models import (
) )
from django.forms import formset_factory from django.forms import formset_factory
from django.http import ( from django.http import (
FileResponse, Http404, HttpResponseNotAllowed, JsonResponse, FileResponse, Http404, HttpResponseNotAllowed, HttpResponseRedirect,
JsonResponse,
) )
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
@@ -225,7 +226,8 @@ class OrderDetail(OrderView):
'text': provider.download_button_text or 'Ticket', 'text': provider.download_button_text or 'Ticket',
'icon': provider.download_button_icon or 'fa-download', 'icon': provider.download_button_icon or 'fa-download',
'identifier': provider.identifier, 'identifier': provider.identifier,
'multi': provider.multi_download_enabled 'multi': provider.multi_download_enabled,
'javascript_required': provider.javascript_required
}) })
return buttons return buttons
@@ -340,12 +342,16 @@ class OrderDownload(AsyncAction, OrderView):
'message': str(self.get_success_message(value)) 'message': str(self.get_success_message(value))
}) })
if isinstance(value, CachedTicket): if isinstance(value, CachedTicket):
resp = FileResponse(value.file.file, content_type=value.type) if value.type == 'text/uri-list':
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format( resp = HttpResponseRedirect(value.file.file.read())
self.request.event.slug.upper(), self.order.code, self.order_position.positionid, return resp
self.output.identifier, value.extension else:
) resp = FileResponse(value.file.file, content_type=value.type)
return resp resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
self.request.event.slug.upper(), self.order.code, self.order_position.positionid,
self.output.identifier, value.extension
)
return resp
elif isinstance(value, CachedCombinedTicket): elif isinstance(value, CachedCombinedTicket):
resp = FileResponse(value.file.file, content_type=value.type) resp = FileResponse(value.file.file, content_type=value.type)
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format( resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(

View File

@@ -81,7 +81,7 @@
{% if line.generate_ticket %} {% if line.generate_ticket %}
{% for b in download_buttons %} {% for b in download_buttons %}
<form action="{% if position_page and line.addon_to %}{% eventurl event "presale:event.order.position.download" secret=line.addon_to.web_secret order=order.code output=b.identifier pid=line.pk position=line.addon_to.positionid %}{% elif position_page %}{% eventurl event "presale:event.order.position.download" secret=line.web_secret order=order.code output=b.identifier pid=line.pk position=line.positionid %}{% else %}{% eventurl event "presale:event.order.download" secret=order.secret order=order.code output=b.identifier position=line.pk %}{% endif %}" <form action="{% if position_page and line.addon_to %}{% eventurl event "presale:event.order.position.download" secret=line.addon_to.web_secret order=order.code output=b.identifier pid=line.pk position=line.addon_to.positionid %}{% elif position_page %}{% eventurl event "presale:event.order.position.download" secret=line.web_secret order=order.code output=b.identifier pid=line.pk position=line.positionid %}{% else %}{% eventurl event "presale:event.order.download" secret=order.secret order=order.code output=b.identifier position=line.pk %}{% endif %}"
method="post" data-asynctask data-asynctask-download class="download-btn-form"> method="post" data-asynctask data-asynctask-download class="download-btn-form{% if b.javascript_required %} requirejs{% endif %}">
{% csrf_token %} {% csrf_token %}
<button type="submit" <button type="submit"
class="btn btn-sm {% if b.identifier == "pdf" %}btn-primary{% else %}btn-default{% endif %}"> class="btn btn-sm {% if b.identifier == "pdf" %}btn-primary{% else %}btn-default{% endif %}">

View File

@@ -8,7 +8,9 @@ from django.contrib import messages
from django.core.files import File from django.core.files import File
from django.db import transaction from django.db import transaction
from django.db.models import Exists, OuterRef, Q, Sum from django.db.models import Exists, OuterRef, Q, Sum
from django.http import FileResponse, Http404, JsonResponse from django.http import (
FileResponse, Http404, HttpResponseRedirect, JsonResponse,
)
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.functional import cached_property from django.utils.functional import cached_property
@@ -155,7 +157,8 @@ class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TicketPageMixin,
'text': provider.download_button_text or 'Download', 'text': provider.download_button_text or 'Download',
'icon': provider.download_button_icon or 'fa-download', 'icon': provider.download_button_icon or 'fa-download',
'identifier': provider.identifier, 'identifier': provider.identifier,
'multi': provider.multi_download_enabled 'multi': provider.multi_download_enabled,
'javascript_required': provider.javascript_required
}) })
return buttons return buttons
@@ -249,7 +252,8 @@ class OrderPositionDetails(EventViewMixin, OrderPositionDetailMixin, CartMixin,
'text': provider.download_button_text or 'Download', 'text': provider.download_button_text or 'Download',
'icon': provider.download_button_icon or 'fa-download', 'icon': provider.download_button_icon or 'fa-download',
'identifier': provider.identifier, 'identifier': provider.identifier,
'multi': provider.multi_download_enabled 'multi': provider.multi_download_enabled,
'javascript_required': provider.javascript_required
}) })
return buttons return buttons
@@ -793,12 +797,16 @@ class OrderDownloadMixin:
'message': str(self.get_success_message(value)) 'message': str(self.get_success_message(value))
}) })
if isinstance(value, CachedTicket): if isinstance(value, CachedTicket):
resp = FileResponse(value.file.file, content_type=value.type) if value.type == 'text/uri-list':
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format( resp = HttpResponseRedirect(value.file.file.read())
self.request.event.slug.upper(), self.order.code, self.order_position.positionid, return resp
self.output.identifier, value.extension else:
) resp = FileResponse(value.file.file, content_type=value.type)
return resp resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
self.request.event.slug.upper(), self.order.code, self.order_position.positionid,
self.output.identifier, value.extension
)
return resp
elif isinstance(value, CachedCombinedTicket): elif isinstance(value, CachedCombinedTicket):
resp = FileResponse(value.file.file, content_type=value.type) resp = FileResponse(value.file.file, content_type=value.type)
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format( resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(

View File

@@ -667,6 +667,10 @@ h1 .label {
} }
} }
.nojs .requirejs {
display: none !important;
}
@import "../../pretixbase/scss/_rtl.scss"; @import "../../pretixbase/scss/_rtl.scss";
@import "../../bootstrap/scss/_rtl.scss"; @import "../../bootstrap/scss/_rtl.scss";
@import "_rtl.scss"; @import "_rtl.scss";

View File

@@ -288,6 +288,10 @@ h2 .label {
display: inline-block; display: inline-block;
} }
.nojs .requirejs {
display: none !important;
}
@import "_iframe.scss"; @import "_iframe.scss";
@import "_a11y.scss"; @import "_a11y.scss";
@import "_print.scss"; @import "_print.scss";