diff --git a/AUTHORS b/AUTHORS index cefd0c1..aa9b956 100644 --- a/AUTHORS +++ b/AUTHORS @@ -3,3 +3,4 @@ Vojtech Jasny Roman Krejcik Jan Vesely Ales Zoulek +Jannis Leidel diff --git a/README.rst b/README.rst index 84472bd..8e65932 100644 --- a/README.rst +++ b/README.rst @@ -1,50 +1,110 @@ -Dynamic Django settings in Redis. +Dynamic Django settings +======================= Features -======== +-------- -* Easy migrate your static settings to dynamic settings. +* Easily migrate your static settings to dynamic settings. * Admin interface to edit the dynamic settings. Installation -============ +------------ -Install from here using ``pip``:: +Install from PyPI:: + + pip install django-constance + +Or install the `in-development version`_ using ``pip``:: pip install -e git+git://github.com/aleszoulek/django-constance#egg=django-constance +.. _`in-development version`: https://github.com/aleszoulek/django-constance/tarball/master#egg=django-constance-dev + Configuration -============= - -Modify your ``settings.py``. Add ``constance`` to your ``INSTALLED_APPS``, -point ``CONSTANCE_CONNECTION`` to your Redis instance, and move each -key you want to turn dynamic into the ``CONSTANCE_CONFIG`` section, like this:: +------------- +Modify your ``settings.py``. Add ``'constance'`` to your ``INSTALLED_APPS``, +and move each key you want to turn dynamic into the ``CONSTANCE_CONFIG`` +section, like this:: INSTALLED_APPS = ( ... 'constance', ) - CONSTANCE_CONNECTION = { - 'host': 'localhost', - 'port': 6379, - 'db': 0, - } - - CONSTANCE_CONFIG = { 'MY_SETTINGS_KEY': (42, 'the answer to everything'), } -Here, ``42`` is the default value for the key MY_SETTINGS_KEY if it is not -found in Redis. The other member of the tuple is a help text the admin -will show. +Here, ``42`` is the default value for the key ``MY_SETTINGS_KEY`` if it is +not found in the backend. The other member of the tuple is a help text the +admin will show. + +See the `Backends`_ section how to setup the backend. + +Backends +~~~~~~~~ + +Constance ships with a series of backends that are used to store the +configuration values: + +* ``constance.backends.redis.RedisBackend`` (default) + + The is the default backend and has a couple of options: + + * ``CONSTANCE_REDIS_CONNECTION``: a dictionary of parameters to pass to + the to Redis client, e.g.:: + + CONSTANCE_REDIS_CONNECTION = { + 'host': 'localhost', + 'port': 6379, + 'db': 0, + } + + * ``CONSTANCE_REDIS_CONNECTION_CLASS`` (optional): an dotted import + path to a connection to use, e.g.:: + + CONSTANCE_REDIS_CONNECTION_CLASS = 'myproject.myapp.mockup.Connection' + + * ``CONSTANCE_REDIS_PREFIX`` (optional): the prefix to be used for the + key when storing in the Redis database. Defaults to ``'constance:'``. + E.g.:: + + CONSTANCE_REDIS_PREFIX = 'constance:myproject:' + +* ``constance.backends.database.DatabaseBackend`` + + If you want to use this backend you need to add + ``'constance.backends.database'`` to you ``INSTALLED_APPS`` setting. + + It also uses `django-picklefield`_ to store the values in the database, so + you need to install this library, too. E.g.:: + + pip install django-picklefield + + The database backend has the ability to automatically cache the config + values and clear them when saving. You need to set the following setting + to enable this feature:: + + CONSTANCE_DATABASE_CACHE_BACKEND = 'memcached://127.0.0.1:11211/' + + .. note:: + + This won't work with a cache backend that doesn't support + cross-process caching, because correct cache invalidation + can't be guaranteed. + +.. _django-picklefield: http://pypi.python.org/pypi/django-picklefield/ Usage -===== +----- -:: +Constance can be used from your Python code and from your Django templates. + +* Python + + Accessing the config variables is as easy as importing the config + object and accessing the variables with attribute lookups:: from constance import config @@ -53,12 +113,46 @@ Usage if config.MY_SETTINGS_KEY == 42: answer_the_question() +* Django templates -Fire up your ``admin`` and you should see a new application ``Constance`` + To access the config object from your template, you can either + pass the object to the template context:: + + from django.shortcuts import render_to_response + from constance import config + + def myview(request): + return render_to_response('my_template.html', {'config': config}) + + Or you can use the included config context processor.:: + + TEMPLATE_CONTEXT_PROCESSORS = ( + # ... + 'constance.context_processors.config', + ) + + This will add the config instance to the context of any template + rendered with a ``RequestContext``. + + Then, in your template you can refer to the config values just as + any other variable, e.g.:: + +

