Commit 4fb3da23 by John Eskew

Remove config_models & use external config_models repo instead.

parent 5cb129e2
"""
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.contrib.admin import ListFilter
from django.core.cache import caches, InvalidCacheBackendError
from django.core.files.base import File
from django.core.urlresolvers import reverse
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.utils.translation import ugettext_lazy as _
try:
cache = caches['configuration'] # pylint: disable=invalid-name
except InvalidCacheBackendError:
from django.core.cache import cache
# 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.get_displayable_field_names()
def get_displayable_field_names(self):
"""
Return all field names, excluding reverse foreign key relationships.
"""
return [
f.name
for f in self.model._meta.get_fields()
if not f.one_to_many
]
# 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.get_displayable_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)
cache.delete(obj.cache_key_name(*(getattr(obj, key_name) for key_name in obj.KEY_FIELDS)))
cache.delete(obj.key_values_cache_key_name())
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.model_name,
),
args=(target.id,),
)
)
class ShowHistoryFilter(ListFilter):
"""
Admin change view filter to show only the most recent (i.e. the "current") row for each
unique key value.
"""
title = _('Status')
parameter_name = 'show_history'
def __init__(self, request, params, model, model_admin):
super(ShowHistoryFilter, self).__init__(request, params, model, model_admin)
if self.parameter_name in params:
value = params.pop(self.parameter_name)
self.used_parameters[self.parameter_name] = value
def has_output(self):
""" Should this filter be shown? """
return True
def choices(self, cl):
""" Returns choices ready to be output in the template. """
show_all = self.used_parameters.get(self.parameter_name) == "1"
return (
{
'display': _('Current Configuration'),
'selected': not show_all,
'query_string': cl.get_query_string({}, [self.parameter_name]),
},
{
'display': _('All (Show History)'),
'selected': show_all,
'query_string': cl.get_query_string({self.parameter_name: "1"}, []),
}
)
def queryset(self, request, queryset):
""" Filter the queryset. No-op since it's done by KeyedConfigurationModelAdmin """
return queryset
def expected_parameters(self):
""" List the query string params used by this filter """
return [self.parameter_name]
class KeyedConfigurationModelAdmin(ConfigurationModelAdmin):
"""
:class:`~django.contrib.admin.ModelAdmin` for :class:`.ConfigurationModel` subclasses that
use extra keys (i.e. they have KEY_FIELDS set).
"""
date_hierarchy = None
list_filter = (ShowHistoryFilter, )
def get_queryset(self, request):
"""
Annote the queryset with an 'is_active' property that's true iff that row is the most
recently added row for that particular set of KEY_FIELDS values.
Filter the queryset to show only is_active rows by default.
"""
if request.GET.get(ShowHistoryFilter.parameter_name) == '1':
queryset = self.model.objects.with_active_flag()
else:
# Show only the most recent row for each key.
queryset = self.model.objects.current_set()
ordering = self.get_ordering(request)
if ordering:
return queryset.order_by(*ordering)
return queryset
def get_list_display(self, request):
""" Add a link to each row for creating a new row using the chosen row as a template """
return self.get_displayable_field_names() + ['edit_link']
def add_view(self, request, form_url='', extra_context=None):
# Prepopulate new configuration entries with the value of the current config, if given:
if 'source' in request.GET:
get = request.GET.copy()
source_id = int(get.pop('source')[0])
source = get_object_or_404(self.model, pk=source_id)
source_dict = models.model_to_dict(source)
for field_name, field_value in source_dict.items():
# read files into request.FILES, if:
# * user hasn't ticked the "clear" checkbox
# * user hasn't uploaded a new file
if field_value and isinstance(field_value, File):
clear_checkbox_name = '{0}-clear'.format(field_name)
if request.POST.get(clear_checkbox_name) != 'on':
request.FILES.setdefault(field_name, field_value)
get[field_name] = field_value
request.GET = get
# Call our grandparent's add_view, skipping the parent code
# because the parent code has a different way to prepopulate new configuration entries
# with the value of the latest config, which doesn't make sense for keyed models.
# pylint: disable=bad-super-call
return super(ConfigurationModelAdmin, self).add_view(request, form_url, extra_context)
def edit_link(self, inst):
""" Edit link for the change view """
if not inst.is_active:
return u'--'
update_url = reverse('admin:{}_{}_add'.format(self.model._meta.app_label, self.model._meta.model_name))
update_url += "?source={}".format(inst.pk)
return u'<a href="{}">{}</a>'.format(update_url, _('Update'))
edit_link.allow_tags = True
edit_link.short_description = _('Update')
"""Decorators for model-based configuration. """
from functools import wraps
from django.http import HttpResponseNotFound
def require_config(config_model):
"""View decorator that enables/disables a view based on configuration.
Arguments:
config_model (ConfigurationModel subclass): The class of the configuration
model to check.
Returns:
HttpResponse: 404 if the configuration model is disabled,
otherwise returns the response from the decorated view.
"""
def _decorator(func):
@wraps(func)
def _inner(*args, **kwargs):
if not config_model.current().enabled:
return HttpResponseNotFound()
else:
return func(*args, **kwargs)
return _inner
return _decorator
"""
Populates a ConfigurationModel by deserializing JSON data contained in a file.
"""
import os
from optparse import make_option
from django.core.management.base import BaseCommand, CommandError
from django.utils.translation import ugettext_lazy as _
from config_models.utils import deserialize_json
class Command(BaseCommand):
"""
This command will deserialize the JSON data in the supplied file to populate
a ConfigurationModel. Note that this will add new entries to the model, but it
will not delete any entries (ConfigurationModel entries are read-only).
"""
help = """
Populates a ConfigurationModel by deserializing the supplied JSON.
JSON should be in a file, with the following format:
{ "model": "config_models.ExampleConfigurationModel",
"data":
[
{ "enabled": True,
"color": "black"
...
},
{ "enabled": False,
"color": "yellow"
...
},
...
]
}
A username corresponding to an existing user must be specified to indicate who
is executing the command.
$ ... populate_model -f path/to/file.json -u username
"""
option_list = BaseCommand.option_list + (
make_option('-f', '--file',
metavar='JSON_FILE',
dest='file',
default=False,
help='JSON file to import ConfigurationModel data'),
make_option('-u', '--username',
metavar='USERNAME',
dest='username',
default=False,
help='username to specify who is executing the command'),
)
def handle(self, *args, **options):
if 'file' not in options or not options['file']:
raise CommandError(_("A file containing JSON must be specified."))
if 'username' not in options or not options['username']:
raise CommandError(_("A valid username must be specified."))
json_file = options['file']
if not os.path.exists(json_file):
raise CommandError(_("File {0} does not exist").format(json_file))
self.stdout.write(_("Importing JSON data from file {0}").format(json_file))
with open(json_file) as data:
created_entries = deserialize_json(data, options['username'])
self.stdout.write(_("Import complete, {0} new entries created").format(created_entries))
"""
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
{
"model": "config_models.ExampleDeserializeConfig",
"data": [
{
"name": "betty",
"enabled": true,
"int_field": 5
},
{
"name": "fred",
"enabled": false
}
]
}
"""
Tests of the populate_model management command and its helper utils.deserialize_json method.
"""
import textwrap
import os.path
from django.utils import timezone
from django.utils.six import BytesIO
from django.contrib.auth.models import User
from django.core.management.base import CommandError
from django.db import models
from config_models.management.commands import populate_model
from config_models.models import ConfigurationModel
from config_models.utils import deserialize_json
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
class ExampleDeserializeConfig(ConfigurationModel):
"""
Test model for testing deserialization of ``ConfigurationModels`` with keyed configuration.
"""
KEY_FIELDS = ('name',)
name = models.TextField()
int_field = models.IntegerField(default=10)
def __unicode__(self):
return "ExampleDeserializeConfig(enabled={}, name={}, int_field={})".format(
self.enabled, self.name, self.int_field
)
class DeserializeJSONTests(CacheIsolationTestCase):
"""
Tests of deserializing the JSON representation of ConfigurationModels.
"""
def setUp(self):
super(DeserializeJSONTests, self).setUp()
self.test_username = 'test_worker'
User.objects.create_user(username=self.test_username)
self.fixture_path = os.path.join(os.path.dirname(__file__), 'data', 'data.json')
def test_deserialize_models(self):
"""
Tests the "happy path", where 2 instances of the test model should be created.
A valid username is supplied for the operation.
"""
start_date = timezone.now()
with open(self.fixture_path) as data:
entries_created = deserialize_json(data, self.test_username)
self.assertEquals(2, entries_created)
self.assertEquals(2, ExampleDeserializeConfig.objects.count())
betty = ExampleDeserializeConfig.current('betty')
self.assertTrue(betty.enabled)
self.assertEquals(5, betty.int_field)
self.assertGreater(betty.change_date, start_date)
self.assertEquals(self.test_username, betty.changed_by.username)
fred = ExampleDeserializeConfig.current('fred')
self.assertFalse(fred.enabled)
self.assertEquals(10, fred.int_field)
self.assertGreater(fred.change_date, start_date)
self.assertEquals(self.test_username, fred.changed_by.username)
def test_existing_entries_not_removed(self):
"""
Any existing configuration model entries are retained
(though they may be come history)-- deserialize_json is purely additive.
"""
ExampleDeserializeConfig(name="fred", enabled=True).save()
ExampleDeserializeConfig(name="barney", int_field=200).save()
with open(self.fixture_path) as data:
entries_created = deserialize_json(data, self.test_username)
self.assertEquals(2, entries_created)
self.assertEquals(4, ExampleDeserializeConfig.objects.count())
self.assertEquals(3, len(ExampleDeserializeConfig.objects.current_set()))
self.assertEquals(5, ExampleDeserializeConfig.current('betty').int_field)
self.assertEquals(200, ExampleDeserializeConfig.current('barney').int_field)
# The JSON file changes "enabled" to False for Fred.
fred = ExampleDeserializeConfig.current('fred')
self.assertFalse(fred.enabled)
def test_duplicate_entries_not_made(self):
"""
If there is no change in an entry (besides changed_by and change_date),
a new entry is not made.
"""
with open(self.fixture_path) as data:
entries_created = deserialize_json(data, self.test_username)
self.assertEquals(2, entries_created)
with open(self.fixture_path) as data:
entries_created = deserialize_json(data, self.test_username)
self.assertEquals(0, entries_created)
# Importing twice will still only result in 2 records (second import a no-op).
self.assertEquals(2, ExampleDeserializeConfig.objects.count())
# Change Betty.
betty = ExampleDeserializeConfig.current('betty')
betty.int_field = -8
betty.save()
self.assertEquals(3, ExampleDeserializeConfig.objects.count())
self.assertEquals(-8, ExampleDeserializeConfig.current('betty').int_field)
# Now importing will add a new entry for Betty.
with open(self.fixture_path) as data:
entries_created = deserialize_json(data, self.test_username)
self.assertEquals(1, entries_created)
self.assertEquals(4, ExampleDeserializeConfig.objects.count())
self.assertEquals(5, ExampleDeserializeConfig.current('betty').int_field)
def test_bad_username(self):
"""
Tests the error handling when the specified user does not exist.
"""
test_json = textwrap.dedent("""
{
"model": "config_models.ExampleDeserializeConfig",
"data": [{"name": "dino"}]
}
""")
with self.assertRaisesRegexp(Exception, "User matching query does not exist"):
deserialize_json(BytesIO(test_json), "unknown_username")
def test_invalid_json(self):
"""
Tests the error handling when there is invalid JSON.
"""
test_json = textwrap.dedent("""
{
"model": "config_models.ExampleDeserializeConfig",
"data": [{"name": "dino"
""")
with self.assertRaisesRegexp(Exception, "JSON parse error"):
deserialize_json(BytesIO(test_json), self.test_username)
def test_invalid_model(self):
"""
Tests the error handling when the configuration model specified does not exist.
"""
test_json = textwrap.dedent("""
{
"model": "xxx.yyy",
"data":[{"name": "dino"}]
}
""")
with self.assertRaisesRegexp(Exception, "No installed app"):
deserialize_json(BytesIO(test_json), self.test_username)
class PopulateModelTestCase(CacheIsolationTestCase):
"""
Tests of populate model management command.
"""
def setUp(self):
super(PopulateModelTestCase, self).setUp()
self.file_path = os.path.join(os.path.dirname(__file__), 'data', 'data.json')
self.test_username = 'test_management_worker'
User.objects.create_user(username=self.test_username)
def test_run_command(self):
"""
Tests the "happy path", where 2 instances of the test model should be created.
A valid username is supplied for the operation.
"""
_run_command(file=self.file_path, username=self.test_username)
self.assertEquals(2, ExampleDeserializeConfig.objects.count())
betty = ExampleDeserializeConfig.current('betty')
self.assertEquals(self.test_username, betty.changed_by.username)
fred = ExampleDeserializeConfig.current('fred')
self.assertEquals(self.test_username, fred.changed_by.username)
def test_no_user_specified(self):
"""
Tests that a username must be specified.
"""
with self.assertRaisesRegexp(CommandError, "A valid username must be specified"):
_run_command(file=self.file_path)
def test_bad_user_specified(self):
"""
Tests that a username must be specified.
"""
with self.assertRaisesRegexp(Exception, "User matching query does not exist"):
_run_command(file=self.file_path, username="does_not_exist")
def test_no_file_specified(self):
"""
Tests the error handling when no JSON file is supplied.
"""
with self.assertRaisesRegexp(CommandError, "A file containing JSON must be specified"):
_run_command(username=self.test_username)
def test_bad_file_specified(self):
"""
Tests the error handling when the path to the JSON file is incorrect.
"""
with self.assertRaisesRegexp(CommandError, "File does/not/exist.json does not exist"):
_run_command(file="does/not/exist.json", username=self.test_username)
def _run_command(*args, **kwargs):
"""Run the management command to deserializer JSON ConfigurationModel data. """
command = populate_model.Command()
return command.handle(*args, **kwargs)
"""
Utilities for working with ConfigurationModels.
"""
from django.apps import apps
from rest_framework.parsers import JSONParser
from rest_framework.serializers import ModelSerializer
from django.contrib.auth.models import User
def get_serializer_class(configuration_model):
""" Returns a ConfigurationModel serializer class for the supplied configuration_model. """
class AutoConfigModelSerializer(ModelSerializer):
"""Serializer class for configuration models."""
class Meta(object):
"""Meta information for AutoConfigModelSerializer."""
model = configuration_model
def create(self, validated_data):
if "changed_by_username" in self.context:
validated_data['changed_by'] = User.objects.get(username=self.context["changed_by_username"])
return super(AutoConfigModelSerializer, self).create(validated_data)
return AutoConfigModelSerializer
def deserialize_json(stream, username):
"""
Given a stream containing JSON, deserializers the JSON into ConfigurationModel instances.
The stream is expected to be in the following format:
{ "model": "config_models.ExampleConfigurationModel",
"data":
[
{ "enabled": True,
"color": "black"
...
},
{ "enabled": False,
"color": "yellow"
...
},
...
]
}
If the provided stream does not contain valid JSON for the ConfigurationModel specified,
an Exception will be raised.
Arguments:
stream: The stream of JSON, as described above.
username: The username of the user making the change. This must match an existing user.
Returns: the number of created entries
"""
parsed_json = JSONParser().parse(stream)
serializer_class = get_serializer_class(apps.get_model(parsed_json["model"]))
list_serializer = serializer_class(data=parsed_json["data"], context={"changed_by_username": username}, many=True)
if list_serializer.is_valid():
model_class = serializer_class.Meta.model
for data in reversed(list_serializer.validated_data):
if model_class.equal_to_current(data):
list_serializer.validated_data.remove(data)
entries_created = len(list_serializer.validated_data)
list_serializer.save()
return entries_created
else:
raise Exception(list_serializer.error_messages)
"""
API view to allow manipulation of configuration models.
"""
from rest_framework.generics import CreateAPIView, RetrieveAPIView
from rest_framework.permissions import DjangoModelPermissions
from rest_framework.authentication import SessionAuthentication
from django.db import transaction
from config_models.utils import get_serializer_class
class ReadableOnlyByAuthors(DjangoModelPermissions):
"""Only allow access by users with `add` permissions on the model."""
perms_map = DjangoModelPermissions.perms_map.copy()
perms_map['GET'] = perms_map['OPTIONS'] = perms_map['HEAD'] = perms_map['POST']
class AtomicMixin(object):
"""Mixin to provide atomic transaction for as_view."""
@classmethod
def create_atomic_wrapper(cls, wrapped_func):
"""Returns a wrapped function."""
def _create_atomic_wrapper(*args, **kwargs):
"""Actual wrapper."""
# When a view call fails due to a permissions error, it raises an exception.
# An uncaught exception breaks the DB transaction for any following DB operations
# unless it's wrapped in a atomic() decorator or context manager.
with transaction.atomic():
return wrapped_func(*args, **kwargs)
return _create_atomic_wrapper
@classmethod
def as_view(cls, **initkwargs):
"""Overrides as_view to add atomic transaction."""
view = super(AtomicMixin, cls).as_view(**initkwargs)
return cls.create_atomic_wrapper(view)
class ConfigurationModelCurrentAPIView(AtomicMixin, CreateAPIView, RetrieveAPIView):
"""
This view allows an authenticated user with the appropriate model permissions
to read and write the current configuration for the specified `model`.
Like other APIViews, you can use this by using a url pattern similar to the following::
url(r'config/example_config$', ConfigurationModelCurrentAPIView.as_view(model=ExampleConfig))
"""
authentication_classes = (SessionAuthentication,)
permission_classes = (ReadableOnlyByAuthors,)
model = None
def get_queryset(self):
return self.model.objects.all()
def get_object(self):
# Return the currently active configuration
return self.model.current()
def get_serializer_class(self):
if self.serializer_class is None:
self.serializer_class = get_serializer_class(self.model)
return self.serializer_class
def perform_create(self, serializer):
# Set the requesting user as the one who is updating the configuration
serializer.save(changed_by=self.request.user)
...@@ -92,6 +92,7 @@ git+https://github.com/edx/xblock-utils.git@v1.0.3#egg=xblock-utils==1.0.3 ...@@ -92,6 +92,7 @@ git+https://github.com/edx/xblock-utils.git@v1.0.3#egg=xblock-utils==1.0.3
git+https://github.com/edx/edx-user-state-client.git@1.0.1#egg=edx-user-state-client==1.0.1 git+https://github.com/edx/edx-user-state-client.git@1.0.1#egg=edx-user-state-client==1.0.1
git+https://github.com/edx/xblock-lti-consumer.git@v1.0.9#egg=xblock-lti-consumer==1.0.9 git+https://github.com/edx/xblock-lti-consumer.git@v1.0.9#egg=xblock-lti-consumer==1.0.9
git+https://github.com/edx/edx-proctoring.git@0.14.0#egg=edx-proctoring==0.14.0 git+https://github.com/edx/edx-proctoring.git@0.14.0#egg=edx-proctoring==0.14.0
git+https://github.com/edx/django-config-models.git@0.1.0#egg=config_models==0.1.0
# Third Party XBlocks # Third Party XBlocks
-e git+https://github.com/mitodl/edx-sga@172a90fd2738f8142c10478356b2d9ed3e55334a#egg=edx-sga -e git+https://github.com/mitodl/edx-sga@172a90fd2738f8142c10478356b2d9ed3e55334a#egg=edx-sga
......
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