diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e0be8b21b6..fbdbb611a3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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. diff --git a/cms/envs/common.py b/cms/envs/common.py index cf827a7320..808bf925ca 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -413,6 +413,9 @@ INSTALLED_APPS = ( 'south', 'method_override', + # Database-backed configuration + 'config_models', + # Monitor the status of services 'service_status', diff --git a/common/djangoapps/config_models/README.rst b/common/djangoapps/config_models/README.rst new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/config_models/__init__.py b/common/djangoapps/config_models/__init__.py new file mode 100644 index 0000000000..3f71f1c98e --- /dev/null +++ b/common/djangoapps/config_models/__init__.py @@ -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. +""" diff --git a/common/djangoapps/config_models/admin.py b/common/djangoapps/config_models/admin.py new file mode 100644 index 0000000000..378900f1dc --- /dev/null +++ b/common/djangoapps/config_models/admin.py @@ -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,), + ) + ) diff --git a/common/djangoapps/config_models/models.py b/common/djangoapps/config_models/models.py new file mode 100644 index 0000000000..4a46e384f2 --- /dev/null +++ b/common/djangoapps/config_models/models.py @@ -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 diff --git a/common/djangoapps/config_models/templatetags.py b/common/djangoapps/config_models/templatetags.py new file mode 100644 index 0000000000..8641fd11ea --- /dev/null +++ b/common/djangoapps/config_models/templatetags.py @@ -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 diff --git a/common/djangoapps/config_models/tests.py b/common/djangoapps/config_models/tests.py new file mode 100644 index 0000000000..bb14ad18e8 --- /dev/null +++ b/common/djangoapps/config_models/tests.py @@ -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) diff --git a/lms/envs/common.py b/lms/envs/common.py index 97a1df6367..5557ce9c9e 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -983,6 +983,9 @@ INSTALLED_APPS = ( 'djcelery', 'south', + # Database-backed configuration + 'config_models', + # Monitor the status of services 'service_status', diff --git a/rakelib/tests.rake b/rakelib/tests.rake index 868fa5836f..e09c5d0762 100644 --- a/rakelib/tests.rake +++ b/rakelib/tests.rake @@ -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? diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index bce6479748..3df9a7034a 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -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