Welcome on {% config.SITE_NAME %}

+ {% if config.BETA_LAUNCHED %} + Woohoo! Head over here to use the beta. + {% else %} + Sadly we haven't launched yet, click here + to signup for our newletter. + {% endif %} + +Editing +~~~~~~~ + +Fire up your ``admin`` and you should see a new app called ``Constance`` with ``MY_SETTINGS_KEY`` in the ``Config`` pseudo model. Screenshots -=========== +^^^^^^^^^^^ .. figure:: https://github.com/aleszoulek/django-constance/raw/master/docs/screenshot2.png diff --git a/constance/admin.py b/constance/admin.py index f318d35..68ff94c 100644 --- a/constance/admin.py +++ b/constance/admin.py @@ -6,18 +6,15 @@ from django import forms from django.contrib import admin from django.contrib.admin import widgets from django.contrib.admin.options import csrf_protect_m -from django.conf import settings from django.conf.urls.defaults import patterns, url -from django.core.urlresolvers import reverse from django.forms import fields from django.http import HttpResponseRedirect from django.shortcuts import render_to_response from django.template.context import RequestContext -from django.utils.functional import update_wrapper from django.utils.formats import localize from django.utils.translation import ugettext_lazy as _ -from constance import config +from constance import config, settings NUMERIC_WIDGET = forms.TextInput(attrs={'size': 10}) @@ -42,7 +39,7 @@ FIELDS = { class ConstanceForm(forms.Form): def __init__(self, *args, **kwargs): super(ConstanceForm, self).__init__(*args, **kwargs) - for name, (default, help_text) in settings.CONSTANCE_CONFIG.items(): + for name, (default, help_text) in settings.CONFIG.items(): field_class, kwargs = FIELDS[type(default)] self.fields[name] = field_class(label=name, **kwargs) @@ -64,8 +61,13 @@ class ConstanceAdmin(admin.ModelAdmin): @csrf_protect_m def changelist_view(self, request, extra_context=None): - form = ConstanceForm(initial=dict((name, getattr(config, name)) - for name in settings.CONSTANCE_CONFIG)) + # First load a mapping between config name and default value + default_initial = ((name, default) + for name, (default, help_text) in settings.CONFIG.iteritems()) + # Then update the mapping with actually values from the backend + initial = dict(default_initial, + **dict(config._backend.mget(settings.CONFIG.keys()))) + form = ConstanceForm(initial=initial) if request.method == 'POST': form = ConstanceForm(request.POST) if form.is_valid(): @@ -81,8 +83,12 @@ class ConstanceAdmin(admin.ModelAdmin): 'form': form, 'media': self.media + form.media, } - for name, (default, help_text) in settings.CONSTANCE_CONFIG.iteritems(): - value = getattr(config, name) + for name, (default, help_text) in settings.CONFIG.iteritems(): + # First try to load the value from the actual backend + value = initial.get(name) + # Then if the returned value is None, get the default + if value is None: + value = getattr(config, name) context['config'].append({ 'name': name, 'default': localize(default), diff --git a/constance/backends/__init__.py b/constance/backends/__init__.py new file mode 100644 index 0000000..a7f9f40 --- /dev/null +++ b/constance/backends/__init__.py @@ -0,0 +1,25 @@ +""" +Defines the base constance backend +""" + +class Backend(object): + + def get(self, key): + """ + Get the key from the backend store and return the value. + Return None if not found. + """ + raise NotImplementedError + + def mget(self, keys): + """ + Get the keys from the backend store and return a list of the values. + Return an empty list if not found. + """ + raise NotImplementedError + + def set(self, key, value): + """ + Add the value to the backend store given the key. + """ + raise NotImplementedError diff --git a/constance/backends/database/__init__.py b/constance/backends/database/__init__.py new file mode 100644 index 0000000..6798480 --- /dev/null +++ b/constance/backends/database/__init__.py @@ -0,0 +1,59 @@ +from django.core.exceptions import ImproperlyConfigured +from django.db.models.signals import post_save +from django.utils.functional import memoize +from django.core.cache import get_cache +from django.core.cache.backends.locmem import CacheClass as LocMemCacheClass + +from constance.backends import Backend +from constance import settings + +db_cache = None +if settings.DATABASE_CACHE_BACKEND: + db_cache = get_cache(settings.DATABASE_CACHE_BACKEND) + if isinstance(db_cache, LocMemCacheClass): + raise ImproperlyConfigured( + "The CONSTANCE_DATABASE_CACHE_BACKEND setting refers to a " + "subclass of Django's local-memory backend (%r). Please set " + "it to a backend that supports cross-process caching." + % settings.DATABASE_CACHE_BACKEND) + +class DatabaseBackend(Backend): + def __init__(self): + from constance.backends.database.models import Constance + self._model = Constance + if not self._model._meta.installed: + raise ImproperlyConfigured( + "The constance.backends.database app isn't installed " + "correctly. Make sure it's in your INSTALLED_APPS setting.") + # Clear simple cache. + post_save.connect(self.clear, sender=self._model) + + def mget(self, keys): + for const in self._model._default_manager.filter(key__in=keys): + yield const.key, const.value + + def get(self, key): + value = None + if db_cache: + value = db_cache.get(key) + if value is None: + try: + value = self._model._default_manager.get(key=key).value + except self._model.DoesNotExist: + pass + else: + if db_cache: + db_cache.add(key, value) + return value + + def set(self, key, value): + constance, created = self._model._default_manager.get_or_create( + key=key, defaults={'value': value} + ) + if not created: + constance.value = value + constance.save() + + def clear(self, sender, instance, created, **kwargs): + if db_cache and not created: + db_cache.clear() diff --git a/constance/backends/database/models.py b/constance/backends/database/models.py new file mode 100644 index 0000000..64819c1 --- /dev/null +++ b/constance/backends/database/models.py @@ -0,0 +1,23 @@ +from django.db import models +from django.core.exceptions import ImproperlyConfigured + +from django.utils.translation import ugettext_lazy as _ + +try: + from picklefield import PickledObjectField +except ImportError: + raise ImproperlyConfigured("Couldn't find the the 3rd party app " + "django-picklefield which is required for " + "the constance database backend.") + +class Constance(models.Model): + key = models.TextField() + value = PickledObjectField() + + class Meta: + verbose_name = _('constance') + verbose_name_plural = _('constances') + db_table = 'constance_config' + + def __unicode__(self): + return self.key diff --git a/constance/backends/redisd.py b/constance/backends/redisd.py new file mode 100644 index 0000000..1c59382 --- /dev/null +++ b/constance/backends/redisd.py @@ -0,0 +1,47 @@ +import itertools + +from django.core.exceptions import ImproperlyConfigured + +from constance import settings, utils +from constance.backends import Backend + +try: + from cPickle import loads, dumps +except ImportError: + from pickle import loads, dumps + + +class RedisBackend(Backend): + + def __init__(self): + super(RedisBackend, self).__init__() + self._prefix = settings.PREFIX + connection_cls = settings.CONNECTION_CLASS + if connection_cls is not None: + self._rd = utils.import_module_attr(connection_cls)() + else: + try: + import redis + except ImportError: + raise ImproperlyConfigured( + "The Redis backend requires redis-py to be installed.") + self._rd = redis.Redis(**settings.REDIS_CONNECTION) + + def add_prefix(self, key): + return "%s%s" % (self._prefix, key) + + def get(self, key): + value = self._rd.get(self.add_prefix(key)) + if value: + return loads(value) + return None + + def mget(self, keys): + if not keys: + return [] + prefixed_keys = [self.add_prefix(key) for key in keys] + values = (loads(value) for value in self._rd.mget(prefixed_keys)) + return itertools.izip(keys, values) + + def set(self, key, value): + self._rd.set(self.add_prefix(key), dumps(value)) diff --git a/constance/config.py b/constance/config.py index 0bf670a..79751db 100644 --- a/constance/config.py +++ b/constance/config.py @@ -1,44 +1,29 @@ -import os -import redis - -try: - from cPickle import loads, dumps -except ImportError: - from pickle import loads, dumps - -def import_module(path): - package, module = path.rsplit('.', 1) - return getattr(__import__(package, None, None, [module]), module) - -settings = import_module(os.getenv('CONSTANCE_SETTINGS_MODULE', 'django.conf.settings')) - +from constance import settings, utils class Config(object): - + """ + The global config wrapper that handles the backend. + """ def __init__(self): - super(Config, self).__setattr__('_prefix', getattr(settings, 'CONSTANCE_PREFIX', 'constance:')) - try: - super(Config, self).__setattr__('_rd', import_module(settings.CONSTANCE_CONNECTION_CLASS)()) - except AttributeError: - super(Config, self).__setattr__('_rd', redis.Redis(**settings.CONSTANCE_CONNECTION)) + super(Config, self).__setattr__('_backend', + utils.import_module_attr(settings.BACKEND)()) def __getattr__(self, key): try: - default, help_text = settings.CONSTANCE_CONFIG[key] - except KeyError, e: + default, help_text = settings.CONFIG[key] + except KeyError: raise AttributeError(key) - result = self._rd.get("%s%s" % (self._prefix, key)) + result = self._backend.get(key) if result is None: result = default setattr(self, key, default) return result - return loads(result) + return result def __setattr__(self, key, value): - if key not in settings.CONSTANCE_CONFIG: + if key not in settings.CONFIG: raise AttributeError(key) - self._rd.set("%s%s" % (self._prefix, key), dumps(value)) + self._backend.set(key, value) def __dir__(self): - return settings.CONSTANCE_CONFIG.keys() - + return settings.CONFIG.iterkeys() diff --git a/constance/settings.py b/constance/settings.py new file mode 100644 index 0000000..83e4648 --- /dev/null +++ b/constance/settings.py @@ -0,0 +1,22 @@ +import os +from constance.utils import import_module_attr + +settings = import_module_attr( + os.getenv('CONSTANCE_SETTINGS_MODULE', 'django.conf.settings') +) + +PREFIX = getattr(settings, 'CONSTANCE_REDIS_PREFIX', + getattr(settings, 'CONSTANCE_PREFIX', 'constance:')) + +BACKEND = getattr(settings, 'CONSTANCE_BACKEND', 'constance.backends.redisd.RedisBackend') + +CONFIG = getattr(settings, 'CONSTANCE_CONFIG', {}) + +CONNECTION_CLASS = getattr(settings, 'CONSTANCE_REDIS_CONNECTION_CLASS', + getattr(settings, 'CONSTANCE_CONNECTION_CLASS', None)) + +REDIS_CONNECTION = getattr(settings, 'CONSTANCE_REDIS_CONNECTION', + getattr(settings, 'CONSTANCE_CONNECTION', {})) + +DATABASE_CACHE_BACKEND = getattr(settings, 'CONSTANCE_DATABASE_CACHE_BACKEND', + None) diff --git a/constance/utils.py b/constance/utils.py new file mode 100644 index 0000000..f6dc970 --- /dev/null +++ b/constance/utils.py @@ -0,0 +1,5 @@ +from django.utils.importlib import import_module + +def import_module_attr(path): + package, module = path.rsplit('.', 1) + return getattr(import_module(package), module) diff --git a/tests/testproject/settings.py b/tests/testproject/settings.py index 89163b5..5d8efc6 100644 --- a/tests/testproject/settings.py +++ b/tests/testproject/settings.py @@ -16,6 +16,7 @@ INSTALLED_APPS = ( 'django.contrib.admin', 'constance', + 'constance.backends.database', 'testproject.test_app', ) diff --git a/tests/testproject/test_app/redis_mockup.py b/tests/testproject/test_app/redis_mockup.py index 0af0017..5b0778b 100644 --- a/tests/testproject/test_app/redis_mockup.py +++ b/tests/testproject/test_app/redis_mockup.py @@ -2,4 +2,10 @@ class Connection(dict): def set(self, key, value): self[key] = value - + def mget(self, keys): + values = [] + for key in keys: + value = self.get(key, None) + if value is not None: + values.append(value) + return values diff --git a/tests/testproject/test_app/tests/test_config.py b/tests/testproject/test_app/tests/test_config.py index 1ca6756..3b18953 100644 --- a/tests/testproject/test_app/tests/test_config.py +++ b/tests/testproject/test_app/tests/test_config.py @@ -1,5 +1,6 @@ # -*- encoding: utf-8 -*- +import sys from datetime import datetime, date, time from decimal import Decimal @@ -8,21 +9,19 @@ from django.conf import settings from django.contrib import admin from django.contrib.auth.models import User -from constance import config +from constance import settings from constance.admin import Config # Use django RequestFactory later on from testproject.test_app.tests.helpers import FakeRequest - -class TestStorage(TestCase): - - def tearDown(self): - config._rd.clear() +class TestStorage(object): def test_store(self): # read defaults + del sys.modules['constance'] + from constance import config self.assertEquals(config.INT_VALUE, 1) self.assertEquals(config.LONG_VALUE, 123456L) self.assertEquals(config.BOOL_VALUE, True) @@ -59,6 +58,7 @@ class TestStorage(TestCase): self.assertEquals(config.TIME_VALUE, time(1, 59, 0)) def test_nonexistent(self): + from constance import config try: config.NON_EXISTENT except Exception, e: @@ -71,6 +71,31 @@ class TestStorage(TestCase): pass self.assertEquals(type(e), AttributeError) +class TestRedis(TestCase, TestStorage): + + def setUp(self): + self.old_backend = settings.BACKEND + settings.BACKEND = 'constance.backends.redisd.RedisBackend' + + def tearDown(self): + del sys.modules['constance'] + from constance import config + config._backend._rd.clear() + settings.BACKEND = self.old_backend + import constance + constance.config = Config() + +class TestDatabase(TestCase, TestStorage): + + def setUp(self): + self.old_backend = settings.BACKEND + settings.BACKEND = 'constance.backends.database.DatabaseBackend' + + def tearDown(self): + del sys.modules['constance'] + settings.BACKEND = self.old_backend + import constance + constance.config = Config() class TestAdmin(TestCase): model = Config