The very basics of the plugin API

This commit is contained in:
Raphael Michel
2014-10-06 23:30:36 +02:00
parent 7ded9e88d8
commit 3c6f8b77cb
17 changed files with 587 additions and 3 deletions

Binary file not shown.

View File

@@ -0,0 +1,10 @@
API details
===========
Contents:
.. toctree::
:maxdepth: 2
plugins
restriction

View File

@@ -0,0 +1,63 @@
.. highlight:: python
:linenothreshold: 5
Plugin basics
=============
It is possible to extend tixl with custom Python code using the official plugin
API. Every plugin has to be implemented as an independent Django 'app' living
either in an own python package either installed like any python module or in
the ``tixlplugins/`` directory of your tixl installation. A plugin may only
require two steps to install:
* Add it to the ``INSTALLED_APPS`` setting of Django in ``tixl/settings.py``
* Perform database migrations by using ``python manage.py migrate``
The communication between tixl and the plugins happens via Django's
`signal dispatcher`_ pattern. The core modules of tixl, ``tixlbase``,
``tixlcontrol`` and ``tixlpresale`` 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 as a subpackage to ``tixlplugins``.
In order to do so, you can place your module into tixl's :file:`tixlplugins` folder *or
anywhere else in your python import path* inside a folder called ``tixlplugins``.
.. IMPORTANT::
This makes use of a design pattern called `namespace packages`_ which is only
implicitly available as of Python 3.4. As we aim to support Python 3.2 for a bit
longer, you **MUST** put **EXACLTY** the following content into ``tixlplugins/__init__.py``
if you create a new ``tixlplugins`` folder somewhere in your path::
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
Otherwise it **will break** on Python 3.2 systems *depending on the python path's order*,
which is not tolerable behaviour. Also, even on Python 3.4 the test runner seems to have
problems without this workaround.
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::
from django.apps import AppConfig
class TimeRestrictionApp(AppConfig):
name = 'tixlplugins.timerestriction'
verbose_name = "Time restriction"
def ready(self):
from . import signals
default_app_config = 'tixlplugins.timerestriction.TimeRestrictionApp'
.. _signal dispatcher: https://docs.djangoproject.com/en/1.7/topics/signals/
.. _namespace packages: http://legacy.python.org/dev/peps/pep-0420/

View File

