Enable course run level overrides for proctoring configuration.

This commit is contained in:
Michael Roytman
2018-11-02 11:41:26 -04:00
committed by Dave St.Germain
parent 77922ae073
commit ecabcf90dd
22 changed files with 627 additions and 90 deletions

View File

@@ -16,7 +16,7 @@ from openedx.core.djangoapps.video_pipeline.models import VideoUploadsEnabledByD
from openedx.core.lib.license import LicenseMixin
from path import Path as path
from pytz import utc
from six import text_type
from six import text_type, iteritems
from xblock.fields import Scope, List, String, Dict, Boolean, Integer, Float
from xmodule import course_metadata_utils
@@ -183,6 +183,151 @@ class TextbookList(List):
return json_data
class ProctoringConfiguration(Dict):
def from_json(self, value):
"""
Return ProctoringConfiguration as full featured Python type. Perform validation on the backend
and the rules and include any inherited values from the platform default.
"""
errors = []
value = super(ProctoringConfiguration, self).from_json(value)
proctoring_backend_settings = getattr(
settings,
'PROCTORING_BACKENDS',
None
)
backend_errors = self._validate_proctoring_backend(value, proctoring_backend_settings)
rules_errors = self._validate_proctoring_rules(value, proctoring_backend_settings)
errors.extend(backend_errors)
errors.extend(rules_errors)
if errors:
raise ValueError(errors)
value = self._get_proctoring_value(value, proctoring_backend_settings)
return value
def _get_proctoring_value(self, value, proctoring_backend_settings):
"""
Return a proctoring value that includes any inherited attributes from the platform defaults
for the backend or rules.
"""
proctoring_provider = value.get('backend', None)
proctoring_provider_rules = value.get('rules', None)
# if both are missing from the value, return the default
if proctoring_provider is None and proctoring_provider_rules is None:
return self.default
# if provider is missing, but rules are not, use the default provider
if proctoring_provider is None and proctoring_provider_rules is not None:
value['backend'] = proctoring_backend_settings.get('DEFAULT', None)
# if rules are missing, but provider is not, use the default rules for the provider
if proctoring_provider is not None and proctoring_provider_rules is None:
value['rules'] = proctoring_backend_settings.get(value['backend'], {}).get('default_rules', {})
proctoring_provider_rules_set = (
proctoring_backend_settings
.get(proctoring_provider, {})
.get('default_rules', {})
)
proctoring_provider_rules = value.get('rules', None)
# add back in any missing rules for the provider
for default_rule, is_enabled in iteritems(proctoring_provider_rules_set):
if default_rule not in proctoring_provider_rules:
proctoring_provider_rules[default_rule] = is_enabled
return value
def _validate_proctoring_backend(self, value, proctoring_backend_settings):
"""
Validate the value for the proctoring backend. If the proctoring backend value is
specified, and it is not one of the backends configured at the platform level, return
a list of error messages to the caller.
"""
errors = []
proctoring_provider_whitelist = [provider for provider in proctoring_backend_settings if provider != 'DEFAULT']
proctoring_provider_whitelist.sort()
proctoring_provider = value.get('backend', None)
if proctoring_provider and proctoring_provider not in proctoring_provider_whitelist:
errors.append(
_('The selected proctoring backend, {proctoring_backend}, is not a valid backend. '
'Please select from one of {available_backends}.')
.format(
proctoring_backend=proctoring_provider,
available_backends=proctoring_provider_whitelist
)
)
return errors
def _validate_proctoring_rules(self, value, proctoring_backend_settings):
"""
Validate the value for the proctoring rules. If the proctoring rules value is
specified, and it is not one of the rules configured for the corresponding backend
at the platform level, or if the value for the rule is not a boolean,
return a list of error messages to the caller.
"""
errors = []
proctoring_provider = value.get('backend', None)
proctoring_provider_rules = value.get('rules', None)
proctoring_provider_rules_set = (
proctoring_backend_settings
.get(proctoring_provider, {})
.get('default_rules', None)
)
if proctoring_provider_rules:
for rule, is_enabled in iteritems(proctoring_provider_rules):
if not isinstance(is_enabled, bool):
errors.append(
_('The value for proctoring configuration rule {rule} '
'should be either true or false.')
.format(rule=rule)
)
if not proctoring_provider_rules_set or rule not in proctoring_provider_rules_set:
errors.append(
_('The proctoring configuration rule {rule} '
'is not a valid rule for provider {provider}.')
.format(
rule=rule,
provider=proctoring_provider
)
)
return errors
@property
def default(self):
"""
Return default value for ProctoringConfiguration.
"""
default = super(ProctoringConfiguration, self).default
proctoring_backend_settings = getattr(settings, 'PROCTORING_BACKENDS', None)
if proctoring_backend_settings:
default_proctoring_provider = proctoring_backend_settings.get('DEFAULT', None)
try:
default_proctoring_rules = proctoring_backend_settings[default_proctoring_provider]['default_rules']
except KeyError:
default_proctoring_rules = {}
return {
'backend': default_proctoring_provider,
'rules': default_proctoring_rules,
}
return default
class CourseFields(object):
lti_passports = List(
display_name=_("LTI Passports"),
@@ -738,6 +883,12 @@ class CourseFields(object):
scope=Scope.settings
)
proctoring_configuration = ProctoringConfiguration(
display_name=_("Proctoring Configuration"),
help=_("Enter a proctoring configuration."),
scope=Scope.settings,
)
allow_proctoring_opt_out = Boolean(
display_name=_("Allow Opting Out of Proctored Exams"),
help=_(

View File

@@ -9,6 +9,8 @@ from fs.memoryfs import MemoryFS
from mock import Mock, patch
from pytz import utc
from xblock.runtime import KvsFieldData, DictKeyValueStore
from django.conf import settings
from django.test import override_settings
import xmodule.course_module
from xmodule.modulestore.xml import ImportSystem, XMLModuleStore
@@ -419,3 +421,282 @@ class CourseDescriptorTestCase(unittest.TestCase):
"""
expected_certificate_available_date = self.course.end + timedelta(days=2)
self.assertEqual(expected_certificate_available_date, self.course.certificate_available_date)
class ProctoringConfigurationTestCase(unittest.TestCase):
"""
Tests for ProctoringConfiguration, including the default value, validation, and inheritance behavior.
"""
shard = 1
def setUp(self):
"""
Initialize dummy testing course.
"""
super(ProctoringConfigurationTestCase, self).setUp()
self.proctoring_configuration = xmodule.course_module.ProctoringConfiguration()
def test_from_json_with_platform_default(self):
"""
Test that a proctoring configuration value equivalent to the platform
default will pass validation.
"""
default_provider = settings.PROCTORING_BACKENDS.get('DEFAULT')
value = {
'backend': default_provider,
'rules': settings.PROCTORING_BACKENDS[default_provider]['default_rules'],
}
# we expect the validated value to be equivalent to the value passed in,
# since there are no validation errors or missing data
self.assertEqual(self.proctoring_configuration.from_json(value), value)
@override_settings(
PROCTORING_BACKENDS={
'DEFAULT': 'mock_proctoring_without_rules',
'mock': {
'default_rules': {
'allow_snarfing': True,
'allow_grok': False
}
},
'mock_proctoring_without_rules': {}
}
)
def test_from_json_with_provider_with_rules(self):
"""
Test that a proctoring provider with rules other than the platform default
passes validation.
"""
provider = 'mock'
value = {
'backend': provider,
'rules': settings.PROCTORING_BACKENDS[provider]['default_rules'],
}
# we expect the validated value to be equivalent to the value passed in,
# since there are no validation errors or missing data
self.assertEqual(self.proctoring_configuration.from_json(value), value)
def test_from_json_with_provider_without_rules(self):
"""
Test that a proctoring provider without rules passes validation.
"""
value = {
'backend': 'mock_proctoring_without_rules',
'rules': {},
}
# we expect the validated value to be equivalent to the value passed in,
# since there are no validation errors or missing data
self.assertEqual(self.proctoring_configuration.from_json(value), value)
def test_from_json_with_invalid_provider(self):
"""
Test that an invalid provider (i.e. not one configured at the platform level)
throws a ValueError with the correct error message.
"""
provider = 'invalid-provider'
proctoring_provider_whitelist = [u'mock', u'mock_proctoring_without_rules']
value = {
'backend': provider,
'rules': {},
}
with self.assertRaises(ValueError) as context_manager:
self.proctoring_configuration.from_json(value)
self.assertEqual(
context_manager.exception.args[0],
['The selected proctoring backend, {}, is not a valid backend. Please select from one of {}.'
.format(provider, proctoring_provider_whitelist)]
)
def test_from_json_with_invalid_rules(self):
"""
Test that an invalid rule (i.e. not one configured at the platform level) for a
valid provider throws a ValueError with the correct error message.
"""
provider = 'mock'
rules = settings.PROCTORING_BACKENDS[provider]['default_rules'].copy()
rules['allow_foo'] = True
value = {
'backend': provider,
'rules': rules,
}
with self.assertRaises(ValueError) as context_manager:
self.proctoring_configuration.from_json(value)
self.assertEqual(
context_manager.exception.args[0],
['The proctoring configuration rule {} is not a valid rule for provider {}.'.
format('allow_foo', provider)]
)
def test_from_json_with_invalid_rule_value(self):
"""
Test that an invalid rule value (i.e. not a boolean) for a valid rule for a
valid provider throws a ValueError with the correct error message.
"""
provider = 'mock'
rules = settings.PROCTORING_BACKENDS[provider]['default_rules'].copy()
rules['allow_grok'] = 'yes'
value = {
'backend': provider,
'rules': rules,
}
with self.assertRaises(ValueError) as context_manager:
self.proctoring_configuration.from_json(value)
self.assertEqual(
context_manager.exception.args[0],
['The value for proctoring configuration rule {} should be either true or false.'.
format('allow_grok')]
)
def test_from_json_adds_platform_default_for_missing_provider(self):
"""
Test that a value with no provider will inherit the default provider
from the platform defaults.
"""
provider = 'mock'
value = {
'rules': {}
}
expected_value = value.copy()
expected_value['backend'] = provider
self.assertEqual(self.proctoring_configuration.from_json(value), expected_value)
def test_from_json_adds_platform_defaults_for_missing_rules(self):
"""
Test that a value with no rules will inherit the default rules for
that provider from the platform defaults.
"""
provider = 'mock'
value = {
'backend': provider
}
expected_value = value.copy()
expected_value['rules'] = settings.PROCTORING_BACKENDS[provider]['default_rules']
self.assertEqual(self.proctoring_configuration.from_json(value), expected_value)
def test_from_json_adds_platform_defaults_for_missing_rules_no_rules_as_empty_dict(self):
"""
Test that a value with no rules will inherit an empty dict for
a provider without rules in the platform defaults.
"""
provider = 'mock_proctoring_without_rules'
value = {
'backend': provider
}
expected_value = value.copy()
expected_value['rules'] = {}
self.assertEqual(self.proctoring_configuration.from_json(value), expected_value)
def test_from_json_adds_platform_defaults_for_missing_provider_and_rules(self):
"""
Test that a value with no rules and no provider will inherit the platform
defaults.
"""
self.assertEqual(self.proctoring_configuration.from_json({}), self.proctoring_configuration.default)
def test_from_json_adds_missing_rules_from_platform_default(self):
"""
Test that a value that is missing rules present in the default will
inherit these rules from the platform default.
"""
provider = 'mock'
rules = settings.PROCTORING_BACKENDS[provider]['default_rules'].copy()
del rules['allow_snarfing']
value = {
'backend': provider,
'rules': rules,
}
expected_value = value.copy()
expected_value['rules'] = settings.PROCTORING_BACKENDS[provider]['default_rules']
self.assertEqual(self.proctoring_configuration.from_json(value), expected_value)
@override_settings(
PROCTORING_BACKENDS={
'mock': {
'default_rules': {
'allow_snarfing': True,
'allow_grok': False
}
},
'mock_proctoring_without_rules': {}
}
)
def test_default_with_no_platform_default(self):
"""
Test that, when the platform defaults are not set, the default is correct.
"""
expected_default = {
'backend': None,
'rules': {}
}
self. assertEqual(self.proctoring_configuration.default, expected_default)
def test_default_with_platform_default_with_rules(self):
"""
Test that, when the platform default provider with rules is specified, the default is correct.
"""
default_provider = settings.PROCTORING_BACKENDS.get('DEFAULT')
default_rules = settings.PROCTORING_BACKENDS[default_provider]['default_rules']
expected_default = {
'backend': default_provider,
'rules': default_rules
}
self.assertEqual(self.proctoring_configuration.default, expected_default)
@override_settings(
PROCTORING_BACKENDS={
'DEFAULT': 'mock_proctoring_without_rules',
'mock': {
'default_rules': {
'allow_snarfing': True,
'allow_grok': False
}
},
'mock_proctoring_without_rules': {}
}
)
def test_default_with_platform_default_without_rules(self):
"""
Test that, when the platform default provider without rules is specified, the default is correct.
"""
default_provider = 'mock_proctoring_without_rules'
default_rules = {}
expected_default = {
'backend': default_provider,
'rules': default_rules
}
self.assertEqual(self.proctoring_configuration.default, expected_default)
@override_settings(PROCTORING_BACKENDS=None)
def test_default_default_with_no_platform_default(self):
"""
Test that, when the platform default is not specified, the default is correct.
"""
default = self.proctoring_configuration.default
self.assertEqual(default, {})