diff --git a/cms/djangoapps/contentstore/config/waffle.py b/cms/djangoapps/contentstore/config/waffle.py index f2770c42d1..c473728d78 100644 --- a/cms/djangoapps/contentstore/config/waffle.py +++ b/cms/djangoapps/contentstore/config/waffle.py @@ -2,7 +2,7 @@ This module contains various configuration settings via waffle switches for the contentstore app. """ -from openedx.core.djangoapps.waffle_utils import WaffleFlagNamespace, WaffleSwitchNamespace +from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag, WaffleFlagNamespace, WaffleSwitchNamespace # Namespace WAFFLE_NAMESPACE = u'studio' @@ -23,3 +23,10 @@ def waffle_flags(): Returns the namespaced, cached, audited Waffle Flag class for Studio pages. """ return WaffleFlagNamespace(name=WAFFLE_NAMESPACE, log_prefix=u'Studio: ') + +# Flags +ENABLE_PROCTORING_PROVIDER_OVERRIDES = CourseWaffleFlag( + waffle_namespace=waffle_flags(), + flag_name=u'enable_proctoring_provider_overrides', + flag_undefined_default=False +) diff --git a/cms/djangoapps/contentstore/proctoring.py b/cms/djangoapps/contentstore/proctoring.py index 70d0cbd46e..a3985f4ac5 100644 --- a/cms/djangoapps/contentstore/proctoring.py +++ b/cms/djangoapps/contentstore/proctoring.py @@ -30,7 +30,6 @@ def register_special_exams(course_key): subsystem. Likewise, if formerly registered exams are unmarked, then those registered exams are marked as inactive """ - if not settings.FEATURES.get('ENABLE_SPECIAL_EXAMS'): # if feature is not enabled then do a quick exit return @@ -72,52 +71,50 @@ def register_special_exams(course_key): ) log.info(msg) + exam_metadata = { + 'exam_name': timed_exam.display_name, + 'time_limit_mins': timed_exam.default_time_limit_minutes, + 'due_date': timed_exam.due, + 'is_proctored': timed_exam.is_proctored_exam, + 'is_practice_exam': timed_exam.is_practice_exam, + 'is_active': True, + 'hide_after_due': timed_exam.hide_after_due, + 'backend': course.proctoring_configuration.get('backend', None), + } + try: exam = get_exam_by_content_id(unicode(course_key), unicode(timed_exam.location)) # update case, make sure everything is synced - exam_id = update_exam( - exam_id=exam['id'], - exam_name=timed_exam.display_name, - time_limit_mins=timed_exam.default_time_limit_minutes, - due_date=timed_exam.due, - is_proctored=timed_exam.is_proctored_exam, - is_practice_exam=timed_exam.is_practice_exam, - is_active=True, - hide_after_due=timed_exam.hide_after_due, - ) + exam_metadata['exam_id'] = exam['id'] + + exam_id = update_exam(**exam_metadata) msg = 'Updated timed exam {exam_id}'.format(exam_id=exam['id']) log.info(msg) except ProctoredExamNotFoundException: - exam_id = create_exam( - course_id=unicode(course_key), - content_id=unicode(timed_exam.location), - exam_name=timed_exam.display_name, - time_limit_mins=timed_exam.default_time_limit_minutes, - due_date=timed_exam.due, - is_proctored=timed_exam.is_proctored_exam, - is_practice_exam=timed_exam.is_practice_exam, - is_active=True, - hide_after_due=timed_exam.hide_after_due, - ) + exam_metadata['course_id'] = unicode(course_key) + exam_metadata['content_id'] = unicode(timed_exam.location) + + exam_id = create_exam(**exam_metadata) msg = 'Created new timed exam {exam_id}'.format(exam_id=exam_id) log.info(msg) + exam_review_policy_metadata = { + 'exam_id': exam_id, + 'set_by_user_id': timed_exam.edited_by, + 'review_policy': timed_exam.exam_review_rules, + 'rules': course.proctoring_configuration.get('rules', None) + } + # only create/update exam policy for the proctored exams if timed_exam.is_proctored_exam and not timed_exam.is_practice_exam: try: - update_review_policy( - exam_id=exam_id, - set_by_user_id=timed_exam.edited_by, - review_policy=timed_exam.exam_review_rules - ) + update_review_policy(**exam_review_policy_metadata) except ProctoredExamReviewPolicyNotFoundException: - if timed_exam.exam_review_rules: # won't save an empty rule. - create_exam_review_policy( - exam_id=exam_id, - set_by_user_id=timed_exam.edited_by, - review_policy=timed_exam.exam_review_rules - ) + review_policy_has_rules = exam_review_policy_metadata.get('rules', None) + + if timed_exam.exam_review_rules or review_policy_has_rules: # won't save an empty rule. + create_exam_review_policy(**exam_review_policy_metadata) msg = 'Created new exam review policy with exam_id {exam_id}'.format(exam_id=exam_id) log.info(msg) else: diff --git a/cms/djangoapps/contentstore/tests/test_proctoring.py b/cms/djangoapps/contentstore/tests/test_proctoring.py index b497874459..492878bc64 100644 --- a/cms/djangoapps/contentstore/tests/test_proctoring.py +++ b/cms/djangoapps/contentstore/tests/test_proctoring.py @@ -10,6 +10,7 @@ from mock import patch from pytz import UTC from contentstore.signals.handlers import listen_for_course_publish +from django.conf import settings from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory @@ -27,11 +28,17 @@ class TestProctoredExams(ModuleStoreTestCase): """ super(TestProctoredExams, self).setUp() + default_proctoring_provider = settings.PROCTORING_BACKENDS['DEFAULT'] + self.course = CourseFactory.create( org='edX', course='900', run='test_run', - enable_proctored_exams=True + enable_proctored_exams=True, + proctoring_configuration={ + 'backend': default_proctoring_provider, + 'rules': settings.PROCTORING_BACKENDS[default_proctoring_provider]['default_rules'], + } ) def _verify_exam_data(self, sequence, expected_active): @@ -49,6 +56,7 @@ class TestProctoredExams(ModuleStoreTestCase): # get the review policy object exam_review_policy = get_review_policy_by_exam_id(exam['id']) self.assertEqual(exam_review_policy['review_policy'], sequence.exam_review_rules) + self.assertEqual(exam_review_policy['rules'], self.course.proctoring_configuration['rules']) if not exam['is_proctored'] and not exam['is_practice_exam']: # the hide after due value only applies to timed exams @@ -61,20 +69,22 @@ class TestProctoredExams(ModuleStoreTestCase): self.assertEqual(exam['is_proctored'], sequence.is_proctored_exam) self.assertEqual(exam['is_practice_exam'], sequence.is_practice_exam) self.assertEqual(exam['is_active'], expected_active) + self.assertEqual(exam['backend'], self.course.proctoring_configuration['backend']) @ddt.data( - (True, 10, True, False, True, False, False), - (True, 10, False, False, True, False, False), - (True, 10, False, False, True, False, True), - (True, 10, True, True, True, True, False), + (True, False, True, False, False), + (False, False, True, False, False), + (False, False, True, False, True), + (True, True, True, True, False), ) @ddt.unpack - def test_publishing_exam(self, is_time_limited, default_time_limit_minutes, is_proctored_exam, + def test_publishing_exam(self, is_proctored_exam, is_practice_exam, expected_active, republish, hide_after_due): """ Happy path testing to see that when a course is published which contains a proctored exam, it will also put an entry into the exam tables """ + default_time_limit_minutes = 10 chapter = ItemFactory.create(parent=self.course, category='chapter', display_name='Test Section') sequence = ItemFactory.create( @@ -82,7 +92,7 @@ class TestProctoredExams(ModuleStoreTestCase): category='sequential', display_name='Test Proctored Exam', graded=True, - is_time_limited=is_time_limited, + is_time_limited=True, default_time_limit_minutes=default_time_limit_minutes, is_proctored_exam=is_proctored_exam, is_practice_exam=is_practice_exam, @@ -104,14 +114,13 @@ class TestProctoredExams(ModuleStoreTestCase): listen_for_course_publish(self, self.course.id) # reverify - self._verify_exam_data(sequence, expected_active) + self._verify_exam_data(sequence, expected_active,) def test_unpublishing_proctored_exam(self): """ Make sure that if we publish and then unpublish a proctored exam, the exam record stays, but is marked as is_active=False """ - chapter = ItemFactory.create(parent=self.course, category='chapter', display_name='Test Section') sequence = ItemFactory.create( parent=chapter, @@ -178,7 +187,6 @@ class TestProctoredExams(ModuleStoreTestCase): """ Make sure the feature flag is honored """ - chapter = ItemFactory.create(parent=self.course, category='chapter', display_name='Test Section') ItemFactory.create( parent=chapter, diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index d9981e5740..9624e33152 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -10,6 +10,7 @@ from xblock_django.models import XBlockStudioConfigurationFlag from xmodule.modulestore.django import modulestore from openedx.features.course_experience import COURSE_ENABLE_UNENROLLED_ACCESS_FLAG +from cms.djangoapps.contentstore.config.waffle import ENABLE_PROCTORING_PROVIDER_OVERRIDES class CourseMetadata(object): @@ -20,9 +21,9 @@ class CourseMetadata(object): editable metadata. ''' # The list of fields that wouldn't be shown in Advanced Settings. - # Should not be used directly. Instead the filtered_list method should + # Should not be used directly. Instead the get_blacklist_of_fields method should # be used if the field needs to be filtered depending on the feature flag. - FILTERED_LIST = [ + FIELDS_BLACK_LIST = [ 'cohort_config', 'xml_attributes', 'start', @@ -65,65 +66,71 @@ class CourseMetadata(object): ] @classmethod - def filtered_list(cls, course_key=None): + def get_blacklist_of_fields(cls, course_key): """ - Filter fields based on feature flag, i.e. enabled, disabled. + Returns a list of fields to not include in Studio Advanced settings based on a + feature flag (i.e. enabled or disabled). """ # Copy the filtered list to avoid permanently changing the class attribute. - filtered_list = list(cls.FILTERED_LIST) + black_list = list(cls.FIELDS_BLACK_LIST) # Do not show giturl if feature is not enabled. if not settings.FEATURES.get('ENABLE_EXPORT_GIT'): - filtered_list.append('giturl') + black_list.append('giturl') # Do not show edxnotes if the feature is disabled. if not settings.FEATURES.get('ENABLE_EDXNOTES'): - filtered_list.append('edxnotes') + black_list.append('edxnotes') # Do not show video auto advance if the feature is disabled if not settings.FEATURES.get('ENABLE_OTHER_COURSE_SETTINGS'): - filtered_list.append('other_course_settings') + black_list.append('other_course_settings') # Do not show video_upload_pipeline if the feature is disabled. if not settings.FEATURES.get('ENABLE_VIDEO_UPLOAD_PIPELINE'): - filtered_list.append('video_upload_pipeline') + black_list.append('video_upload_pipeline') # Do not show video auto advance if the feature is disabled if not settings.FEATURES.get('ENABLE_AUTOADVANCE_VIDEOS'): - filtered_list.append('video_auto_advance') + black_list.append('video_auto_advance') # Do not show social sharing url field if the feature is disabled. if (not hasattr(settings, 'SOCIAL_SHARING_SETTINGS') or not getattr(settings, 'SOCIAL_SHARING_SETTINGS', {}).get("CUSTOM_COURSE_URLS")): - filtered_list.append('social_sharing_url') + black_list.append('social_sharing_url') # Do not show teams configuration if feature is disabled. if not settings.FEATURES.get('ENABLE_TEAMS'): - filtered_list.append('teams_configuration') + black_list.append('teams_configuration') if not settings.FEATURES.get('ENABLE_VIDEO_BUMPER'): - filtered_list.append('video_bumper') + black_list.append('video_bumper') # Do not show enable_ccx if feature is not enabled. if not settings.FEATURES.get('CUSTOM_COURSES_EDX'): - filtered_list.append('enable_ccx') - filtered_list.append('ccx_connector') + black_list.append('enable_ccx') + black_list.append('ccx_connector') # Do not show "Issue Open Badges" in Studio Advanced Settings # if the feature is disabled. if not settings.FEATURES.get('ENABLE_OPENBADGES'): - filtered_list.append('issue_badges') + black_list.append('issue_badges') # If the XBlockStudioConfiguration table is not being used, there is no need to # display the "Allow Unsupported XBlocks" setting. if not XBlockStudioConfigurationFlag.is_enabled(): - filtered_list.append('allow_unsupported_xblocks') + black_list.append('allow_unsupported_xblocks') + + # If the ENABLE_PROCTORING_PROVIDER_OVERRIDES waffle flag is not enabled, + # do not show "Proctoring Configuration" in Studio Advanced Settings. + if not ENABLE_PROCTORING_PROVIDER_OVERRIDES.is_enabled(course_key): + black_list.append('proctoring_configuration') # Do not show "Course Visibility For Unenrolled Learners" in Studio Advanced Settings # if the enable_anonymous_access flag is not enabled if not COURSE_ENABLE_UNENROLLED_ACCESS_FLAG.is_enabled(course_key=course_key): - filtered_list.append('course_visibility') - return filtered_list + black_list.append('course_visibility') + return black_list @classmethod def fetch(cls, descriptor): @@ -133,8 +140,10 @@ class CourseMetadata(object): """ result = {} metadata = cls.fetch_all(descriptor) + black_list_of_fields = cls.get_blacklist_of_fields(descriptor.id) + for key, value in metadata.iteritems(): - if key in cls.filtered_list(descriptor.id): + if key in black_list_of_fields: continue result[key] = value return result @@ -169,17 +178,17 @@ class CourseMetadata(object): Ensures none of the fields are in the blacklist. """ - filtered_list = cls.filtered_list(descriptor.id) + blacklist_of_fields = cls.get_blacklist_of_fields(descriptor.id) # Don't filter on the tab attribute if filter_tabs is False. if not filter_tabs: - filtered_list.remove("tabs") + blacklist_of_fields.remove("tabs") # Validate the values before actually setting them. key_values = {} for key, model in jsondict.iteritems(): # should it be an error if one of the filtered list items is in the payload? - if key in filtered_list: + if key in blacklist_of_fields: continue try: val = model['value'] @@ -205,11 +214,12 @@ class CourseMetadata(object): errors: list of error objects result: the updated course metadata or None if error """ - filtered_list = cls.filtered_list(descriptor.id) - if not filter_tabs: - filtered_list.remove("tabs") + blacklist_of_fields = cls.get_blacklist_of_fields(descriptor.id) - filtered_dict = dict((k, v) for k, v in jsondict.iteritems() if k not in filtered_list) + if not filter_tabs: + blacklist_of_fields.remove("tabs") + + filtered_dict = dict((k, v) for k, v in jsondict.iteritems() if k not in blacklist_of_fields) did_validate = True errors = [] key_values = {} @@ -238,7 +248,7 @@ class CourseMetadata(object): for key, value in key_values.iteritems(): setattr(descriptor, key, value) - if save and len(key_values): + if save and key_values: modulestore().update_item(descriptor, user.id) return cls.fetch(descriptor) diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 14dae321bb..630befbb20 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -492,7 +492,6 @@ XBLOCK_SETTINGS.setdefault("VideoModule", {})['YOUTUBE_API_KEY'] = AUTH_TOKENS.g ################# PROCTORING CONFIGURATION ################## -PROCTORING_BACKEND_PROVIDER = AUTH_TOKENS.get("PROCTORING_BACKEND_PROVIDER", PROCTORING_BACKEND_PROVIDER) PROCTORING_SETTINGS = ENV_TOKENS.get("PROCTORING_SETTINGS", PROCTORING_SETTINGS) ################# MICROSITE #################### diff --git a/cms/envs/common.py b/cms/envs/common.py index bf1baa22a2..9bc7f1ffcd 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1446,11 +1446,12 @@ MICROSITE_TEMPLATE_BACKEND = 'microsite_configuration.backends.filebased.Filebas MICROSITE_DATABASE_TEMPLATE_CACHE_TTL = 5 * 60 ############################### PROCTORING CONFIGURATION DEFAULTS ############## -PROCTORING_BACKEND_PROVIDER = { - 'class': 'edx_proctoring.backends.null.NullBackendProvider', - 'options': {}, -} PROCTORING_SETTINGS = {} +PROCTORING_BACKENDS = { + 'DEFAULT': 'null', + 'null': {}, +} + ############################ Global Database Configuration ##################### diff --git a/cms/envs/production.py b/cms/envs/production.py index 2fc776a3b7..0dc26e589c 100644 --- a/cms/envs/production.py +++ b/cms/envs/production.py @@ -495,8 +495,8 @@ XBLOCK_SETTINGS.setdefault("VideoModule", {})['YOUTUBE_API_KEY'] = AUTH_TOKENS.g ################# PROCTORING CONFIGURATION ################## -PROCTORING_BACKEND_PROVIDER = AUTH_TOKENS.get("PROCTORING_BACKEND_PROVIDER", PROCTORING_BACKEND_PROVIDER) PROCTORING_SETTINGS = ENV_TOKENS.get("PROCTORING_SETTINGS", PROCTORING_SETTINGS) +PROCTORING_BACKENDS = ENV_TOKENS.get("PROCTORING_BACKENDS", PROCTORING_BACKENDS) ################# MICROSITE #################### # microsite specific configurations. diff --git a/cms/envs/test.py b/cms/envs/test.py index 849130059c..6d90b65d56 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -36,6 +36,7 @@ from lms.envs.test import ( JWT_AUTH, REGISTRATION_EXTRA_FIELDS, ECOMMERCE_API_URL, + PROCTORING_BACKENDS, ) diff --git a/cms/envs/test_static_optimized.py b/cms/envs/test_static_optimized.py index 2874ee494d..64a94efa31 100644 --- a/cms/envs/test_static_optimized.py +++ b/cms/envs/test_static_optimized.py @@ -23,6 +23,7 @@ DATABASES = { } + ######################### PIPELINE #################################### # Use RequireJS optimized storage diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 1471489977..5f709c5946 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -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=_( diff --git a/common/lib/xmodule/xmodule/tests/test_course_module.py b/common/lib/xmodule/xmodule/tests/test_course_module.py index 16f53729d9..dbf0a4d2e9 100644 --- a/common/lib/xmodule/xmodule/tests/test_course_module.py +++ b/common/lib/xmodule/xmodule/tests/test_course_module.py @@ -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, {}) diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 439597f85e..0be963faa9 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -894,7 +894,6 @@ JWT_AUTH.update(AUTH_TOKENS.get('JWT_AUTH', {})) ################# PROCTORING CONFIGURATION ################## -PROCTORING_BACKEND_PROVIDER = AUTH_TOKENS.get("PROCTORING_BACKEND_PROVIDER", PROCTORING_BACKEND_PROVIDER) PROCTORING_SETTINGS = ENV_TOKENS.get("PROCTORING_SETTINGS", PROCTORING_SETTINGS) ################# MICROSITE #################### diff --git a/lms/envs/common.py b/lms/envs/common.py index b6d939af3e..e5fa962a13 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3217,11 +3217,11 @@ RSS_PROXY_CACHE_TIMEOUT = 3600 # The length of time we cache RSS retrieved from #### PROCTORING CONFIGURATION DEFAULTS -PROCTORING_BACKEND_PROVIDER = { - 'class': 'edx_proctoring.backends.null.NullBackendProvider', - 'options': {}, -} PROCTORING_SETTINGS = {} +PROCTORING_BACKENDS = { + 'DEFAULT': 'null', + 'null': {}, +} #### Custom Courses for EDX (CCX) configuration diff --git a/lms/envs/production.py b/lms/envs/production.py index a98925a535..b25c5d94bd 100644 --- a/lms/envs/production.py +++ b/lms/envs/production.py @@ -890,8 +890,8 @@ JWT_AUTH.update(AUTH_TOKENS.get('JWT_AUTH', {})) ################# PROCTORING CONFIGURATION ################## -PROCTORING_BACKEND_PROVIDER = AUTH_TOKENS.get("PROCTORING_BACKEND_PROVIDER", PROCTORING_BACKEND_PROVIDER) PROCTORING_SETTINGS = ENV_TOKENS.get("PROCTORING_SETTINGS", PROCTORING_SETTINGS) +PROCTORING_BACKENDS = ENV_TOKENS.get("PROCTORING_BACKENDS", PROCTORING_BACKENDS) ################# MICROSITE #################### MICROSITE_CONFIGURATION = ENV_TOKENS.get('MICROSITE_CONFIGURATION', {}) diff --git a/lms/envs/test.py b/lms/envs/test.py index d5f094f6eb..95019b3c68 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -124,6 +124,17 @@ XQUEUE_WAITTIME_BETWEEN_REQUESTS = 5 # seconds MOCK_STAFF_GRADING = True MOCK_PEER_GRADING = True +PROCTORING_BACKENDS = { + 'DEFAULT': 'mock', + 'mock': { + 'default_rules': { + 'allow_snarfing': True, + 'allow_grok': False + } + }, + 'mock_proctoring_without_rules': {} +} + ############################ STATIC FILES ############################# # TODO (cpennington): We need to figure out how envs/test.py can inject things diff --git a/lms/envs/test_static_optimized.py b/lms/envs/test_static_optimized.py index 25a44eb9e4..4cc54b34bf 100644 --- a/lms/envs/test_static_optimized.py +++ b/lms/envs/test_static_optimized.py @@ -35,6 +35,16 @@ XQUEUE_INTERFACE = { "basic_auth": ('anant', 'agarwal'), } +PROCTORING_BACKENDS = { + 'DEFAULT': 'mock', + 'mock': { + 'default_rules': { + 'allow_snarfing': True, + 'allow_grok': False, + } + }, + 'mock_proctoring_without_rules': {}, +} ######################### PIPELINE #################################### diff --git a/openedx/tests/settings.py b/openedx/tests/settings.py index 22da32f525..a1e8306eae 100644 --- a/openedx/tests/settings.py +++ b/openedx/tests/settings.py @@ -51,6 +51,17 @@ DATABASES = { } } +PROCTORING_BACKENDS = { + 'DEFAULT': 'mock', + 'mock': { + 'default_rules': { + 'allow_snarfing': True, + 'allow_grok': False, + } + }, + 'mock_proctoring_without_rules': {}, +} + FEATURES = {} INSTALLED_APPS = ( diff --git a/requirements/edx/base.in b/requirements/edx/base.in index e36a17747f..96b2b9fdfc 100644 --- a/requirements/edx/base.in +++ b/requirements/edx/base.in @@ -80,7 +80,7 @@ edx-enterprise edx-milestones edx-oauth2-provider edx-organizations -edx-proctoring +edx-proctoring==1.5.0b1 edx-rest-api-client edx-search edx-submissions @@ -133,7 +133,7 @@ rfc6266-parser # Used to generate Content-Disposition heade social-auth-app-django<3.0.0 social-auth-core<2.0.0 pysrt==0.4.7 # Support for SubRip subtitle files, used in the video XModule -pytz==2016.10 # Time zone information database +pytz # Time zone information database PyYAML # Used to parse XModule resource templates redis==2.10.6 # celery task broker requests-oauthlib # Simplifies use of OAuth via the requests library, used for CCX and LTI diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 0429405f91..6204a2bca1 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -124,7 +124,11 @@ edx-milestones==0.1.13 edx-oauth2-provider==1.2.2 edx-opaque-keys[django]==0.4.4 edx-organizations==1.0.0 +<<<<<<< HEAD edx-proctoring==1.4.0 +======= +edx-proctoring==1.5.0b1 +>>>>>>> Enable course run level overrides for proctoring configuration. edx-rest-api-client==1.9.2 edx-search==1.2.1 edx-submissions==2.0.12 @@ -145,7 +149,11 @@ hash-ring==1.3.1 # via django-memcached-hashring help-tokens==1.0.3 html5lib==1.0.1 httplib2==0.12.0 # via oauth2, zendesk +<<<<<<< HEAD idna==2.8 +======= +idna==2.7 +>>>>>>> Enable course run level overrides for proctoring configuration. ipaddr==2.1.11 ipaddress==1.0.22 isodate==0.6.0 # via python-saml @@ -169,7 +177,11 @@ mock==1.0.1 mongoengine==0.10.0 mysql-python==1.2.5 networkx==1.7 +<<<<<<< HEAD newrelic==4.8.0.110 +======= +newrelic==4.6.0.106 +>>>>>>> Enable course run level overrides for proctoring configuration. nltk==3.4 nodeenv==1.1.1 numpy==1.6.2 @@ -204,13 +216,19 @@ python-memcached==1.48 python-openid==2.2.5 python-saml==2.4.0 python-swiftclient==3.6.0 -pytz==2016.10 +pytz==2018.7 pyuca==1.1 pyyaml==3.13 redis==2.10.6 +<<<<<<< HEAD reportlab==3.5.12 requests-oauthlib==1.0.0 requests==2.21.0 +======= +reportlab==3.5.11 +requests-oauthlib==1.0.0 +requests==2.20.1 +>>>>>>> Enable course run level overrides for proctoring configuration. rest-condition==1.0.3 rfc6266-parser==0.0.5.post2 rules==2.0.1 diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index e49d10fca4..01dcb595be 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -144,7 +144,11 @@ edx-milestones==0.1.13 edx-oauth2-provider==1.2.2 edx-opaque-keys[django]==0.4.4 edx-organizations==1.0.0 +<<<<<<< HEAD edx-proctoring==1.4.0 +======= +edx-proctoring==1.5.0b1 +>>>>>>> Enable course run level overrides for proctoring configuration. edx-rest-api-client==1.9.2 edx-search==1.2.1 edx-sphinx-theme==1.4.0 @@ -217,7 +221,11 @@ more-itertools==4.3.0 moto==0.3.1 mysql-python==1.2.5 networkx==1.7 +<<<<<<< HEAD newrelic==4.8.0.110 +======= +newrelic==4.6.0.106 +>>>>>>> Enable course run level overrides for proctoring configuration. nltk==3.4 nodeenv==1.1.1 numpy==1.6.2 @@ -281,15 +289,21 @@ python-saml==2.4.0 python-slugify==1.2.6 python-subunit==1.3.0 python-swiftclient==3.6.0 -pytz==2016.10 +pytz==2018.7 pyuca==1.1 pyyaml==3.13 queuelib==1.5.0 radon==2.4.0 redis==2.10.6 +<<<<<<< HEAD reportlab==3.5.12 requests-oauthlib==1.0.0 requests==2.21.0 +======= +reportlab==3.5.11 +requests-oauthlib==1.0.0 +requests==2.20.1 +>>>>>>> Enable course run level overrides for proctoring configuration. rest-condition==1.0.3 rfc6266-parser==0.0.5.post2 rules==2.0.1 diff --git a/requirements/edx/paver.txt b/requirements/edx/paver.txt index f0d2c0689c..6bb5a96311 100644 --- a/requirements/edx/paver.txt +++ b/requirements/edx/paver.txt @@ -22,7 +22,11 @@ psutil==1.2.1 pymongo==2.9.1 python-memcached==1.48 pyyaml==3.13 # via watchdog +<<<<<<< HEAD requests==2.21.0 +======= +requests==2.20.1 +>>>>>>> Enable course run level overrides for proctoring configuration. six==1.11.0 # via edx-opaque-keys, libsass, paver, stevedore stevedore==1.10.0 urllib3==1.23 # via requests diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 71eee7e828..785927226a 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -139,7 +139,11 @@ edx-milestones==0.1.13 edx-oauth2-provider==1.2.2 edx-opaque-keys[django]==0.4.4 edx-organizations==1.0.0 +<<<<<<< HEAD edx-proctoring==1.4.0 +======= +edx-proctoring==1.5.0b1 +>>>>>>> Enable course run level overrides for proctoring configuration. edx-rest-api-client==1.9.2 edx-search==1.2.1 edx-submissions==2.0.12 @@ -209,7 +213,11 @@ more-itertools==4.3.0 # via pytest moto==0.3.1 mysql-python==1.2.5 networkx==1.7 +<<<<<<< HEAD newrelic==4.8.0.110 +======= +newrelic==4.6.0.106 +>>>>>>> Enable course run level overrides for proctoring configuration. nltk==3.4 nodeenv==1.1.1 numpy==1.6.2 @@ -270,15 +278,21 @@ python-saml==2.4.0 python-slugify==1.2.6 # via transifex-client python-subunit==1.3.0 python-swiftclient==3.6.0 -pytz==2016.10 +pytz==2018.7 pyuca==1.1 pyyaml==3.13 queuelib==1.5.0 # via scrapy radon==2.4.0 redis==2.10.6 +<<<<<<< HEAD reportlab==3.5.12 requests-oauthlib==1.0.0 requests==2.21.0 +======= +reportlab==3.5.11 +requests-oauthlib==1.0.0 +requests==2.20.1 +>>>>>>> Enable course run level overrides for proctoring configuration. rest-condition==1.0.3 rfc6266-parser==0.0.5.post2 rules==2.0.1