Commit d379b35f by Calen Pennington

Add config_model, a library for database backed configuration

ConfigurationModels can be managed using the admin site. They are
append-only, and track the user who is making the change, and the time
that the change was made. The configuration is stored in the database,
and cached for performance.

[LMS-1220]
parent 881e3ba5
......@@ -12,6 +12,10 @@ Blades: Fix bug when image response in Firefox does not retain input. BLD-711.
Blades: Give numerical response tolerance as a range. BLD-25.
Common: Add a utility app for building databased-backed configuration
for specific application features. Includes admin site customization
for easier administration and tracking.
Common: Add the ability to dark-launch site translations. These languages
will be unavailable to users except through the use of a specific query
parameter.
......
......@@ -413,6 +413,9 @@ INSTALLED_APPS = (
'south',
'method_override',
# Database-backed configuration
'config_models',
# Monitor the status of services
'service_status',
......
"""
Model-Based Configuration
=========================
This app allows other apps to easily define a configuration model
that can be hooked into the admin site to allow configuration management
with auditing.
Installation
------------
Add ``config_models`` to your ``INSTALLED_APPS`` list.
Usage
-----
Create a subclass of ``ConfigurationModel``, with fields for each
value that needs to be configured::
class MyConfiguration(ConfigurationModel):
frobble_timeout = IntField(default=10)
frazzle_target = TextField(defalut="debug")
This is a normal django model, so it must be synced and migrated as usual.
The default values for the fields in the ``ConfigurationModel`` will be
used if no configuration has yet been created.
Register that class with the Admin site, using the ``ConfigurationAdminModel``::
from django.contrib import admin
from config_models.admin import ConfigurationModelAdmin
admin.site.register(MyConfiguration, ConfigurationModelAdmin)
Use the configuration in your code::
def my_view(self, request):
config = MyConfiguration.current()
fire_the_missiles(config.frazzle_target, timeout=config.frobble_timeout)
Use the admin site to add new configuration entries. The most recently created
entry is considered to be ``current``.
Configuration
-------------
The current ``ConfigurationModel`` will be cached in the ``configuration`` django cache,
or in the ``default`` cache if ``configuration`` doesn't exist. You can specify the cache
timeout in each ``ConfigurationModel`` by setting the ``cache_timeout`` property.
You can change the name of the cache key used by the ``ConfigurationModel`` by overriding
the ``cache_key_name`` function.
Extension
---------
``ConfigurationModels`` are just django models, so they can be extended with new fields
and migrated as usual. Newly added fields must have default values and should be nullable,
so that rollbacks to old versions of configuration work correctly.
"""
"""
Admin site models for managing :class:`.ConfigurationModel` subclasses
"""
from django.forms import models
from django.contrib import admin
from django.http import HttpResponseRedirect
from django.core.urlresolvers import reverse
# pylint: disable=protected-access
class ConfigurationModelAdmin(admin.ModelAdmin):
"""
:class:`~django.contrib.admin.ModelAdmin` for :class:`.ConfigurationModel` subclasses
"""
date_hierarchy = 'change_date'
def get_actions(self, request):
return {
'revert': (ConfigurationModelAdmin.revert, 'revert', 'Revert to the selected configuration')
}
def get_list_display(self, request):
return self.model._meta.get_all_field_names()
# Don't allow deletion of configuration
def has_delete_permission(self, request, obj=None):
return False
# Make all fields read-only when editing an object
def get_readonly_fields(self, request, obj=None):
if obj: # editing an existing object
return self.model._meta.get_all_field_names()
return self.readonly_fields
def add_view(self, request, form_url='', extra_context=None):
# Prepopulate new configuration entries with the value of the current config
get = request.GET.copy()
get.update(models.model_to_dict(self.model.current()))
request.GET = get
return super(ConfigurationModelAdmin, self).add_view(request, form_url, extra_context)
# Hide the save buttons in the change view
def change_view(self, request, object_id, form_url='', extra_context=None):
extra_context = extra_context or {}
extra_context['readonly'] = True
return super(ConfigurationModelAdmin, self).change_view(
request,
object_id,
form_url,
extra_context=extra_context
)
def save_model(self, request, obj, form, change):
obj.changed_by = request.user
super(ConfigurationModelAdmin, self).save_model(request, obj, form, change)
def revert(self, request, queryset):
"""
Admin action to revert a configuration back to the selected value
"""
if queryset.count() != 1:
self.message_user(request, "Please select a single configuration to revert to.")
return
target = queryset[0]
target.id = None
self.save_model(request, target, None, False)
self.message_user(request, "Reverted configuration.")
return HttpResponseRedirect(
reverse(
'admin:{}_{}_change'.format(
self.model._meta.app_label,
self.model._meta.module_name,
),
args=(target.id,),
)
)
"""
Django Model baseclass for database-backed configuration.
"""
from django.db import models
from django.contrib.auth.models import User
from django.core.cache import get_cache, InvalidCacheBackendError
try:
cache = get_cache('configuration') # pylint: disable=invalid-name
except InvalidCacheBackendError:
from django.core.cache import cache
class ConfigurationModel(models.Model):
"""
Abstract base class for model-based configuration
Properties:
cache_timeout (int): The number of seconds that this configuration
should be cached
"""
class Meta(object): # pylint: disable=missing-docstring
abstract = True
# The number of seconds
cache_timeout = 600
change_date = models.DateTimeField(auto_now_add=True)
changed_by = models.ForeignKey(User, editable=False)
enabled = models.BooleanField(default=False)
def save(self, *args, **kwargs):
"""
Clear the cached value when saving a new configuration entry
"""
super(ConfigurationModel, self).save(*args, **kwargs)
cache.delete(self.cache_key_name())
@classmethod
def cache_key_name(cls):
"""Return the name of the key to use to cache the current configuration"""
return 'configuration/{}/current'.format(cls.__name__)
@classmethod
def current(cls):
"""
Return the active configuration entry, either from cache,
from the database, or by creating a new empty entry (which is not
persisted).
"""
cached = cache.get(cls.cache_key_name())
if cached is not None:
return cached
try:
current = cls.objects.order_by('-change_date')[0]
except IndexError:
current = cls()
cache.set(cls.cache_key_name(), current, cls.cache_timeout)
return current
"""
Override the submit_row template tag to remove all save buttons from the
admin dashboard change view if the context has readonly marked in it.
"""
from django.contrib.admin.templatetags.admin_modify import register
from django.contrib.admin.templatetags.admin_modify import submit_row as original_submit_row
@register.inclusion_tag('admin/submit_line.html', takes_context=True)
def submit_row(context):
"""
Overrides 'django.contrib.admin.templatetags.admin_modify.submit_row'.
Manipulates the context going into that function by hiding all of the buttons
in the submit row if the key `readonly` is set in the context.
"""
ctx = original_submit_row(context)
if context.get('readonly', False):
ctx.update({
'show_delete_link': False,
'show_save_as_new': False,
'show_save_and_add_another': False,
'show_save_and_continue': False,
'show_save': False,
})
else:
return ctx
"""
Tests of ConfigurationModel
"""
from django.contrib.auth.models import User
from django.db import models
from django.test import TestCase
from freezegun import freeze_time
from mock import patch
from config_models.models import ConfigurationModel
class ExampleConfig(ConfigurationModel):
"""
Test model for testing ``ConfigurationModels``.
"""
cache_timeout = 300
string_field = models.TextField()
int_field = models.IntegerField(default=10)
@patch('config_models.models.cache')
class ConfigurationModelTests(TestCase):
"""
Tests of ConfigurationModel
"""
def setUp(self):
self.user = User()
self.user.save()
def test_cache_deleted_on_save(self, mock_cache):
ExampleConfig(changed_by=self.user).save()
mock_cache.delete.assert_called_with(ExampleConfig.cache_key_name())
def test_cache_key_name(self, _mock_cache):
self.assertEquals(ExampleConfig.cache_key_name(), 'configuration/ExampleConfig/current')
def test_no_config_empty_cache(self, mock_cache):
mock_cache.get.return_value = None
current = ExampleConfig.current()
self.assertEquals(current.int_field, 10)
self.assertEquals(current.string_field, '')
mock_cache.set.assert_called_with(ExampleConfig.cache_key_name(), current, 300)
def test_no_config_full_cache(self, mock_cache):
current = ExampleConfig.current()
self.assertEquals(current, mock_cache.get.return_value)
def test_config_ordering(self, mock_cache):
mock_cache.get.return_value = None
with freeze_time('2012-01-01'):
first = ExampleConfig(changed_by=self.user)
first.string_field = 'first'
first.save()
second = ExampleConfig(changed_by=self.user)
second.string_field = 'second'
second.save()
self.assertEquals(ExampleConfig.current().string_field, 'second')
def test_cache_set(self, mock_cache):
mock_cache.get.return_value = None
first = ExampleConfig(changed_by=self.user)
first.string_field = 'first'
first.save()
ExampleConfig.current()
mock_cache.set.assert_called_with(ExampleConfig.cache_key_name(), first, 300)
......@@ -983,6 +983,9 @@ INSTALLED_APPS = (
'djcelery',
'south',
# Database-backed configuration
'config_models',
# Monitor the status of services
'service_status',
......
......@@ -22,10 +22,15 @@ def run_tests(system, report_dir, test_id=nil, stop_on_failure=true)
# to the Djangoapps we want to test. Otherwise, it will
# run tests on all installed packages.
default_test_id = "#{system}/djangoapps common/djangoapps"
# We need to use $DIR/*, rather than just $DIR so that
# django-nose will import them early in the test process,
# thereby making sure that we load any django models that are
# only defined in test files.
default_test_id = "#{system}/djangoapps/* common/djangoapps/*"
if system == :lms || system == :cms
default_test_id += " #{system}/lib"
default_test_id += " #{system}/lib/*"
end
if test_id.nil?
......
......@@ -94,21 +94,22 @@ transifex-client==0.10
# Used for testing
coverage==3.7
ddt==0.6.0
django-crum==0.5
django-debug-toolbar-mongo
django_debug_toolbar==0.9.4
django_nose==1.1
factory_boy==2.2.1
freezegun==0.1.11
mock==1.0.1
nose-exclude
nose-ignore-docstring
nosexcover==1.0.7
pep8==1.4.5
pylint==0.28
python-subunit==0.0.16
rednose==0.3
selenium==2.34.0
splinter==0.5.4
django_nose==1.1
django_debug_toolbar==0.9.4
django-debug-toolbar-mongo
nose-ignore-docstring
nose-exclude
django-crum==0.5
python-subunit==0.0.16
testtools==0.9.34
git+https://github.com/mfogel/django-settings-context-processor.git
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment