Add a plugin API for ticket outputs

This commit is contained in:
Raphael Michel
2015-04-19 18:11:15 +02:00
parent d17bf6a874
commit 8b88878b8d
15 changed files with 423 additions and 74 deletions

View File

@@ -9,4 +9,5 @@ Contents:
plugins plugins
restriction restriction
payment payment
ticketoutput
general general

View File

@@ -20,11 +20,10 @@ that we'll soon create::
from pretix.base.signals import register_payment_providers from pretix.base.signals import register_payment_providers
from .payment import Paypal
@receiver(register_payment_providers) @receiver(register_payment_providers)
def register_payment_provider(sender, **kwargs): def register_payment_provider(sender, **kwargs):
from .payment import Paypal
return Paypal return Paypal

View File

@@ -0,0 +1,70 @@
.. highlight:: python
:linenothreshold: 5
Writing a ticket output plugin
==============================
A ticket output is a method to offer a ticket (an order) for the user to download.
In this document, we will walk through the creation of a ticket output plugin. This
is very similar to creating a payment provider.
Please read :ref:`Creating a plugin <pluginsetup>` first, if you haven't already.
Output registration
-------------------
The payment provider API does not make a lot of usage from signals, however, it
does use a signal to get a list of all available ticket outputs. Your plugin
should listen for this signal and return the subclass of ``pretix.base.ticketoutput.BaseTicketOutput``
that we'll soon create::
from django.dispatch import receiver
from pretix.base.signals import register_ticket_outputs
@receiver(register_ticket_outputs)
def register_ticket_output(sender, **kwargs):
from .ticketoutput import PdfTicketOutput
return PdfTicketOutput
The output class
----------------
.. class:: pretix.base.ticketoutput.BaseTicketOutput
The central object of each ticket output is the subclass of ``BaseTicketOutput``
we already mentioned above. In this section, we will discuss it's interface in detail.
.. py:attribute:: BaseTicketOutput.event
The default constructor sets this property to the event we are currently
working for.
.. py:attribute:: BaseTicketOutput.settings
The default constructor sets this property to a ``SettingsSandbox`` object. You can
use this object to store settings using its ``get`` and ``set`` methods. All settings
you store are transparently prefixed, so you get your very own settings namespace.
.. autoattribute:: identifier
This is an abstract attribute, you **must** override this!
.. autoattribute:: verbose_name
This is an abstract attribute, you **must** override this!
.. autoattribute:: is_enabled
.. autoattribute:: settings_form_fields
.. automethod:: settings_content_render
.. automethod:: generate
.. autoattribute:: download_button_text
.. autoattribute:: download_button_icon

View File

@@ -123,6 +123,7 @@ class BasePaymentProvider:
page, this method is called. It may return HTML containing additional information page, this method is called. It may return HTML containing additional information
that is displayed below the form fields configured in ``settings_form_fields``. that is displayed below the form fields configured in ``settings_form_fields``.
""" """
pass
@property @property
def checkout_form_fields(self) -> dict: def checkout_form_fields(self) -> dict:

View File

@@ -60,3 +60,11 @@ subclass of pretix.base.payment.BasePaymentProvider
register_payment_providers = EventPluginSignal( register_payment_providers = EventPluginSignal(
providing_args=[] providing_args=[]
) )
"""
This signal is sent out to get all known ticket outputs. Receivers should return a
subclass of pretix.base.ticketoutput.BaseTicketOutput
"""
register_ticket_outputs = EventPluginSignal(
providing_args=[]
)

View File

@@ -0,0 +1,116 @@
from collections import OrderedDict
from django import forms
from django.http import HttpRequest, HttpResponse
from django.utils.translation import ugettext_lazy as _
from pretix.base.forms import SettingsForm
from pretix.base.models import Order
from pretix.base.settings import SettingsSandbox
class BaseTicketOutput:
"""
This is the base class for all ticket outputs.
"""
def __init__(self, event):
self.event = event
self.settings = SettingsSandbox('ticketoutput', self.identifier, event)
def __str__(self):
return self.identifier
@property
def is_enabled(self) -> bool:
"""
Returns, whether or whether not this output is enabled.
By default, this is determined by the value of the ``_enabled`` setting.
"""
return self.settings.get('_enabled', as_type=bool)
def generate(self, request: HttpRequest, order: Order) -> HttpResponse:
"""
This method should generate the download file and return it to the user.
"""
raise NotImplementedError()
@property
def verbose_name(self) -> str:
"""
A human-readable name for this ticket output. This should
be short but self-explaining. Good examples include 'PDF tickets'
and 'Passbook'.
"""
raise NotImplementedError() # NOQA
@property
def identifier(self) -> str:
"""
A short and unique identifier for this ticket output.
This should only contain lowercase letters and in most
cases will be the same as your packagename.
"""
raise NotImplementedError() # NOQA
@property
def settings_form_fields(self) -> dict:
"""
When the event's administrator administrator visits the event configuration
page, this method is called to return the configuration fields available.
It should therefore return a dictionary where the keys should be (unprefixed)
settings keys and the values should be corresponding Django form fields.
The default implementation returns the appropriate fields for the ``_enabled``
setting mentioned above.
We suggest that you return an ``OrderedDict`` object instead of a dictionary
and make use of the default implementation. Your implementation could look
like this::
@property
def settings_form_fields(self):
return OrderedDict(
list(super().settings_form_fields.items()) + [
('paper_size',
forms.CharField(
label=_('Paper size'),
required=False
))
]
)
.. WARNING:: It is highly discouraged to alter the ``_enabled`` field of the default
implementation.
"""
return OrderedDict([
('_enabled',
forms.ChoiceField(
label=_('Enable output'),
required=False,
choices=SettingsForm.BOOL_CHOICES,
)),
])
def settings_content_render(self, request: HttpRequest) -> str:
"""
When the event's administrator administrator visits the event configuration
page, this method is called. It may return HTML containing additional information
that is displayed below the form fields configured in ``settings_form_fields``.
"""
pass
@property
def download_button_text(self) -> str:
"""
The text on the download button in the frontend.
"""
return _('Download ticket')
@property
def download_button_icon(self) -> str:
"""
The name of the icon on the download button in the frontend
"""
return None

View File

@@ -13,6 +13,27 @@
<legend>{% trans "Ticket download" %}</legend> <legend>{% trans "Ticket download" %}</legend>
{% bootstrap_field form.ticket_download layout="horizontal" %} {% bootstrap_field form.ticket_download layout="horizontal" %}
{% bootstrap_field form.ticket_download_date layout="horizontal" %} {% bootstrap_field form.ticket_download_date layout="horizontal" %}
{% for provider in providers %}
<div class="panel panel-default">
<div class="panel-heading">
<div class="row">
<div class="col-sm-10">
<h3 class="panel-title">{{ provider.verbose_name }}</h3>
</div>
</div>
</div>
<div class="panel-body">
{% bootstrap_form provider.form layout='horizontal' %}
{% with c=provider.settings_content %}
{% if c %}{{ c|safe }}{% endif %}
{% endwith %}
</div>
</div>
{% empty %}
<div class="alert alert-warning">
{% trans "There are no ticket outputs available. Please go to the plugin settings and activate one or more ticket output plugins." %}</em>
</div>
{% endfor %}
</fieldset> </fieldset>
<div class="form-group submit-group"> <div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save"> <button type="submit" class="btn btn-primary btn-save">

View File

