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]
This commit is contained in:
@@ -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',
|
||||
|
||||
|
||||
0
common/djangoapps/config_models/README.rst
Normal file
0
common/djangoapps/config_models/README.rst
Normal file
62
common/djangoapps/config_models/__init__.py
Normal file
62
common/djangoapps/config_models/__init__.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
80
common/djangoapps/config_models/admin.py
Normal file
80
common/djangoapps/config_models/admin.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
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,),
|
||||
)
|
||||
)
|
||||
62
common/djangoapps/config_models/models.py
Normal file
62
common/djangoapps/config_models/models.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
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
|
||||
29
common/djangoapps/config_models/templatetags.py
Normal file
29
common/djangoapps/config_models/templatetags.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
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
|
||||
76
common/djangoapps/config_models/tests.py
Normal file
76
common/djangoapps/config_models/tests.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user