Enable course run level overrides for proctoring configuration.
This commit is contained in:
committed by
Dave St.Germain
parent
77922ae073
commit
ecabcf90dd
@@ -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=_(
|
||||
|
||||
@@ -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, {})
|
||||
|
||||
Reference in New Issue
Block a user