From 3c6f8b77cb40c9ee4eafbebc833fd366590a5d11 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Mon, 6 Oct 2014 23:30:36 +0200 Subject: [PATCH] The very basics of the plugin API --- doc/development/api/.restriction.rst.swo | Bin 0 -> 20480 bytes doc/development/api/index.rst | 10 + doc/development/api/plugins.rst | 63 +++++ doc/development/api/restriction.rst | 105 ++++++++ doc/development/concepts.rst | 4 +- doc/development/index.rst | 1 + src/.coveragerc | 2 +- src/requirements.txt | 1 + src/tixl/settings.py | 4 +- src/tixlbase/cache.py | 0 src/tixlbase/models.py | 24 ++ src/tixlbase/signals.py | 5 + src/tixlplugins/__init__.py | 2 + src/tixlplugins/timerestriction/__init__.py | 11 + src/tixlplugins/timerestriction/models.py | 24 ++ src/tixlplugins/timerestriction/signals.py | 61 +++++ src/tixlplugins/timerestriction/tests.py | 273 ++++++++++++++++++++ 17 files changed, 587 insertions(+), 3 deletions(-) create mode 100644 doc/development/api/.restriction.rst.swo create mode 100644 doc/development/api/index.rst create mode 100644 doc/development/api/plugins.rst create mode 100644 doc/development/api/restriction.rst create mode 100644 src/tixlbase/cache.py create mode 100644 src/tixlbase/signals.py create mode 100644 src/tixlplugins/__init__.py create mode 100644 src/tixlplugins/timerestriction/__init__.py create mode 100644 src/tixlplugins/timerestriction/models.py create mode 100644 src/tixlplugins/timerestriction/signals.py create mode 100644 src/tixlplugins/timerestriction/tests.py diff --git a/doc/development/api/.restriction.rst.swo b/doc/development/api/.restriction.rst.swo new file mode 100644 index 0000000000000000000000000000000000000000..06a209b2d1dda597f47b3fc0072ed7298e96e19e GIT binary patch literal 20480 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 literal 0 HcmV?d00001 diff --git a/doc/development/api/index.rst b/doc/development/api/index.rst new file mode 100644 index 0000000000..d693800a39 --- /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 0000000000..7af03fda1a --- /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 0000000000..1139a88e51 --- /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 5a019a4a05..afb9ff9014 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 5c90823ba9..5a11fed7ab 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 9b8509ef8b..0d8c77cc01 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 1603d8e837..b07b932206 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 8f6a6cc50b..81b1eb16b0 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 0000000000..e69de29bb2 diff --git a/src/tixlbase/models.py b/src/tixlbase/models.py index 040ddec9aa..ba0f313a6e 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 0000000000..befaf947bf --- /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 0000000000..3ad9513f40 --- /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 0000000000..df92e1790b --- /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 0000000000..00167c5f6c --- /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 0000000000..b22d45ba5e --- /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 0000000000..86a7f3d58d --- /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'])