From 13f88346d42852301c7c623ed674f96fb7998860 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Sun, 15 Mar 2015 19:48:42 +0100 Subject: [PATCH] Documentation for the payment provider plugin API --- doc/conf.py | 6 +- doc/development/api/payment.rst | 108 ++++++++++++++++++- doc/development/api/plugins.rst | 73 +++++++++---- doc/development/api/restriction.rst | 6 +- src/pretix/base/payment.py | 149 +++++++++++++++++++-------- src/pretix/plugins/paypal/payment.py | 1 - 6 files changed, 274 insertions(+), 69 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 1a90b3529..bcddbe260 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -19,7 +19,11 @@ import os # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) +sys.path.insert(0, os.path.abspath('../src')) + +import django +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pretix.settings") +django.setup() # -- General configuration ------------------------------------------------ diff --git a/doc/development/api/payment.rst b/doc/development/api/payment.rst index 5b70dd4a6..b81a45808 100644 --- a/doc/development/api/payment.rst +++ b/doc/development/api/payment.rst @@ -8,21 +8,119 @@ In this document, we will walk through the creation of a payment provider plugin Please read :ref:`Creating a plugin ` first, if you haven't already. -The signal ----------- +Provider 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 payment providers. Your plugin -should listen for this signal and return the subclass of ``pretix.base.payment.PaymentProvider`` +should listen for this signal and return the subclass of ``pretix.base.payment.BasePaymentProvider`` that we'll soon create:: from django.dispatch import receiver from pretix.base.signals import register_payment_providers - from .payment import BankTransfer + from .payment import Paypal @receiver(register_payment_providers) def register_payment_provider(sender, **kwargs): - return BankTransfer + return Paypal + + +The provider class +------------------ + +.. class:: pretix.base.payment.BasePaymentProvider + + The central object of each payment provider is the subclass of ``BasePaymentProvider`` + we already mentioned above. In this section, we will discuss it's interface in detail. + + .. py:attribute:: BasePaymentProvider.event + + The default constructor sets this property to the event we are currently + working for. + + .. py:attribute:: BasePaymentProvider.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 + + .. automethod:: calculate_fee + + .. autoattribute:: settings_form_fields + + .. automethod:: checkout_form_render + + .. automethod:: checkout_form + + .. autoattribute:: checkout_form_fields + + .. automethod:: checkout_prepare + + .. automethod:: checkout_is_valid_session + + .. automethod:: checkout_confirm_render + + This is an abstract method, you **must** override this! + + .. automethod:: checkout_perform + + .. automethod:: order_pending_render + + This is an abstract method, you **must** override this! + + .. automethod:: order_paid_render + + +Additional views +---------------- + +For most simple payment providers it is more than sufficient to implement +some of the :py:class:`BasePaymentProvider` methods. However, in some cases +it is necessary to introduce additional views. One example is the PayPal +provider. It redirects the user to a paypal website in the +:py:meth:`BasePaymentProvider.checkout_prepare`` step of the checkout process +and provides PayPal with an URL to redirect back to. This URL points to a +view which looks roughly like this:: + + @login_required + def success(request): + pid = request.GET.get('paymentId') + payer = request.GET.get('PayerID') + # We stored some information in the session in checkout_prepare(), + # let's compare the new information to double-check that this is about + # the same payment + if pid == request.session['payment_paypal_id']: + # Save the new information to the user's session + request.session['payment_paypal_payer'] = payer + try: + # Redirect back to the confirm page. We chose to save the + # event ID in the user's session. We could also put this + # information into an URL parameter. + event = Event.objects.current.get(identity=request.session['payment_paypal_event']) + return redirect(reverse('presale:event.checkout.confirm', kwargs={ + 'event': event.slug, + 'organizer': event.organizer.slug, + })) + except Event.DoesNotExist: + pass # TODO: Display error message + else: + pass # TODO: Display error message + +If you do not want to provide a view of your own, you could even let PayPal +redirect directly back to the confirm page and handle the query parameters +inside :py:meth:`BasePaymentProvider.checkout_is_valid_session``. However, +because some external providers (not PayPal) force you to have a *constant* +redirect URL, it might be necessary to define custom views. diff --git a/doc/development/api/plugins.rst b/doc/development/api/plugins.rst index 78e0edff7..1f33b0cac 100644 --- a/doc/development/api/plugins.rst +++ b/doc/development/api/plugins.rst @@ -13,23 +13,45 @@ require two steps to install: * Add it to the ``INSTALLED_APPS`` setting of Django in ``pretix/settings.py`` * Perform database migrations by using ``python manage.py migrate`` -The communication between pretix and the plugins happens via Django's -`signal dispatcher`_ pattern. The core modules of pretix, ``pretixbase``, +The communication between pretix and the plugins happens mostly using Django's +`signal dispatcher`_ feature. The core modules of pretix, ``pretixbase``, ``pretixcontrol`` and ``pretixpresale`` expose a number of signals which are documented on the next pages. .. _`pluginsetup`: -Creating a plugin ------------------ +To create a new plugin, create a new python package which must be a vaild `Django app`_ +and must contain plugin metadata, as described below. -To create a new plugin, create a new python package. +The following pages go into detail about the several types of plugins currently +supported. While these instructions don't assume that you know a lot about pretix, +they do assume that you have prior knowledge about Django (e.g. it's view layer, +how it's ORM works, etc.). -Inside your newly created folder, you'll probably need the three python modules ``__init__.py``, -``models.py`` and ``signals.py``, although this is up to you. You can take the following -example, taken from the time restriction module (see next chapter) as a template for your -``__init__.py`` module:: +Plugin metadata +--------------- +The plugin metadata lives inside a ``PretixPluginMeta`` class inside your app's +configuration class. The metadata class must define the following attributes: + +``type`` (``pretix.base.plugins.PluginType``): + The type of plugin. Currently available: ``RESTRICTION``, ``PAYMENT`` + +``name`` (``str``): + The human-readable name of your plugin + +``author`` (``str``): + Your name + +``version`` (``str``): + A human-readable version code of your plugin + +``description`` (``str``): + A more verbose description of what your plugin does. + +A working example would be:: + + # file: pretix/plugins/timerestriction/__init__.py from django.apps import AppConfig from django.utils.translation import ugettext_lazy as _ from pretix.base.plugins import PluginType @@ -48,21 +70,36 @@ example, taken from the time restriction module (see next chapter) as a template "of a given item or variation to a certain timeframe " + "or change its price during a certain period.") - def ready(self): - from . import signals # NOQA default_app_config = 'pretix.plugins.timerestriction.TimeRestrictionApp' -.. IMPORTANT:: - You have to implement a ``PretixPluginMeta`` class like in the example to make your - plugin available to the users. -Currently, the ``PluginType`` enum has the following values defined: +Signals +------- -* ``RESTRICTION`` -* ``PAYMENT`` +The various components of pretix define a number of signals which your plugin can +listen for. We will go into the details of the different signals in the following +pages. We suggest that you put your signal receivers into a ``signals`` submodule +of your plugin. You should extend your ``AppConfig`` (see above) by the following +method to make your receivers available:: -The next pages provide details on their usage. + class TimeRestrictionApp(AppConfig): + … + def ready(self): + from . import signals # NOQA + +Views +----- + +Your plugin may define custom views. If you put an ``urls`` submodule into your +plugin module, pretix will automatically import it and include it into the root +URL configuration. + +.. WARNING:: If you define custom URLs and views, you are currently on your own + with checking that the calling user is logged in, has appropriate permissions, + etc. We plan on providing native support for this in a later version. + +.. _Django app: https://docs.djangoproject.com/en/1.7/ref/applications/ .. _signal dispatcher: https://docs.djangoproject.com/en/1.7/topics/signals/ .. _namespace packages: http://legacy.python.org/dev/peps/pep-0420/ diff --git a/doc/development/api/restriction.rst b/doc/development/api/restriction.rst index 5f69e8f57..5e54f0c88 100644 --- a/doc/development/api/restriction.rst +++ b/doc/development/api/restriction.rst @@ -4,9 +4,9 @@ Writing a restriction plugin ============================ -Please make sure you have read and understood the :ref:`basic idea being pretix's restrictions -`. In this document, we will walk through the creation of a restriction -plugin using the example of a restriction by date and time. +Please make sure you have read and understood the :ref:`basic idea ` behind +what pretix calls *restrictions*. In this document, we will walk through the creation of a +restriction plugin using the example of a restriction by date and time. Also, read :ref:`Creating a plugin ` first. diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index 2d30f4e0d..45685d861 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -3,10 +3,12 @@ from decimal import Decimal from django import forms from django.forms import Form +from django.http import HttpRequest from django.template import Context from django.template.loader import get_template 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 @@ -66,11 +68,30 @@ class BasePaymentProvider: def settings_form_fields(self) -> dict: """ When the event's administrator administrator visits the event configuration - page, - A dictionary. The keys should be (unprefixed) EventSetting keys, - the values should be corresponding django form fields. + page, this method is called to return the configuration fields available. - We suggest returning a collections.OrderedDict object instead of a dict. + 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``, + ``_fee_abs`` and ``_fee_percent`` settings 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()) + [ + ('bank_details', + forms.CharField( + widget=forms.Textarea, + label=_('Bank account details'), + required=False + )) + ] + ) """ return OrderedDict([ ('_enabled', @@ -96,18 +117,20 @@ class BasePaymentProvider: @property def checkout_form_fields(self) -> dict: """ - A dictionary. The keys should be unprefixed field names, - the values should be corresponding django form fields. + This is used by the default implementation of :py:meth:`checkout_form`. + It should return an object similar to :py:attr:`settings_form_fields`. - We suggest returning a collections.OrderedDict object instead of a dict. + The default implementation returns an empty dictionary. """ - # TODO: Proper handling of required=True fields in HTML return {} - def checkout_form(self, request) -> Form: + def checkout_form(self, request: HttpRequest) -> Form: """ - Returns the Form object of the form that should be displayed when the - user selects this provider as his payment method. + This is called by the default implementation of :py:meth:`checkout_form_render` + to obtain the form that is displayed to the user during the checkout + process. The default implementation constructs the form using + :py:attr:`checkout_form_fields` and sets appropriate prefixes for the form + and all fields and fills the form with data form the user's session. """ form = Form( data=(request.POST if request.method == 'POST' else None), @@ -121,10 +144,15 @@ class BasePaymentProvider: form.fields = self.checkout_form_fields return form - def checkout_form_render(self, request) -> str: + def checkout_form_render(self, request: HttpRequest) -> str: """ - Returns the HTML of the form that should be displayed when the user - selects this provider as his payment method. + When the user selects this provider as his prefered payment method, + he will be shown the HTML you return from this method. + + The default implementation will call :py:meth:`checkout_form` + and render the returned form. If your payment method doesn't require + the user to fill out form fields, you should just return a paragraph + of explainatory text. """ form = self.checkout_form(request) template = get_template('pretixpresale/event/checkout_payment_form_default.html') @@ -133,23 +161,53 @@ class BasePaymentProvider: def checkout_confirm_render(self, request) -> str: """ - Returns the HTML that should be displayed when the user selected this provider - on the 'confirm order' page. + If the user successfully filled in his payment data, he will be redirected + to a confirmation page which lists all details of his order for a final review. + This method should return the HTML which should be displayed inside the + 'Payment' box on this page. + + In most cases, this should include a short summary of the user's input and + a short explaination on how the payment process will continue. """ raise NotImplementedError() # NOQA - def checkout_prepare(self, request, cart) -> "bool|HttpResponse": + def checkout_prepare(self, request: HttpRequest, cart: dict) -> "bool|str": """ - Will be called if the user selects this provider as his payment method. - If the payment provider provides a form to the user to enter payment data, - this method should at least store the user's input into his session. + Will be called after the user selected this provider as his payment method. + If you provided a form to the user to enter payment data, this method should + at least store the user's input into his session. - It should return True or False, depending of the validity of the user's input, - if the frontend should continue with default behaviour, or a redirect URL, - if you need special behaviour. + This method should return ``False``, if the user's input was invalid, ``True`` + if the input was valid and the frontend should continue with default behaviour + or a string containing an URL, if the user should be redirected somewhere else. - On errors, it should use Django's message framework to display an error message + On errors, you should use Django's message framework to display an error message to the user (or the normal form validation error messages). + + The default implementation stores the input into the form returned by + :py:meth:`checkout_form` in the user's session. + + If your payment method requires you to redirect the user to an external provider, + this might be the place to do so. + + .. IMPORTANT:: If this is called, the user has not yet confirmed his or her order. + You may NOT do anything which actually moves money. + + :param cart: This dictionary contains at least the following keys: + + positions: + A list of ``CartPosition`` objects that are annotated with the special + attributes ``count`` and ``total`` because multiple objects of the + same content are grouped into one. + + raw: + The raw list of ``CartPosition`` objects in the users cart + + total: + The overall total *including* the fee for the payment method. + + payment_fee: + The fee for the payment method. """ form = self.checkout_form(request) if form.is_valid(): @@ -159,49 +217,58 @@ class BasePaymentProvider: else: return False - def checkout_is_valid_session(self, request) -> bool: + def checkout_is_valid_session(self, request: HttpRequest) -> bool: """ This is called at the time the user tries to place the order. It should return - True, if the user's session is valid and all data your payment provider requires + ``True``, if the user's session is valid and all data your payment provider requires in future steps is present. """ raise NotImplementedError() # NOQA - def checkout_perform(self, request, order) -> str: + def checkout_perform(self, request: HttpRequest, order: Order) -> str: """ - Will be called if the user submitted his order successfully to initiate the - payment process. + After the user confirmed his purchase, this method will be called to complete + the payment process. This is the place to actually move the money, if applicable. + If you need any speical behaviour, you can return a string + containing an URL the user will be redirected to. If you are done with your process + you should return the user to the order's detail page. - It should return a custom redirct URL, if you need special behaviour, or None to - continue with default behaviour. + If the payment is completed, you should call ``order.mark_paid(provider, info)`` + with ``provider`` being your :py:attr:`identifier` and ``info`` being any string + you might want to store for later usage. Please note, that if you want to store + something inside ``order.payment_info``, please do a ``order = order.clone()`` before + modifying or saving the order object. - On errors, it should use Django's message framework to display an error message - to the user (or the normal form validation error messages). + The default implementation just returns ``None`` and therefore leaves the + order unpaid. The user will be redirected to the order's detail page by default. + + On errors, you should use Django's message framework to display an error message + to the user. :param order: The order object """ return None - def order_pending_render(self, request, order) -> str: + def order_pending_render(self, request: HttpRequest, order: Order) -> str: """ - Will be called if the user views the detail page of an unpaid order which is - associated with this payment provider. + If the user visits a detail page of an order which has not yet been paid but + this payment method was selected during checkout, this method will be called + to provide HTML content for the 'payment' box on the page. - It should return HTML code which should be displayed to the user. It should contian - instructions on how to continue with the payment process, either in form of text - or buttons/links/etc. + It should contain instructions on how to continue with the payment process, + either in form of text or buttons/links/etc. :param order: The order object """ raise NotImplementedError() # NOQA - def order_paid_render(self, request, order) -> str: + def order_paid_render(self, request: HttpRequest, order: Order) -> str: """ Will be called if the user views the detail page of an paid order which is associated with this payment provider. It should return HTML code which should be displayed to the user or None, - if there is nothing to say. + if there is nothing to say (like the default implementation does). :param order: The order object """ diff --git a/src/pretix/plugins/paypal/payment.py b/src/pretix/plugins/paypal/payment.py index b0731264c..fb6ed2b8e 100644 --- a/src/pretix/plugins/paypal/payment.py +++ b/src/pretix/plugins/paypal/payment.py @@ -5,7 +5,6 @@ from django.contrib import messages from django.core.urlresolvers import reverse from django.template import Context from django.template.loader import get_template -from django.utils.functional import cached_property from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext as __ from django import forms