@@ -13,7 +13,7 @@ from pytz import common_timezones
from pretix.base.forms import VersionedModelForm, SettingsForm from pretix.base.forms import VersionedModelForm, SettingsForm
from pretix.base.models import Event from pretix.base.models import Event
from pretix.base.signals import register_payment_providers from pretix.base.signals import register_payment_providers, register_ticket_outputs
from pretix.control.permissions import EventPermissionRequiredMixin from pretix.control.permissions import EventPermissionRequiredMixin
from . import UpdateView from . import UpdateView
@@ -215,7 +215,7 @@ class EventPlugins(EventPermissionRequiredMixin, TemplateView, SingleObjectMixin
}) + '?success=true' }) + '?success=true'
class PaymentMethodForm(SettingsForm): class ProviderForm(SettingsForm):
""" """
This is a SettingsForm, but if fields are set to required=True, validation This is a SettingsForm, but if fields are set to required=True, validation
errors are only raised if the payment method is enabled. errors are only raised if the payment method is enabled.
@@ -258,7 +258,7 @@ class PaymentSettings(EventPermissionRequiredMixin, TemplateView, SingleObjectMi
responses = register_payment_providers.send(self.request.event) responses = register_payment_providers.send(self.request.event)
for receiver, response in responses: for receiver, response in responses:
provider = response(self.request.event) provider = response(self.request.event)
provider.form = PaymentMethodForm( provider.form = ProviderForm(
obj=self.request.event, obj=self.request.event,
settingspref='payment_%s_' % provider.identifier, settingspref='payment_%s_' % provider.identifier,
data=(self.request.POST if self.request.method == 'POST' else None) data=(self.request.POST if self.request.method == 'POST' else None)
@@ -346,6 +346,11 @@ class TicketSettings(EventPermissionRequiredMixin, FormView):
form.save() form.save()
return super().form_valid(form) return super().form_valid(form)
def get_context_data(self, *args, **kwargs) -> dict:
context = super().get_context_data(*args, **kwargs)
context['providers'] = self.provider_forms
return context
def get_success_url(self) -> str: def get_success_url(self) -> str:
return reverse('control:event.settings.tickets', kwargs={ return reverse('control:event.settings.tickets', kwargs={
'organizer': self.request.event.organizer.slug, 'organizer': self.request.event.organizer.slug,
@@ -362,6 +367,41 @@ class TicketSettings(EventPermissionRequiredMixin, FormView):
form.prepare_fields() form.prepare_fields()
return form return form
def post(self, request, *args, **kwargs):
success = True
for provider in self.provider_forms:
if provider.form.is_valid():
provider.form.save()
else:
success = False
form = self.get_form(self.get_form_class())
if success and form.is_valid():
return redirect(self.get_success_url())
else:
return self.get(request)
@cached_property
def provider_forms(self) -> list:
providers = []
responses = register_ticket_outputs.send(self.request.event)
for receiver, response in responses:
provider = response(self.request.event)
provider.form = ProviderForm(
obj=self.request.event,
settingspref='ticketoutput_%s_' % provider.identifier,
data=(self.request.POST if self.request.method == 'POST' else None)
)
provider.form.fields = OrderedDict(
[
('ticketoutput_%s_%s' % (provider.identifier, k), v)
for k, v in provider.settings_form_fields.items()
]
)
provider.settings_content = provider.settings_content_render(self.request)
provider.form.prepare_fields()
providers.append(provider)
return providers
def index(request, organizer, event): def index(request, organizer, event):
return render(request, 'pretixcontrol/event/index.html', {}) return render(request, 'pretixcontrol/event/index.html', {})

View File

@@ -0,0 +1,30 @@
from django.apps import AppConfig
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _
from pretix.base.plugins import PluginType
class TicketOutputPdfApp(AppConfig):
name = 'pretix.plugins.ticketoutputpdf'
verbose_name = _("PDF ticket output")
class PretixPluginMeta:
type = PluginType.PAYMENT
name = _("PDF ticket output")
author = _("the pretix team")
version = '1.0.0'
description = _("This plugin allows you to print out tickets as PDF files")
def ready(self):
from . import signals # NOQA
@cached_property
def compatibility_errors(self):
errs = []
try:
import reportlab
except ImportError:
errs.append("Python package 'reportlab' is not installed.")
return errs
default_app_config = 'pretix.plugins.ticketoutputpdf.TicketOutputPdfApp'

View File

@@ -0,0 +1,9 @@
from django.dispatch import receiver
from pretix.base.signals import register_ticket_outputs
@receiver(register_ticket_outputs)
def register_ticket_outputs(sender, **kwargs):
from .ticketoutput import PdfTicketOutput
return PdfTicketOutput

View File

@@ -0,0 +1,86 @@
from io import BytesIO
import logging
from django.contrib import messages
from django.contrib.staticfiles import finders
from django.http import HttpResponse
from django.shortcuts import redirect
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import Order
from pretix.base.ticketoutput import BaseTicketOutput
logger = logging.getLogger('pretix.plugins.ticketoutputpdf')
class PdfTicketOutput(BaseTicketOutput):
identifier = 'pdf'
verbose_name = _('PDF output')
download_button_text = _('Download PDF')
download_button_icon = 'fa-print'
def generate(self, request, order):
from reportlab.graphics.shapes import Drawing
from reportlab.pdfgen import canvas
from reportlab.lib import pagesizes, units
from reportlab.graphics.barcode.qr import QrCodeWidget
from reportlab.graphics import renderPDF
from PyPDF2 import PdfFileWriter, PdfFileReader
response = HttpResponse(content_type='application/pdf')
response['Content-Disposition'] = 'inline; filename="order%s%s.pdf"' % (request.event.slug, order.code)
pagesize = request.event.settings.get('ticketpdf_pagesize', default='A4')
if hasattr(pagesizes, pagesize):
pagesize = getattr(pagesizes, pagesize)
else:
pagesize = pagesizes.A4
defaultfname = finders.find('pretixpresale/pdf/ticket_default_a4.pdf')
fname = request.event.settings.get('ticketpdf_background', default=defaultfname)
# TODO: Handle file objects
buffer = BytesIO()
p = canvas.Canvas(buffer, pagesize=pagesize)
for op in order.positions.all().select_related('item', 'variation'):
p.setFont("Helvetica", 22)
p.drawString(15 * units.mm, 235 * units.mm, str(request.event.name))
p.setFont("Helvetica", 17)
item = str(op.item.name)
if op.variation:
item += " " + str(op.variation)
p.drawString(15 * units.mm, 220 * units.mm, item)
p.setFont("Helvetica", 17)
p.drawString(15 * units.mm, 210 * units.mm, "%s %s" % (str(op.price), request.event.currency))
reqs = 80 * units.mm
qrw = QrCodeWidget(op.identity, barLevel='H')
b = qrw.getBounds()
w = b[2] - b[0]
h = b[3] - b[1]
d = Drawing(reqs, reqs, transform=[reqs / w, 0, 0, reqs / h, 0, 0])
d.add(qrw)
renderPDF.draw(d, p, 10 * units.mm, 130 * units.mm)
p.setFont("Helvetica", 11)
p.drawString(15 * units.mm, 130 * units.mm, op.identity)
p.showPage()
p.save()
buffer.seek(0)
new_pdf = PdfFileReader(buffer)
output = PdfFileWriter()
for page in new_pdf.pages:
bg_pdf = PdfFileReader(open(fname, "rb"))
bg_page = bg_pdf.getPage(0)
bg_page.mergePage(page)
output.addPage(bg_page)
output.write(response)
return response

View File

@@ -37,10 +37,12 @@
entering the event. entering the event.
{% endblocktrans %} {% endblocktrans %}
</p> </p>
<a href="{% url "presale:event.order.download" organizer=request.event.organizer.slug event=request.event.slug order=order.code %}" {% for b in download_buttons %}
class="btn btn-primary"> <a href="{% url "presale:event.order.download" organizer=request.event.organizer.slug event=request.event.slug order=order.code output=b.identifier %}"
<span class="fa fa-print"></span> {% trans "Download PDF" %} class="btn btn-primary">
</a> <span class="fa {{ b.icon }}"></span> {{ b.text }}
</a>
{% endfor %}
{% else %} {% else %}
{% blocktrans trimmed with date=event.settings.ticket_download_date|date %} {% blocktrans trimmed with date=event.settings.ticket_download_date|date %}
You will be able to download your tickets here on {{ date }}. You will be able to download your tickets here on {{ date }}.

View File

@@ -22,7 +22,7 @@ urlpatterns = [
name='event.order.cancel'), name='event.order.cancel'),
url(r'^order/(?P<order>[^/]+)/modify$', pretix.presale.views.order.OrderModify.as_view(), url(r'^order/(?P<order>[^/]+)/modify$', pretix.presale.views.order.OrderModify.as_view(),
name='event.order.modify'), name='event.order.modify'),
url(r'^order/(?P<order>[^/]+)/download$', pretix.presale.views.order.OrderDownload.as_view(), url(r'^order/(?P<order>[^/]+)/download/(?P<output>[^/]+)$', pretix.presale.views.order.OrderDownload.as_view(),
name='event.order.download'), name='event.order.download'),
url(r'^login$', pretix.presale.views.event.EventLogin.as_view(), name='event.checkout.login'), url(r'^login$', pretix.presale.views.event.EventLogin.as_view(), name='event.checkout.login'),
url(r'^logout$', pretix.presale.views.event.EventLogout.as_view(), name='event.logout'), url(r'^logout$', pretix.presale.views.event.EventLogout.as_view(), name='event.logout'),

View File

@@ -8,7 +8,7 @@ from django.utils.functional import cached_property
from django.views.generic import TemplateView, View from django.views.generic import TemplateView, View
from django.http import HttpResponseNotFound, HttpResponseForbidden, HttpResponse from django.http import HttpResponseNotFound, HttpResponseForbidden, HttpResponse
from pretix.base.models import Order, OrderPosition from pretix.base.models import Order, OrderPosition
from pretix.base.signals import register_payment_providers from pretix.base.signals import register_payment_providers, register_ticket_outputs
from pretix.presale.views import EventViewMixin, EventLoginRequiredMixin, CartDisplayMixin from pretix.presale.views import EventViewMixin, EventLoginRequiredMixin, CartDisplayMixin
from pretix.presale.views.checkout import QuestionsViewMixin from pretix.presale.views.checkout import QuestionsViewMixin
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
@@ -46,6 +46,21 @@ class OrderDetails(EventViewMixin, EventLoginRequiredMixin, OrderDetailMixin,
if provider.identifier == self.order.payment_provider: if provider.identifier == self.order.payment_provider:
return provider return provider
@cached_property
def download_buttons(self):
buttons = []
responses = register_ticket_outputs.send(self.request.event)
for receiver, response in responses:
provider = response(self.request.event)
if not provider.is_enabled:
continue
buttons.append({
'icon': provider.download_button_icon or 'fa-download',
'text': provider.download_button_text or 'fa-download',
'identifier': provider.identifier,
})
return buttons
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs) ctx = super().get_context_data(**kwargs)
ctx['order'] = self.order ctx['order'] = self.order
@@ -54,6 +69,7 @@ class OrderDetails(EventViewMixin, EventLoginRequiredMixin, OrderDetailMixin,
and now() > self.request.event.settings.ticket_download_date and now() > self.request.event.settings.ticket_download_date
and self.order.status == Order.STATUS_PAID and self.order.status == Order.STATUS_PAID
) )
ctx['download_buttons'] = self.download_buttons
ctx['cart'] = self.get_cart( ctx['cart'] = self.get_cart(
answers=True, answers=True,
queryset=OrderPosition.objects.current.filter(order=self.order) queryset=OrderPosition.objects.current.filter(order=self.order)
@@ -157,73 +173,22 @@ class OrderDownload(EventViewMixin, EventLoginRequiredMixin, OrderDetailMixin,
'order': self.order.code, 'order': self.order.code,
}) })
def get(self, request, *args, **kwargs): @cached_property
from reportlab.graphics.shapes import Drawing def output(self):
from reportlab.pdfgen import canvas responses = register_ticket_outputs.send(self.request.event)
from reportlab.lib import pagesizes, units for receiver, response in responses:
from reportlab.graphics.barcode.qr import QrCodeWidget provider = response(self.request.event)
from reportlab.graphics import renderPDF if provider.identifier == self.kwargs.get('output'):
from PyPDF2 import PdfFileWriter, PdfFileReader return provider
def get(self, request, *args, **kwargs):
if not self.output or not self.output.is_enabled:
messages.error(request, _('You requested an invalid ticket output type.'))
return redirect(self.get_order_url())
if self.order.status != Order.STATUS_PAID: if self.order.status != Order.STATUS_PAID:
messages.error(request, _('Order is not paid.')) messages.error(request, _('Order is not paid.'))
return redirect(self.get_order_url()) return redirect(self.get_order_url())
if not self.request.event.settings.ticket_download or now() < self.request.event.settings.ticket_download_date: if not self.request.event.settings.ticket_download or now() < self.request.event.settings.ticket_download_date:
messages.error(request, _('Ticket download is not (yet) enabled.')) messages.error(request, _('Ticket download is not (yet) enabled.'))
return redirect(self.get_order_url()) return redirect(self.get_order_url())
return self.output.generate(request, self.order)
response = HttpResponse(content_type='application/pdf')
response['Content-Disposition'] = 'inline; filename="order%s%s.pdf"' % (request.event.slug, self.order.code)
pagesize = request.event.settings.get('ticketpdf_pagesize', default='A4')
if hasattr(pagesizes, pagesize):
pagesize = getattr(pagesizes, pagesize)
else:
pagesize = pagesizes.A4
defaultfname = finders.find('pretixpresale/pdf/ticket_default_a4.pdf')
fname = request.event.settings.get('ticketpdf_background', default=defaultfname)
# TODO: Handle file objects
buffer = BytesIO()
p = canvas.Canvas(buffer, pagesize=pagesize)
for op in self.order.positions.all().select_related('item', 'variation'):
p.setFont("Helvetica", 22)
p.drawString(15 * units.mm, 235 * units.mm, str(request.event.name))
p.setFont("Helvetica", 17)
item = str(op.item.name)
if op.variation:
item += " " + str(op.variation)
p.drawString(15 * units.mm, 220 * units.mm, item)
p.setFont("Helvetica", 17)
p.drawString(15 * units.mm, 210 * units.mm, "%s %s" % (str(op.price), request.event.currency))
reqs = 80 * units.mm
qrw = QrCodeWidget(op.identity, barLevel='H')
b = qrw.getBounds()
w = b[2] - b[0]
h = b[3] - b[1]
d = Drawing(reqs, reqs, transform=[reqs / w, 0, 0, reqs / h, 0, 0])
d.add(qrw)
renderPDF.draw(d, p, 10 * units.mm, 130 * units.mm)
p.setFont("Helvetica", 11)
p.drawString(15 * units.mm, 130 * units.mm, op.identity)
p.showPage()
p.save()
buffer.seek(0)
new_pdf = PdfFileReader(buffer)
output = PdfFileWriter()
for page in new_pdf.pages:
bg_pdf = PdfFileReader(open(fname, "rb"))
bg_page = bg_pdf.getPage(0)
bg_page.mergePage(page)
output.addPage(bg_page)
output.write(response)
return response

View File

@@ -49,6 +49,7 @@ INSTALLED_APPS = (
'pretix.plugins.banktransfer', 'pretix.plugins.banktransfer',
'pretix.plugins.stripe', 'pretix.plugins.stripe',
'pretix.plugins.paypal', 'pretix.plugins.paypal',
'pretix.plugins.ticketoutputpdf',
) )
MIDDLEWARE_CLASSES = ( MIDDLEWARE_CLASSES = (