diff --git a/doc/development/api/exporter.rst b/doc/development/api/exporter.rst new file mode 100644 index 0000000000..8b467ecde9 --- /dev/null +++ b/doc/development/api/exporter.rst @@ -0,0 +1,59 @@ +.. highlight:: python + :linenothreshold: 5 + +Writing an exporter plugin +========================== + +An is a method to export the product and order data in pretix for later use in another +context. + +In this document, we will walk through the creation of an exporter output plugin. This +is very similar to creating a payment provider. + +Please read :ref:`Creating a plugin ` first, if you haven't already. + +Exporter registration +--------------------- + +The exporter API does not make a lot of usage from signals, however, it does use a signal to get a list of +all available exporters. Your plugin should listen for this signal and return the subclass of +``pretix.base.exporter.BaseExporter`` +that we'll soon create:: + + from django.dispatch import receiver + + from pretix.base.signals import register_data_exporter + + + @receiver(register_data_exporter) + def register_data_exporter(sender, **kwargs): + from .exporter import MyExporter + return MyExporter + + +The exporter class +------------------ + +.. class:: pretix.base.exporter.BaseExporter + + The central object of each exporter is the subclass of ``BaseExporter`` we already mentioned above. + In this section, we will discuss it's interface in detail. + + .. py:attribute:: BaseExporter.event + + The default constructor sets this property to the event we are currently + working for. + + .. autoattribute:: identifier + + This is an abstract attribute, you **must** override this! + + .. autoattribute:: verbose_name + + This is an abstract attribute, you **must** override this! + + .. autoattribute:: export_form_fields + + .. automethod:: render + + This is an abstract method, you **must** override this! diff --git a/doc/development/api/index.rst b/doc/development/api/index.rst index 424e5baa17..3722efe524 100644 --- a/doc/development/api/index.rst +++ b/doc/development/api/index.rst @@ -10,4 +10,5 @@ Contents: restriction payment ticketoutput + exporter general diff --git a/src/pretix/base/__init__.py b/src/pretix/base/__init__.py index 2455a458a3..41a919e4e0 100644 --- a/src/pretix/base/__init__.py +++ b/src/pretix/base/__init__.py @@ -5,4 +5,8 @@ class PretixBaseConfig(AppConfig): name = 'pretix.base' label = 'pretixbase' + def ready(self): + from . import exporter # NOQA + from . import payment # NOQA + default_app_config = 'pretix.base.PretixBaseConfig' diff --git a/src/pretix/base/exporter.py b/src/pretix/base/exporter.py new file mode 100644 index 0000000000..1a717abcfb --- /dev/null +++ b/src/pretix/base/exporter.py @@ -0,0 +1,70 @@ +from django import forms +from django.dispatch import receiver +from django.http import HttpRequest, HttpResponse, JsonResponse + +from pretix.base.signals import register_data_exporters + + +class BaseExporter: + """ + This is the base class for all data exporters + """ + + def __init__(self, event): + self.event = event + + def __str__(self): + return self.identifier + + @property + def verbose_name(self) -> str: + """ + A human-readable name for this exporter. This should be short but + self-explaining. Good examples include 'JSON' or 'Microsoft Excel'. + """ + raise NotImplementedError() # NOQA + + @property + def identifier(self) -> str: + """ + A short and unique identifier for this exporter. + This should only contain lowercase letters and in most + cases will be the same as your packagename. + """ + raise NotImplementedError() # NOQA + + @property + def export_form_fields(self) -> dict: + """ + When the event's administrator administrator visits the export page, this method + is called to return the configuration fields available. + + It should therefore return a dictionary where the keys should be field names and + the values should be corresponding Django form fields. + + We suggest that you return an ``OrderedDict`` object instead of a dictionary. + Your implementation could look like this:: + + @property + def export_form_fields(self): + return OrderedDict( + [ + ('tab_width', + forms.IntegerField( + label=_('Tab width'), + default=4 + )) + ] + ) + """ + return {} + + def render(self, request: HttpRequest) -> HttpResponse: + """ + Render the exported file and return a request that either contains the file + or redirects to it. + + :type request: HttpRequest + :param request: The HTTP request of the user requesting the export + """ + raise NotImplementedError() # NOQA diff --git a/src/pretix/base/plugins.py b/src/pretix/base/plugins.py index f0d0a0b68c..c3ab53b84b 100644 --- a/src/pretix/base/plugins.py +++ b/src/pretix/base/plugins.py @@ -10,6 +10,7 @@ class PluginType(Enum): RESTRICTION = 1 PAYMENT = 2 ADMINFEATURE = 3 + EXPORT = 4 def get_all_plugins() -> "List[class]": diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index 92ffe3ee77..57c2df10cc 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -69,3 +69,11 @@ subclass of pretix.base.ticketoutput.BaseTicketOutput register_ticket_outputs = EventPluginSignal( providing_args=[] ) + +""" +This signal is sent out to get all known data exporters. Receivers should return a +subclass of pretix.base.exporter.BaseExporter +""" +register_data_exporters = EventPluginSignal( + providing_args=[] +) diff --git a/src/pretix/control/templates/pretixcontrol/event/base.html b/src/pretix/control/templates/pretixcontrol/event/base.html index 60ce6b41bc..9b17bbf435 100644 --- a/src/pretix/control/templates/pretixcontrol/event/base.html +++ b/src/pretix/control/templates/pretixcontrol/event/base.html @@ -114,6 +114,12 @@ {% trans "Overview" %} +
  • + + {% trans "Export" %} + +
  • {% endif %} diff --git a/src/pretix/control/templates/pretixcontrol/orders/export.html b/src/pretix/control/templates/pretixcontrol/orders/export.html new file mode 100644 index 0000000000..685e9c22d6 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/orders/export.html @@ -0,0 +1,25 @@ +{% extends "pretixcontrol/event/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% load order_overview %} +{% block title %}{% trans "Data export" %}{% endblock %} +{% block content %} +

    {% trans "Data export" %}

    + {% for e in exporters %} +
    +
    +

    {{ e.verbose_name }}

    +
    +
    +
    + {% csrf_token %} + + {% bootstrap_form e.form layout='horizontal' %} + +
    +
    +
    + {% endfor %} +{% endblock %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 9652dac757..a62ee6d64d 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -65,6 +65,7 @@ urlpatterns = [ name='event.order.extend'), url(r'^orders/(?P[0-9A-Z]+)/$', orders.OrderDetail.as_view(), name='event.order'), url(r'^orders/overview/$', orders.OverView.as_view(), name='event.orders.overview'), + url(r'^orders/export/$', orders.ExportView.as_view(), name='event.orders.export'), url(r'^orders/go$', orders.OrderGo.as_view(), name='event.orders.go'), url(r'^orders/$', orders.OrderList.as_view(), name='event.orders'), ])), diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 34a5c3db46..daf6b66cf1 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -1,5 +1,6 @@ from itertools import groupby +from django import forms from django.contrib import messages from django.db.models import Q, Count, Sum from django.http import HttpResponse @@ -11,7 +12,9 @@ from django.views.generic import DetailView, ListView, TemplateView, View from pretix.base.models import Item, ItemCategory, Order, OrderPosition, Quota from pretix.base.services.orders import mark_order_paid -from pretix.base.signals import register_payment_providers +from pretix.base.signals import ( + register_data_exporters, register_payment_providers, +) from pretix.control.forms.orders import ExtendForm from pretix.control.permissions import EventPermissionRequiredMixin @@ -325,3 +328,45 @@ class OrderGo(EventPermissionRequiredMixin, View): except Order.DoesNotExist: messages.error(request, _('There is no order with the given order code.')) return redirect('control:event.orders', event=request.event.slug, organizer=request.event.organizer.slug) + + +class ExportView(EventPermissionRequiredMixin, TemplateView): + permission = 'can_view_orders' + template_name = 'pretixcontrol/orders/export.html' + + @cached_property + def exporters(self): + exporters = [] + responses = register_data_exporters.send(self.request.event) + for receiver, response in responses: + ex = response(self.request.event) + ex.form = forms.Form( + data=(self.request.POST if self.request.method == 'POST' else None) + ) + ex.form.fields = ex.export_form_fields + exporters.append(ex) + return exporters + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['exporters'] = self.exporters + return ctx + + @cached_property + def exporter(self): + for ex in self.exporters: + if ex.identifier == self.request.POST.get("exporter"): + return ex + + def post(self, *args, **kwargs): + if not self.exporter: + messages.error(self.request, _('The selected exporter was not found.')) + return redirect('control:event.orders.export', kwargs={ + 'event': self.request.event.slug, + 'organizer': self.request.event.organizer.slug + }) + if not self.exporter.form.is_valid(): + messages.error(self.request, _('There was a problem processing your input. See below for error details.')) + return self.get(*args, **kwargs) + + return self.exporter.render(self.request)