Add a facility for building stacked configuration models that control the same configuration on a Global, Site, Org and Course level

This commit is contained in:
Calen Pennington
2018-10-12 14:01:14 -04:00
parent 3dbef0b209
commit 8901bf4482
6 changed files with 193 additions and 0 deletions

View File

@@ -997,6 +997,7 @@ INSTALLED_APPS = [
# Database-backed configuration
'config_models',
'openedx.core.djangoapps.config_model_utils',
'waffle',
# Monitor the status of services

View File

@@ -2035,6 +2035,7 @@ INSTALLED_APPS = [
# Database-backed configuration
'config_models',
'openedx.core.djangoapps.config_model_utils',
'waffle',
# Monitor the status of services

View File

@@ -0,0 +1,11 @@
"""
Django AppConfig for ConfigurationModel utilities
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.apps import AppConfig
class ConfigModelUtilsConfig(AppConfig):
name = 'openedx.core.djangoapps.config_model_utils'

View File

@@ -0,0 +1,180 @@
"""
Useful ConfigurationModel subclasses
StackedConfigurationModel: A ConfigurationModel that can be overridden at site, org and course levels
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from collections import namedtuple
from django.conf import settings
from django.db import models
from django.contrib.sites.models import Site
from django.contrib.sites.requests import RequestSite
from django.core.exceptions import ValidationError
from django.utils.translation import ugettext_lazy as _
import crum
from config_models.models import ConfigurationModel
from openedx.core.djangoapps.site_configuration.models import SiteConfiguration
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
class StackedConfigurationModel(ConfigurationModel):
"""
A ConfigurationModel that stacks Global, Site, Org, and Course level
configuration values.
"""
class Meta(object):
abstract = True
KEY_FIELDS = ('site', 'org', 'course')
STACKABLE_FIELDS = ('enabled',)
enabled = models.NullBooleanField(default=None, verbose_name=_("Enabled"))
site = models.ForeignKey(Site, on_delete=models.CASCADE, null=True)
org = models.CharField(max_length=255, db_index=True, null=True)
course = models.ForeignKey(
CourseOverview,
on_delete=models.DO_NOTHING,
null=True,
)
@classmethod
def attribute_tuple(cls):
"""
Returns a namedtuple with all attributes that can be overridden on this config model.
For example, if MyStackedConfig.STACKABLE_FIELDS = ('enabled', 'enabled_as_of', 'studio_enabled'),
then:
# These lines are the same
MyStackedConfig.attribute_tuple()
namedtuple('MyStackedConfigValues', ('enabled', 'enabled_as_of', 'studio_enabled'))
# attribute_tuple() behavior
MyStackedConfigValues = MyStackedConfig.attribute_tuple()
MyStackedConfigValues(True, '10/1/18', False).enabled # True
MyStackedConfigValues(True, '10/1/18', False).enabled_as_of # '10/1/18'
MyStackedConfigValues(True, '10/1/18', False).studio_enabled # False
"""
if hasattr(cls, '_attribute_tuple'):
return cls._attribute_tuple
cls._attribute_tuple = namedtuple(
'{}Values'.format(cls.__name__),
cls.STACKABLE_FIELDS
)
return cls._attribute_tuple
@classmethod
def current(cls, site=None, org=None, course=None): # pylint: disable=arguments-differ
"""
Return the current overridden configuration at the specified level.
Only one level may be specified at a time. Specifying multiple levels
will result in a ValueError.
For example, considering the following set of requirements:
Global: Feature Disabled
edx.org (Site): Feature Enabled
Harvard (org): Feature Disabled
CS50 (course): Feature Enabled
Assuming these values had been properly configured, these calls would result
MyStackedConfig.current() # False
MyStackedConfig.current(site=Site(domain='edx.org')) # True
MyStackedConfig.current(site=Site(domain='whitelabel.edx.org')) # False -- value derived from global setting
MyStackedConfig.current(org='HarvardX') # False
MyStackedConfig.current(org='MITx') # True -- value derived from edx.org site
MyStackedConfig.current(course=CourseKey(org='HarvardX', course='CS50', run='2018_Q1')) # True
cs50 = CourseKey(org='HarvardX', course='Bio101', run='2018_Q1')
MyStackedConfig.current(course=cs50) # False -- value derived from HarvardX org
The following calls would result in errors due to overspecification:
MyStackedConfig.current(site=Site(domain='edx.org'), org='HarvardX')
MyStackedConfig.current(site=Site(domain='edx.org'), course=cs50)
MyStackedConfig.current(org='HarvardX', course=cs50)
Arguments:
site: The Site to check current values for
org: The org to check current values for
course: The course to check current values for
Returns:
An instance of :class:`cls.attribute_tuple()` with the overridden values
specified down to the level of the supplied argument (or global values if
no arguments are supplied).
"""
# Raise an error if more than one of site/org/course are specified simultaneously.
if len([arg for arg in [site, org, course] if arg is not None]) > 1:
raise ValueError("Only one of site, org, and course can be specified")
if org is None and course is not None:
org = cls._org_from_course(course)
if site is None and org is not None:
site = cls._site_from_org(org)
stackable_fields = [cls._meta.get_field(field_name) for field_name in cls.STACKABLE_FIELDS]
field_defaults = {
field.name: field.get_default()
for field in stackable_fields
}
values = field_defaults.copy()
global_current = super(StackedConfigurationModel, cls).current(None, None, None)
for field in stackable_fields:
values[field.name] = field.value_from_object(global_current)
def _override_fields_with_level(level_config):
for field in stackable_fields:
value = field.value_from_object(level_config)
if value != field_defaults[field.name]:
values[field.name] = value
if site is not None:
_override_fields_with_level(
super(StackedConfigurationModel, cls).current(site, None, None)
)
if org is not None:
_override_fields_with_level(
super(StackedConfigurationModel, cls).current(None, org, None)
)
if course is not None:
_override_fields_with_level(
super(StackedConfigurationModel, cls).current(None, None, course)
)
return cls.attribute_tuple()(**values)
@classmethod
def _org_from_course(cls, course_key):
return course_key.org
@classmethod
def _site_from_org(cls, org):
configuration = SiteConfiguration.get_configuration_for_org(org)
if configuration is None:
try:
return Site.objects.get(id=settings.SITE_ID)
except Site.DoesNotExist:
return RequestSite(crum.get_current_request())
else:
return configuration.site
def clean(self):
# fail validation if more than one of site/org/course are specified simultaneously
if len([arg for arg in [self.site, self.org, self.course] if arg is not None]) > 1:
raise ValidationError(
_('Configuration may not be specified at more than one level at once.')
)