diff --git a/doc/development/api/.restriction.rst.swo b/doc/development/api/.restriction.rst.swo new file mode 100644 index 000000000..06a209b2d Binary files /dev/null and b/doc/development/api/.restriction.rst.swo differ diff --git a/doc/development/api/index.rst b/doc/development/api/index.rst new file mode 100644 index 000000000..d693800a3 --- /dev/null +++ b/doc/development/api/index.rst @@ -0,0 +1,10 @@ +API details +=========== + +Contents: + +.. toctree:: + :maxdepth: 2 + + plugins + restriction diff --git a/doc/development/api/plugins.rst b/doc/development/api/plugins.rst new file mode 100644 index 000000000..7af03fda1 --- /dev/null +++ b/doc/development/api/plugins.rst @@ -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/ diff --git a/doc/development/api/restriction.rst b/doc/development/api/restriction.rst new file mode 100644 index 000000000..1139a88e5 --- /dev/null +++ b/doc/development/api/restriction.rst @@ -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 +`. 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. + +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/ diff --git a/doc/development/concepts.rst b/doc/development/concepts.rst index 5a019a4a0..afb9ff901 100644 --- a/doc/development/concepts.rst +++ b/doc/development/concepts.rst @@ -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 ^^^^^^^^^^^^ diff --git a/doc/development/index.rst b/doc/development/index.rst index 5c90823ba..5a11fed7a 100644 --- a/doc/development/index.rst +++ b/doc/development/index.rst @@ -11,3 +11,4 @@ Contents: setup style structure + api/index diff --git a/src/.coveragerc b/src/.coveragerc index 9b8509ef8..0d8c77cc0 100644 --- a/src/.coveragerc +++ b/src/.coveragerc @@ -1,5 +1,5 @@ [run] -source = tixlbase,tixlcontrol,tixlpresale +source = tixlbase,tixlcontrol,tixlpresale,tixlplugins omit = */migrations/*,*/urls.py,*/tests/* [report] diff --git a/src/requirements.txt b/src/requirements.txt index 1603d8e83..b07b93220 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -13,6 +13,7 @@ lxml # Debugging requirements django-debug-toolbar +ipython # Testing requirements pyflakes diff --git a/src/tixl/settings.py b/src/tixl/settings.py index 8f6a6cc50..81b1eb16b 100644 --- a/src/tixl/settings.py +++ b/src/tixl/settings.py @@ -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 = ( diff --git a/src/tixlbase/cache.py b/src/tixlbase/cache.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/tixlbase/models.py b/src/tixlbase/models.py index 040ddec9a..ba0f313a6 100644 --- a/src/tixlbase/models.py +++ b/src/tixlbase/models.py @@ -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") diff --git a/src/tixlbase/signals.py b/src/tixlbase/signals.py new file mode 100644 index 000000000..befaf947b --- /dev/null +++ b/src/tixlbase/signals.py @@ -0,0 +1,5 @@ +import django.dispatch + +determine_availability = django.dispatch.Signal( + providing_args=["item", "variations", "context", "cache"] +) diff --git a/src/tixlplugins/__init__.py b/src/tixlplugins/__init__.py new file mode 100644 index 000000000..3ad9513f4 --- /dev/null +++ b/src/tixlplugins/__init__.py @@ -0,0 +1,2 @@ +from pkgutil import extend_path +__path__ = extend_path(__path__, __name__) diff --git a/src/tixlplugins/timerestriction/__init__.py b/src/tixlplugins/timerestriction/__init__.py new file mode 100644 index 000000000..df92e1790 --- /dev/null +++ b/src/tixlplugins/timerestriction/__init__.py @@ -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' diff --git a/src/tixlplugins/timerestriction/models.py b/src/tixlplugins/timerestriction/models.py new file mode 100644 index 000000000..00167c5f6 --- /dev/null +++ b/src/tixlplugins/timerestriction/models.py @@ -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"), + ) diff --git a/src/tixlplugins/timerestriction/signals.py b/src/tixlplugins/timerestriction/signals.py new file mode 100644 index 000000000..b22d45ba5 --- /dev/null +++ b/src/tixlplugins/timerestriction/signals.py @@ -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 diff --git a/src/tixlplugins/timerestriction/tests.py b/src/tixlplugins/timerestriction/tests.py new file mode 100644 index 000000000..86a7f3d58 --- /dev/null +++ b/src/tixlplugins/timerestriction/tests.py @@ -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'])