@@ -0,0 +1,105 @@
.. highlight:: python
:linenothreshold: 5
Writing a restriction plugin
============================
Please make sure you have read and understood the :ref:`basic idea being tixl's restrictions
<restrictionconcept>`. 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 <pluginsetup>` first.
The restriction model
---------------------
It is very likely that your new restriction plugin needs to store data. In order to do
so, it should define its own model with a name related to what your restriction does,
e.g. ``TimeRestriction``. This model should be a child class of ``tixlbase.models.BaseRestriction``.
You do not need to define custom fields, but you should create at least an empty model.
In our example, we put the following into :file:`tixlplugins/timerestriction/models.py`::
from django.db import models
from django.utils.translation import ugettext_lazy as _
from tixlbase.models import BaseRestriction
class TimeRestriction(BaseRestriction):
"""
This restriction makes an item or variation only available
within a given time frame. The price of the item can be modified
during this time frame.
"""
timeframe_from = models.DateTimeField(
verbose_name=_("Start of time frame"),
)
timeframe_to = models.DateTimeField(
verbose_name=_("End of time frame"),
)
price = models.DecimalField(
null=True, blank=True,
max_digits=7, decimal_places=2,
verbose_name=_("Price in time frame"),
)
The basic signals
-----------------
Availability determination
^^^^^^^^^^^^^^^^^^^^^^^^^^
This is the one signal *every* restriction plugin has to listen for, as your plugin does not
restrict anything without doing so. It is available as ``tixlbase.signals.determine_availability``
and is sent out every time some component of tixl wants to know whether a specific item or
variation is available for sell.
It is sent out with several arguments:
item
The instance of ``tixlbase.models.Item`` in question.
variations
A list of dictionaries in the same format as ``Item.get_all_variations``:
The list contains one dictionary per variation, where the ``Property`` IDs are
keys and the ``PropertyValue`` objects are values. If an ``ItemVariation`` object
exists, it is available in the dictionary via the special key ``'variation'``. If
the item does not have any properties, the list will contain exactly one empty
dictionary. Please not: this is *not* the list of all possible variations, this is
only the list of all variations the frontend likes to determine the status for.
context
A yet-to-defined context object containing information about the user and the order
process. This is required to implement coupon-systems or similar restrictions.
cache
An object very similar to Django's own caching API (see tip below)
All receivers **have to** return a copy of the given list of variation dictionaries where each
dictionary can be extended by the following two keys:
available
A boolean value whether or not this plugin allows this variation to be on sale. Defaults
to ``True``.
price
A price to be set for this variation. Set to ``None`` or omit to keep the default price
of the variation or the item's base price.
.. IMPORTANT::
As this signal might be called *a lot* under heavy load, you are expected to implement
your receiver with an eye to performance. We highly recommend making use of Django's
`caching feature`_. We cannot do this for you, as the possibility of caching highly
depends on the details of your restriction.
**Attention:** Please use the **cache object provided in the signal** instead of importing
it directly from django, so we can take care of invalidation whenever the organizer changes
the event or item settings. Please also **prefix all your cache keys** with your
plugin name.
In our example, the implementation could look like this::
TBD
.. IMPORTANT::
Please note the copying of the ``variations`` list in the example above.
.. _caching feature: https://docs.djangoproject.com/en/1.7/topics/cache/

View File

@@ -1,4 +1,4 @@
Implementation Concepts
Implementation concepts
=======================
Basic terminology
@@ -74,6 +74,8 @@ An item can be extended using **questions**. Questions enable items to be extend
additional information which can be entered by the user. Examples of possible questions
include 'Name' or 'age'.
.. _restrictionconcept:
Restrictions
^^^^^^^^^^^^

View File

@@ -11,3 +11,4 @@ Contents:
setup
style
structure
api/index

View File

@@ -1,5 +1,5 @@
[run]
source = tixlbase,tixlcontrol,tixlpresale
source = tixlbase,tixlcontrol,tixlpresale,tixlplugins
omit = */migrations/*,*/urls.py,*/tests/*
[report]

View File

@@ -13,6 +13,7 @@ lxml
# Debugging requirements
django-debug-toolbar
ipython
# Testing requirements
pyflakes

View File

@@ -8,8 +8,9 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/dev/ref/settings/
"""
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
import os
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
@@ -43,6 +44,7 @@ INSTALLED_APPS = (
'bootstrap3',
'debug_toolbar.apps.DebugToolbarConfig',
'djangoformsetjs',
'tixlplugins.timerestriction',
)
MIDDLEWARE_CLASSES = (

0
src/tixlbase/cache.py Normal file
View File

View File

@@ -569,6 +569,9 @@ class Item(models.Model):
return result
def get_cache(self):
return None
class ItemVariation(models.Model):
"""
@@ -610,3 +613,24 @@ class ItemVariation(models.Model):
class Meta:
verbose_name = _("Item variation")
verbose_name_plural = _("Item variations")
class BaseRestriction(models.Model):
"""
A restriction is the abstract concept of a rule that limits the availability
of Items or ItemVariations. This model is just an abstract base class to be
extended by restriction plugins.
"""
items = models.ManyToManyField(
Item,
related_name="restrictions_%(app_label)s_%(class)s",
)
variations = models.ManyToManyField(
ItemVariation,
related_name="restrictions_%(app_label)s_%(class)s",
)
class Meta:
abstract = True
verbose_name = _("Restriction")
verbose_name_plural = _("Restrictions")

5
src/tixlbase/signals.py Normal file
View File

@@ -0,0 +1,5 @@
import django.dispatch
determine_availability = django.dispatch.Signal(
providing_args=["item", "variations", "context", "cache"]
)

View File

@@ -0,0 +1,2 @@
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)

View File

@@ -0,0 +1,11 @@
from django.apps import AppConfig
class TimeRestrictionApp(AppConfig):
name = 'tixlplugins.timerestriction'
verbose_name = "Time restriction"
def ready(self):
from . import signals
default_app_config = 'tixlplugins.timerestriction.TimeRestrictionApp'

View File

@@ -0,0 +1,24 @@
from django.db import models
from django.utils.translation import ugettext_lazy as _
from tixlbase.models import BaseRestriction
class TimeRestriction(BaseRestriction):
"""
This restriction makes an item or variation only available
within a given time frame. The price of the item can be modified
during this time frame.
"""
timeframe_from = models.DateTimeField(
verbose_name=_("Start of time frame"),
)
timeframe_to = models.DateTimeField(
verbose_name=_("End of time frame"),
)
price = models.DecimalField(
null=True, blank=True,
max_digits=7, decimal_places=2,
verbose_name=_("Price in time frame"),
)

View File

@@ -0,0 +1,61 @@
from django.dispatch import receiver
from django.utils.timezone import now
from tixlbase.signals import determine_availability
from .models import TimeRestriction
@receiver(determine_availability)
def availability_handler(sender, **kwargs):
# Handle the signal's input arguments
item = kwargs['item']
variations = kwargs['variations']
cache = kwargs['cache'] # NOQA
context = kwargs['context'] # NOQA
# Fetch all restriction objects applied to this item
restrictions = list(TimeRestriction.objects.filter(
items__in=(item,),
).prefetch_related('variations'))
# If we do not know anything about this item, we are done here.
if len(restrictions) == 0:
return variations
# IMPORTANT:
# We need to make a two-level deep copy of the variations list before we
# modify it, becuase we need to to copy the dictionaries. Otherwise, we'll
# interfere with other plugins.
variations = [d.copy() for d in variations]
# Walk through all variations we are asked for
for v in variations:
# If this point is reached, there ARE time restrictions for this item
# Therefore, it is only available inside one of the timeframes, but not
# without any timeframe
available = False
price = None
# Walk through all restriction objects applied to this item
for restriction in restrictions:
applied_to = list(restriction.variations.all())
# Only take this restriction into consideration if it either
# is directly applied to this variation OR is applied to all
# variations (e.g. the applied_to list is empty)
if len(applied_to) > 0:
if 'variation' not in v or v['variation'] not in applied_to:
continue
if restriction.timeframe_from <= now() and restriction.timeframe_to >= now():
# Selling this item is currently possible
available = True
# If multiple time frames are currently active, make sure to
# get the cheapest price:
if restriction.price is not None and (price is None or restriction.price < price):
price = restriction.price
v['available'] = available
v['price'] = price
return variations

View File

@@ -0,0 +1,273 @@
from datetime import timedelta
from django.test import TestCase
from django.utils.timezone import now
from tixlbase.models import (
Event, Organizer, Item, Property, PropertyValue, ItemVariation
)
# Do NOT use relative imports here
from tixlplugins.timerestriction import signals
from tixlplugins.timerestriction.models import TimeRestriction
class TimeRestrictionTest(TestCase):
"""
This test case tests the various aspects of the time restriction
plugin
"""
def setUp(self):
o = Organizer.objects.create(name='Dummy', slug='dummy')
self.event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=now(),
)
self.item = Item.objects.create(event=self.event, name='Dummy', default_price=14)
self.property = Property.objects.create(event=self.event, name='Size')
self.value1 = PropertyValue.objects.create(prop=self.property, value='S')
self.value2 = PropertyValue.objects.create(prop=self.property, value='M')
self.value3 = PropertyValue.objects.create(prop=self.property, value='L')
def test_nothing(self):
result = signals.availability_handler(
None, item=self.item,
variations=self.item.get_all_variations(),
context=None, cache=self.item.get_cache()
)
self.assertEqual(len(result), 1)
self.assertTrue('available' not in result[0] or result[0]['available'] is True)
def test_simple_case_available(self):
r = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=3),
timeframe_to=now() + timedelta(days=3),
price=12
)
r.items.add(self.item)
result = signals.availability_handler(
None, item=self.item,
variations=self.item.get_all_variations(),
context=None, cache=self.item.get_cache()
)
self.assertEqual(len(result), 1)
self.assertIn('available', result[0])
self.assertTrue(result[0]['available'])
self.assertEqual(result[0]['price'], 12)
def test_simple_case_unavailable(self):
r = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=5),
timeframe_to=now() - timedelta(days=3),
price=12
)
r.items.add(self.item)
result = signals.availability_handler(
None, item=self.item,
variations=self.item.get_all_variations(),
context=None, cache=self.item.get_cache()
)
self.assertEqual(len(result), 1)
self.assertIn('available', result[0])
self.assertFalse(result[0]['available'])
def test_multiple_overlapping_now(self):
r1 = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=5),
timeframe_to=now() + timedelta(days=3),
price=12
)
r1.items.add(self.item)
r2 = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=3),
timeframe_to=now() + timedelta(days=5),
price=8
)
r2.items.add(self.item)
result = signals.availability_handler(
None, item=self.item,
variations=self.item.get_all_variations(),
context=None, cache=self.item.get_cache()
)
self.assertEqual(len(result), 1)
self.assertIn('available', result[0])
self.assertTrue(result[0]['available'])
self.assertEqual(result[0]['price'], 8)
def test_multiple_overlapping_tomorrow(self):
r1 = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=5),
timeframe_to=now() + timedelta(days=5),
price=12
)
r1.items.add(self.item)
r2 = TimeRestriction.objects.create(
timeframe_from=now() + timedelta(days=1),
timeframe_to=now() + timedelta(days=7),
price=8
)
r2.items.add(self.item)
result = signals.availability_handler(
None, item=self.item,
variations=self.item.get_all_variations(),
context=None, cache=self.item.get_cache()
)
self.assertEqual(len(result), 1)
self.assertIn('available', result[0])
self.assertTrue(result[0]['available'])
self.assertEqual(result[0]['price'], 12)
def test_multiple_distinct_available(self):
r1 = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=5),
timeframe_to=now() + timedelta(days=2),
price=12
)
r1.items.add(self.item)
r2 = TimeRestriction.objects.create(
timeframe_from=now() + timedelta(days=4),
timeframe_to=now() + timedelta(days=7),
price=8
)
r2.items.add(self.item)
result = signals.availability_handler(
None, item=self.item,
variations=self.item.get_all_variations(),
context=None, cache=self.item.get_cache()
)
self.assertEqual(len(result), 1)
self.assertIn('available', result[0])
self.assertTrue(result[0]['available'])
self.assertEqual(result[0]['price'], 12)
def test_multiple_distinct_unavailable(self):
r1 = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=5),
timeframe_to=now() - timedelta(days=1),
price=12
)
r1.items.add(self.item)
r2 = TimeRestriction.objects.create(
timeframe_from=now() + timedelta(days=4),
timeframe_to=now() + timedelta(days=7),
price=8
)
r2.items.add(self.item)
result = signals.availability_handler(
None, item=self.item,
variations=self.item.get_all_variations(),
context=None, cache=self.item.get_cache()
)
self.assertEqual(len(result), 1)
self.assertIn('available', result[0])
self.assertFalse(result[0]['available'])
def test_variation_specific(self):
self.item.properties.add(self.property)
v1 = ItemVariation.objects.create(item=self.item)
v1.values.add(self.value1)
v2 = ItemVariation.objects.create(item=self.item)
v2.values.add(self.value2)
r1 = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=5),
timeframe_to=now() + timedelta(days=1),
price=12
)
r1.items.add(self.item)
r1.variations.add(v1)
result = signals.availability_handler(
None, item=self.item,
variations=self.item.get_all_variations(),
context=None, cache=self.item.get_cache()
)
self.assertEqual(len(result), 3)
for v in result:
if 'variation' in v and v['variation'].pk == v1.pk:
self.assertTrue(v['available'])
self.assertEqual(v['price'], 12)
else:
self.assertFalse(v['available'])
def test_variation_specific_and_general(self):
self.item.properties.add(self.property)
v1 = ItemVariation.objects.create(item=self.item)
v1.values.add(self.value1)
v2 = ItemVariation.objects.create(item=self.item)
v2.values.add(self.value2)
r1 = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=5),
timeframe_to=now() + timedelta(days=1),
price=12
)
r1.items.add(self.item)
r2 = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=5),
timeframe_to=now() + timedelta(days=1),
price=8
)
r2.items.add(self.item)
r2.variations.add(v1)
r3 = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=5),
timeframe_to=now() - timedelta(days=1),
price=10
)
r3.items.add(self.item)
r3.variations.add(v2)
result = signals.availability_handler(
None, item=self.item,
variations=self.item.get_all_variations(),
context=None, cache=self.item.get_cache()
)
self.assertEqual(len(result), 3)
for v in result:
if 'variation' in v and v['variation'].pk == v1.pk:
self.assertTrue(v['available'])
self.assertEqual(v['price'], 8)
else:
self.assertTrue(v['available'])
self.assertEqual(v['price'], 12)
def test_variation_specifics(self):
self.item.properties.add(self.property)
v1 = ItemVariation.objects.create(item=self.item)
v1.values.add(self.value1)
v2 = ItemVariation.objects.create(item=self.item)
v2.values.add(self.value2)
r1 = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=5),
timeframe_to=now() + timedelta(days=1),
price=12
)
r1.items.add(self.item)
r1.variations.add(v1)
r2 = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=5),
timeframe_to=now() + timedelta(days=1),
price=8
)
r2.items.add(self.item)
r2.variations.add(v1)
r3 = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=5),
timeframe_to=now() - timedelta(days=1),
price=8
)
r3.items.add(self.item)
r3.variations.add(v2)
result = signals.availability_handler(
None, item=self.item,
variations=self.item.get_all_variations(),
context=None, cache=self.item.get_cache()
)
self.assertEqual(len(result), 3)
for v in result:
if 'variation' in v and v['variation'].pk == v1.pk:
self.assertTrue(v['available'])
self.assertEqual(v['price'], 8)
else:
self.assertFalse(v['available'])