This commit is contained in:
Ales Zoulek 2011-01-24 13:33:29 +01:00
commit d17bf804a4
13 changed files with 367 additions and 68 deletions

View file

@ -3,3 +3,4 @@ Vojtech Jasny <voy@voy.cz>
Roman Krejcik <farin@farin.cz>
Jan Vesely <jave@janvesely.com>
Ales Zoulek <ales.zoulek@gmail.com>
Jannis Leidel <jannis@leidel.info>

View file

@ -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.::
<h1>Welcome on {% config.SITE_NAME %}</h1>
{% if config.BETA_LAUNCHED %}
Woohoo! Head over <a href="/sekrit/">here</a> to use the beta.
{% else %}
Sadly we haven't launched yet, click <a href="/newsletter/">here</a>
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

View file

@ -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),

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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))

View file

@ -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()

22
constance/settings.py Normal file
View file

@ -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)

5
constance/utils.py Normal file
View file

@ -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)

View file

@ -16,6 +16,7 @@ INSTALLED_APPS = (
'django.contrib.admin',
'constance',
'constance.backends.database',
'testproject.test_app',
)

View file

@ -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

View file

@ -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