From 374811996102d382a490469f1ba3e706a462692a Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Fri, 17 Jul 2015 12:06:18 -0400 Subject: [PATCH] Integrate timed and proctored exam authoring into Studio --- cms/djangoapps/contentstore/proctoring.py | 116 ++++++++++ cms/djangoapps/contentstore/signals.py | 30 ++- .../contentstore/tests/test_proctoring.py | 206 ++++++++++++++++++ cms/djangoapps/contentstore/views/helpers.py | 14 ++ cms/djangoapps/contentstore/views/item.py | 16 +- .../views/tests/test_credit_eligibility.py | 4 +- .../contentstore/views/tests/test_item.py | 32 +++ .../models/settings/course_metadata.py | 4 + cms/envs/aws.py | 6 + cms/envs/common.py | 15 ++ .../spec/views/pages/course_outline_spec.js | 47 +++- cms/static/js/views/course_outline.js | 8 + .../js/views/modals/course_outline_modals.js | 101 ++++++++- cms/static/sass/elements/_modal-window.scss | 46 +++- cms/static/sass/elements/_modules.scss | 12 +- cms/templates/course_outline.html | 2 +- cms/templates/js/course-outline.underscore | 13 +- ...d-examination-preference-editor.underscore | 39 ++++ common/lib/xmodule/xmodule/course_module.py | 9 + common/lib/xmodule/xmodule/seq_module.py | 49 ++++- .../pages/studio/settings_advanced.py | 1 + lms/envs/common.py | 3 + openedx/core/djangoapps/credit/signals.py | 11 +- openedx/core/djangoapps/credit/tasks.py | 49 ++++- .../djangoapps/credit/tests/test_tasks.py | 99 ++++++++- requirements/edx/github.txt | 3 + 26 files changed, 896 insertions(+), 39 deletions(-) create mode 100644 cms/djangoapps/contentstore/proctoring.py create mode 100644 cms/djangoapps/contentstore/tests/test_proctoring.py create mode 100644 cms/templates/js/timed-examination-preference-editor.underscore diff --git a/cms/djangoapps/contentstore/proctoring.py b/cms/djangoapps/contentstore/proctoring.py new file mode 100644 index 0000000000..06be401a28 --- /dev/null +++ b/cms/djangoapps/contentstore/proctoring.py @@ -0,0 +1,116 @@ +""" +Code related to the handling of Proctored Exams in Studio +""" + +import logging + +from django.conf import settings + +from xmodule.modulestore.django import modulestore + +from contentstore.views.helpers import is_item_in_course_tree + +from edx_proctoring.api import ( + get_exam_by_content_id, + update_exam, + create_exam, + get_all_exams_for_course, +) +from edx_proctoring.exceptions import ( + ProctoredExamNotFoundException +) + +log = logging.getLogger(__name__) + + +def register_proctored_exams(course_key): + """ + This is typically called on a course published signal. The course is examined for sequences + that are marked as timed exams. Then these are registered with the edx-proctoring + subsystem. Likewise, if formerly registered exams are unmarked, then those + registred exams are marked as inactive + """ + + if not settings.FEATURES.get('ENABLE_PROCTORED_EXAMS'): + # if feature is not enabled then do a quick exit + return + + course = modulestore().get_course(course_key) + if not course.enable_proctored_exams: + # likewise if course does not have this feature turned on + return + + # get all sequences, since they can be marked as timed/proctored exams + _timed_exams = modulestore().get_items( + course_key, + qualifiers={ + 'category': 'sequential', + }, + settings={ + 'is_time_limited': True, + } + ) + + # filter out any potential dangling sequences + timed_exams = [ + timed_exam + for timed_exam in _timed_exams + if is_item_in_course_tree(timed_exam) + ] + + # enumerate over list of sequences which are time-limited and + # add/update any exam entries in edx-proctoring + for timed_exam in timed_exams: + msg = ( + 'Found {location} as a timed-exam in course structure. Inspecting...'.format( + location=unicode(timed_exam.location) + ) + ) + log.info(msg) + + try: + exam = get_exam_by_content_id(unicode(course_key), unicode(timed_exam.location)) + # update case, make sure everything is synced + update_exam( + exam_id=exam['id'], + exam_name=timed_exam.display_name, + time_limit_mins=timed_exam.default_time_limit_minutes, + is_proctored=timed_exam.is_proctored_enabled, + is_active=True + ) + 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, + is_proctored=timed_exam.is_proctored_enabled, + is_active=True + ) + msg = 'Created new timed exam {exam_id}'.format(exam_id=exam_id) + log.info(msg) + + # then see which exams we have in edx-proctoring that are not in + # our current list. That means the the user has disabled it + exams = get_all_exams_for_course(course_key) + + for exam in exams: + if exam['is_active']: + # try to look up the content_id in the sequences location + + search = [ + timed_exam for timed_exam in timed_exams if + unicode(timed_exam.location) == exam['content_id'] + ] + if not search: + # This means it was turned off in Studio, we need to mark + # the exam as inactive (we don't delete!) + msg = 'Disabling timed exam {exam_id}'.format(exam_id=exam['id']) + log.info(msg) + update_exam( + exam_id=exam['id'], + is_proctored=False, + is_active=False, + ) diff --git a/cms/djangoapps/contentstore/signals.py b/cms/djangoapps/contentstore/signals.py index 8ada95bce1..127de92790 100644 --- a/cms/djangoapps/contentstore/signals.py +++ b/cms/djangoapps/contentstore/signals.py @@ -1,4 +1,5 @@ """ receivers of course_published and library_updated events in order to trigger indexing task """ + from datetime import datetime from pytz import UTC @@ -6,16 +7,33 @@ from django.dispatch import receiver from xmodule.modulestore.django import SignalHandler from contentstore.courseware_index import CoursewareSearchIndexer, LibrarySearchIndexer +from contentstore.proctoring import register_proctored_exams +from openedx.core.djangoapps.credit.signals import on_course_publish @receiver(SignalHandler.course_published) def listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=unused-argument """ - Receives signal and kicks off celery task to update search index + Receives publishing signal and performs publishing related workflows, such as + registering proctored exams, building up credit requirements, and performing + search indexing """ - # import here, because signal is registered at startup, but items in tasks are not yet able to be loaded - from .tasks import update_search_index + + # first is to registered exams, the credit subsystem will assume that + # all proctored exams have already been registered, so we have to do that first + register_proctored_exams(course_key) + + # then call into the credit subsystem (in /openedx/djangoapps/credit) + # to perform any 'on_publish' workflow + on_course_publish(course_key) + + # Finally call into the course search subsystem + # to kick off an indexing action + if CoursewareSearchIndexer.indexing_is_enabled(): + # import here, because signal is registered at startup, but items in tasks are not yet able to be loaded + from .tasks import update_search_index + update_search_index.delay(unicode(course_key), datetime.now(UTC).isoformat()) @@ -24,7 +42,9 @@ def listen_for_library_update(sender, library_key, **kwargs): # pylint: disable """ Receives signal and kicks off celery task to update search index """ - # import here, because signal is registered at startup, but items in tasks are not yet able to be loaded - from .tasks import update_library_index + if LibrarySearchIndexer.indexing_is_enabled(): + # import here, because signal is registered at startup, but items in tasks are not yet able to be loaded + from .tasks import update_library_index + update_library_index.delay(unicode(library_key), datetime.now(UTC).isoformat()) diff --git a/cms/djangoapps/contentstore/tests/test_proctoring.py b/cms/djangoapps/contentstore/tests/test_proctoring.py new file mode 100644 index 0000000000..18fb2e53e3 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_proctoring.py @@ -0,0 +1,206 @@ +""" +Tests for the edx_proctoring integration into Studio +""" + +from mock import patch +import ddt +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + +from contentstore.signals import listen_for_course_publish + +from edx_proctoring.api import get_all_exams_for_course + + +@ddt.ddt +@patch.dict('django.conf.settings.FEATURES', {'ENABLE_PROCTORED_EXAMS': True}) +class TestProctoredExams(ModuleStoreTestCase): + """ + Tests for the publishing of proctored exams + """ + + def setUp(self): + """ + Initial data setup + """ + super(TestProctoredExams, self).setUp() + + self.course = CourseFactory.create( + org='edX', + course='900', + run='test_run', + enable_proctored_exams=True + ) + + def _verify_exam_data(self, sequence, expected_active): + """ + Helper method to compare the sequence with the stored exam, + which should just be a single one + """ + exams = get_all_exams_for_course(unicode(self.course.id)) + + self.assertEqual(len(exams), 1) + + exam = exams[0] + self.assertEqual(exam['course_id'], unicode(self.course.id)) + self.assertEqual(exam['content_id'], unicode(sequence.location)) + self.assertEqual(exam['exam_name'], sequence.display_name) + self.assertEqual(exam['time_limit_mins'], sequence.default_time_limit_minutes) + self.assertEqual(exam['is_proctored'], sequence.is_proctored_enabled) + self.assertEqual(exam['is_active'], expected_active) + + @ddt.data( + (True, 10, True, True, False), + (True, 10, False, True, False), + (True, 10, True, True, True), + ) + @ddt.unpack + def test_publishing_exam(self, is_time_limited, default_time_limit_minutes, + is_procted_enabled, expected_active, republish): + """ + 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 + """ + + chapter = ItemFactory.create(parent=self.course, category='chapter', display_name='Test Section') + sequence = ItemFactory.create( + parent=chapter, + category='sequential', + display_name='Test Proctored Exam', + graded=True, + is_time_limited=is_time_limited, + default_time_limit_minutes=default_time_limit_minutes, + is_proctored_enabled=is_procted_enabled + ) + + listen_for_course_publish(self, self.course.id) + + self._verify_exam_data(sequence, expected_active) + + if republish: + # update the sequence + sequence.default_time_limit_minutes += sequence.default_time_limit_minutes + self.store.update_item(sequence, self.user.id) + + # simulate a publish + listen_for_course_publish(self, self.course.id) + + # reverify + 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, + category='sequential', + display_name='Test Proctored Exam', + graded=True, + is_time_limited=True, + default_time_limit_minutes=10, + is_proctored_enabled=True + ) + + listen_for_course_publish(self, self.course.id) + + exams = get_all_exams_for_course(unicode(self.course.id)) + self.assertEqual(len(exams), 1) + + sequence.is_time_limited = False + sequence.is_proctored_enabled = False + + self.store.update_item(sequence, self.user.id) + + listen_for_course_publish(self, self.course.id) + + self._verify_exam_data(sequence, False) + + def test_dangling_exam(self): + """ + Make sure we filter out all dangling items + """ + + chapter = ItemFactory.create(parent=self.course, category='chapter', display_name='Test Section') + ItemFactory.create( + parent=chapter, + category='sequential', + display_name='Test Proctored Exam', + graded=True, + is_time_limited=True, + default_time_limit_minutes=10, + is_proctored_enabled=True + ) + + listen_for_course_publish(self, self.course.id) + + exams = get_all_exams_for_course(unicode(self.course.id)) + self.assertEqual(len(exams), 1) + + self.store.delete_item(chapter.location, self.user.id) + + # republish course + listen_for_course_publish(self, self.course.id) + + # look through exam table, the dangling exam + # should be disabled + exams = get_all_exams_for_course(unicode(self.course.id)) + self.assertEqual(len(exams), 1) + + exam = exams[0] + self.assertEqual(exam['is_active'], False) + + @patch.dict('django.conf.settings.FEATURES', {'ENABLE_PROCTORED_EXAMS': False}) + def test_feature_flag_off(self): + """ + Make sure the feature flag is honored + """ + + chapter = ItemFactory.create(parent=self.course, category='chapter', display_name='Test Section') + ItemFactory.create( + parent=chapter, + category='sequential', + display_name='Test Proctored Exam', + graded=True, + is_time_limited=True, + default_time_limit_minutes=10, + is_proctored_enabled=True + ) + + listen_for_course_publish(self, self.course.id) + + exams = get_all_exams_for_course(unicode(self.course.id)) + self.assertEqual(len(exams), 0) + + def test_advanced_setting_off(self): + """ + Make sure the feature flag is honored + """ + + self.course = CourseFactory.create( + org='edX', + course='901', + run='test_run2', + enable_proctored_exams=False + ) + + chapter = ItemFactory.create(parent=self.course, category='chapter', display_name='Test Section') + ItemFactory.create( + parent=chapter, + category='sequential', + display_name='Test Proctored Exam', + graded=True, + is_time_limited=True, + default_time_limit_minutes=10, + is_proctored_enabled=True + ) + + listen_for_course_publish(self, self.course.id) + + # there shouldn't be any exams because we haven't enabled that + # advanced setting flag + exams = get_all_exams_for_course(unicode(self.course.id)) + self.assertEqual(len(exams), 0) diff --git a/cms/djangoapps/contentstore/views/helpers.py b/cms/djangoapps/contentstore/views/helpers.py index 6d5cdf9ba3..59dc39cc30 100644 --- a/cms/djangoapps/contentstore/views/helpers.py +++ b/cms/djangoapps/contentstore/views/helpers.py @@ -299,3 +299,17 @@ def create_xblock(parent_locator, user, category, display_name, boilerplate=None store.update_item(course, user.id) return created_block + + +def is_item_in_course_tree(item): + """ + Check that the item is in the course tree. + + It's possible that the item is not in the course tree + if its parent has been deleted and is now an orphan. + """ + ancestor = item.get_parent() + while ancestor is not None and ancestor.location.category != "course": + ancestor = ancestor.get_parent() + + return ancestor is not None diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 414d61908b..7a8f0071a5 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -810,6 +810,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F # defining the default value 'True' for delete, drag and add new child actions in xblock_actions for each xblock. xblock_actions = {'deletable': True, 'draggable': True, 'childAddable': True} explanatory_message = None + # is_entrance_exam is inherited metadata. if xblock.category == 'chapter' and getattr(xblock, "is_entrance_exam", None): # Entrance exam section should not be deletable, draggable and not have 'New Subsection' button. @@ -846,9 +847,22 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F "course_graders": json.dumps([grader.get('type') for grader in graders]), "has_changes": has_changes, "actions": xblock_actions, - "explanatory_message": explanatory_message + "explanatory_message": explanatory_message, } + # update xblock_info with proctored_exam information if the feature flag is enabled + if settings.FEATURES.get('ENABLE_PROCTORED_EXAMS'): + if xblock.category == 'course': + xblock_info.update({ + "enable_proctored_exams": xblock.enable_proctored_exams + }) + elif xblock.category == 'sequential': + xblock_info.update({ + "is_proctored_enabled": xblock.is_proctored_enabled, + "is_time_limited": xblock.is_time_limited, + "default_time_limit_minutes": xblock.default_time_limit_minutes + }) + # Entrance exam subsection should be hidden. in_entrance_exam is inherited metadata, all children will have it. if xblock.category == 'sequential' and getattr(xblock, "in_entrance_exam", False): xblock_info["is_header_visible"] = False diff --git a/cms/djangoapps/contentstore/views/tests/test_credit_eligibility.py b/cms/djangoapps/contentstore/views/tests/test_credit_eligibility.py index 6ce0cfce51..a93e7d8641 100644 --- a/cms/djangoapps/contentstore/views/tests/test_credit_eligibility.py +++ b/cms/djangoapps/contentstore/views/tests/test_credit_eligibility.py @@ -10,7 +10,7 @@ from xmodule.modulestore.tests.factories import CourseFactory from openedx.core.djangoapps.credit.api import get_credit_requirements from openedx.core.djangoapps.credit.models import CreditCourse -from openedx.core.djangoapps.credit.signals import listen_for_course_publish +from openedx.core.djangoapps.credit.signals import on_course_publish class CreditEligibilityTest(CourseTestCase): @@ -50,7 +50,7 @@ class CreditEligibilityTest(CourseTestCase): credit_course.save() self.assertEqual(len(get_credit_requirements(self.course.id)), 0) # test that after publishing course, minimum grade requirement is added - listen_for_course_publish(self, self.course.id) + on_course_publish(self.course.id) self.assertEqual(len(get_credit_requirements(self.course.id)), 1) response = self.client.get_html(self.course_details_url) diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py index 4261042711..d233847a3a 100644 --- a/cms/djangoapps/contentstore/views/tests/test_item.py +++ b/cms/djangoapps/contentstore/views/tests/test_item.py @@ -1664,6 +1664,38 @@ class TestXBlockInfo(ItemTest): else: self.assertIsNone(xblock_info.get('child_info', None)) + @patch.dict('django.conf.settings.FEATURES', {'ENABLE_PROCTORED_EXAMS': True}) + def test_proctored_exam_xblock_info(self): + self.course.enable_proctored_exams = True + self.course.save() + self.store.update_item(self.course, self.user.id) + + course = modulestore().get_item(self.course.location) + xblock_info = create_xblock_info( + course, + include_child_info=True, + include_children_predicate=ALWAYS, + ) + # exam proctoring should be enabled and time limited. + self.assertEqual(xblock_info['enable_proctored_exams'], True) + + sequential = ItemFactory.create( + parent_location=self.chapter.location, category='sequential', + display_name="Test Lesson 1", user_id=self.user.id, + is_proctored_enabled=True, is_time_limited=True, + default_time_limit_minutes=100 + ) + sequential = modulestore().get_item(sequential.location) + xblock_info = create_xblock_info( + sequential, + include_child_info=True, + include_children_predicate=ALWAYS, + ) + # exam proctoring should be enabled and time limited. + self.assertEqual(xblock_info['is_proctored_enabled'], True) + self.assertEqual(xblock_info['is_time_limited'], True) + self.assertEqual(xblock_info['default_time_limit_minutes'], 100) + class TestLibraryXBlockInfo(ModuleStoreTestCase): """ diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index 54ccf989ac..7eb5713dae 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -46,6 +46,10 @@ class CourseMetadata(object): 'language', 'certificates', 'minimum_grade_credit', + 'default_time_limit_minutes', + 'is_proctored_enabled', + 'is_time_limited', + 'is_practice_exam', ] @classmethod diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 85396530fe..db401adcfd 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -348,3 +348,9 @@ if FEATURES['ENABLE_COURSEWARE_INDEX'] or FEATURES['ENABLE_LIBRARY_INDEX']: XBLOCK_SETTINGS = ENV_TOKENS.get('XBLOCK_SETTINGS', {}) XBLOCK_SETTINGS.setdefault("VideoDescriptor", {})["licensing_enabled"] = FEATURES.get("LICENSING", False) + + +################# PROCTORING CONFIGURATION ################## + +PROCTORING_BACKEND_PROVIDER = AUTH_TOKENS.get("PROCTORING_BACKEND_PROVIDER", PROCTORING_BACKEND_PROVIDER) +PROCTORING_SETTINGS = ENV_TOKENS.get("PROCTORING_SETTINGS", PROCTORING_SETTINGS) diff --git a/cms/envs/common.py b/cms/envs/common.py index a683dce912..d0e3903463 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -181,6 +181,9 @@ FEATURES = { # Can the visibility of the discussion tab be configured on a per-course basis? 'ALLOW_HIDING_DISCUSSION_TAB': False, + + # Timed or Proctored Exams + 'ENABLE_PROCTORED_EXAMS': False, } ENABLE_JASMINE = False @@ -869,6 +872,9 @@ OPTIONAL_APPS = ( # milestones 'milestones', + + # edX Proctoring + 'edx_proctoring', ) @@ -1016,3 +1022,12 @@ CREDIT_PROVIDER_TIMESTAMP_EXPIRATION = 15 * 60 ################################ Deprecated Blocks Info ################################ DEPRECATED_BLOCK_TYPES = ['peergrading', 'combinedopenended'] + + +#### PROCTORING CONFIGURATION DEFAULTS + +PROCTORING_BACKEND_PROVIDER = { + 'class': 'edx_proctoring.backends.NullBackendProvider', + 'options': {}, +} +PROCTORING_SETTINGS = {} diff --git a/cms/static/js/spec/views/pages/course_outline_spec.js b/cms/static/js/spec/views/pages/course_outline_spec.js index 104eb19980..0e9104d9d2 100644 --- a/cms/static/js/spec/views/pages/course_outline_spec.js +++ b/cms/static/js/spec/views/pages/course_outline_spec.js @@ -17,6 +17,7 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "js/views/utils/view_ut id: 'mock-course', display_name: 'Mock Course', category: 'course', + enable_proctored_exams: true, studio_url: '/course/slashes:MockCourse', is_container: true, has_changes: false, @@ -214,7 +215,7 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "js/views/utils/view_ut 'course-outline', 'xblock-string-field-editor', 'modal-button', 'basic-modal', 'course-outline-modal', 'release-date-editor', 'due-date-editor', 'grading-editor', 'publish-editor', - 'staff-lock-editor' + 'staff-lock-editor', 'timed-examination-preference-editor' ]); appendSetFixtures(mockOutlinePage); mockCourseJSON = createMockCourseJSON({}, [ @@ -582,7 +583,7 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "js/views/utils/view_ut }); describe("Subsection", function() { - var getDisplayNameWrapper, setEditModalValues, mockServerValuesJson; + var getDisplayNameWrapper, setEditModalValues, mockServerValuesJson, setModalTimedExaminationPreferenceValues; getDisplayNameWrapper = function() { return getItemHeaders('subsection').find('.wrapper-xblock-field'); @@ -595,6 +596,16 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "js/views/utils/view_ut $("#staff_lock").prop('checked', is_locked); }; + setModalTimedExaminationPreferenceValues = function( + is_timed_examination, + time_limit, + is_exam_proctoring_enabled + ){ + $("#id_time_limit").val(time_limit); + $("#id_exam_proctoring").prop('checked', is_exam_proctoring_enabled); + $("#id_timed_examination").prop('checked', is_timed_examination); + }; + // Contains hard-coded dates because dates are presented in different formats. mockServerValuesJson = createMockSectionJSON({ release_date: 'Jan 01, 2970 at 05:00 UTC' @@ -607,7 +618,10 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "js/views/utils/view_ut format: "Lab", due: "2014-07-10T00:00:00Z", has_explicit_staff_lock: true, - staff_only_message: true + staff_only_message: true, + "is_time_limited": true, + "is_proctored_enabled": true, + "default_time_limit_minutes": 150 }, [ createMockVerticalJSON({ has_changes: true, @@ -682,6 +696,7 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "js/views/utils/view_ut createCourseOutlinePage(this, mockCourseJSON, false); outlinePage.$('.outline-subsection .configure-button').click(); setEditModalValues("7/9/2014", "7/10/2014", "Lab", true); + setModalTimedExaminationPreferenceValues(true, "02:30", true); $(".wrapper-modal-window .action-save").click(); AjaxHelpers.expectJsonRequest(requests, 'POST', '/xblock/mock-subsection', { "graderType":"Lab", @@ -689,7 +704,10 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "js/views/utils/view_ut "metadata":{ "visible_to_staff_only": true, "start":"2014-07-09T00:00:00.000Z", - "due":"2014-07-10T00:00:00.000Z" + "due":"2014-07-10T00:00:00.000Z", + "is_time_limited": true, + "is_proctored_enabled": true, + "default_time_limit_minutes": 150 } }); expect(requests[0].requestHeaders['X-HTTP-Method-Override']).toBe('PATCH'); @@ -720,6 +738,27 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "js/views/utils/view_ut expect($("#due_date").val()).toBe('7/10/2014'); expect($("#grading_type").val()).toBe('Lab'); expect($("#staff_lock").is(":checked")).toBe(true); + expect($("#id_timed_examination").is(":checked")).toBe(true); + expect($("#id_exam_proctoring").is(":checked")).toBe(true); + expect($("#id_time_limit").val()).toBe("02:30"); + }); + + it('can be edited and enable/disable proctoring fields, when time_limit checkbox value changes', function() { + createCourseOutlinePage(this, mockCourseJSON, false); + outlinePage.$('.outline-subsection .configure-button').click(); + setEditModalValues("7/9/2014", "7/10/2014", "Lab", true); + setModalTimedExaminationPreferenceValues(true, "02:30", true); + var target = $('#id_timed_examination'); + target.attr("checked","checked"); + target.click(); + expect($('#id_exam_proctoring')).toHaveAttr('disabled','disabled'); + expect($('#id_time_limit')).toHaveAttr('disabled','disabled'); + target.removeAttr("checked"); + target.click(); + expect($('#id_exam_proctoring')).not.toHaveAttr('disabled','disabled'); + expect($('#id_time_limit')).not.toHaveAttr('disabled','disabled'); + expect($('#id_time_limit').val()).toBe('00:30'); + expect($('#id_exam_proctoring')).not.toHaveAttr('checked'); }); it('release date, due date, grading type, and staff lock can be cleared.', function() { diff --git a/cms/static/js/views/course_outline.js b/cms/static/js/views/course_outline.js index 1a5e26dde4..a3db955686 100644 --- a/cms/static/js/views/course_outline.js +++ b/cms/static/js/views/course_outline.js @@ -140,9 +140,17 @@ define(["jquery", "underscore", "js/views/xblock_outline", "js/views/utils/view_ }, editXBlock: function() { + var enable_proctored_exams = false; + if (this.model.get('category') === 'sequential' && + this.parentView.parentView.model.has('enable_proctored_exams')) { + + enable_proctored_exams = this.parentView.parentView.model.get('enable_proctored_exams'); + } + var modal = CourseOutlineModalsFactory.getModal('edit', this.model, { onSave: this.refresh.bind(this), parentInfo: this.parentInfo, + enable_proctored_exams: enable_proctored_exams, xblockType: XBlockViewUtils.getXBlockType( this.model.get('category'), this.parentView.model, true ) diff --git a/cms/static/js/views/modals/course_outline_modals.js b/cms/static/js/views/modals/course_outline_modals.js index e46cd710de..9758fd7f0d 100644 --- a/cms/static/js/views/modals/course_outline_modals.js +++ b/cms/static/js/views/modals/course_outline_modals.js @@ -13,7 +13,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', ) { 'use strict'; var CourseOutlineXBlockModal, SettingsXBlockModal, PublishXBlockModal, AbstractEditor, BaseDateEditor, - ReleaseDateEditor, DueDateEditor, GradingEditor, PublishEditor, StaffLockEditor; + ReleaseDateEditor, DueDateEditor, GradingEditor, PublishEditor, StaffLockEditor, TimedExaminationPreferenceEditor; CourseOutlineXBlockModal = BaseModal.extend({ events : { @@ -257,7 +257,94 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', }; } }); + TimedExaminationPreferenceEditor = AbstractEditor.extend({ + templateName: 'timed-examination-preference-editor', + className: 'edit-settings-timed-examination', + events : { + 'change #id_timed_examination': 'timedExamination', + 'focusout #id_time_limit': 'timeLimitFocusout' + }, + timeLimitFocusout: function(event) { + var selectedTimeLimit = $(event.currentTarget).val(); + if (!this.isValidTimeLimit(selectedTimeLimit)) { + $(event.currentTarget).val("00:30"); + } + }, + timedExamination: function (event) { + event.preventDefault(); + if (!$(event.currentTarget).is(':checked')) { + this.$('#id_exam_proctoring').attr('checked', false); + this.$('#id_time_limit').val('00:30'); + this.$('#id_exam_proctoring').attr('disabled','disabled'); + this.$('#id_time_limit').attr('disabled', 'disabled'); + } + else { + this.$('#id_exam_proctoring').removeAttr('disabled'); + this.$('#id_time_limit').removeAttr('disabled'); + } + return true; + }, + afterRender: function () { + AbstractEditor.prototype.afterRender.call(this); + this.$('input.time').timepicker({ + 'timeFormat' : 'H:i', + 'forceRoundTime': false + }); + this.setExamTime(this.model.get('default_time_limit_minutes')); + this.setExamTmePreference(this.model.get('is_time_limited')); + this.setExamProctoring(this.model.get('is_proctored_enabled')); + }, + setExamProctoring: function(value) { + this.$('#id_exam_proctoring').prop('checked', value); + }, + setExamTime: function(value) { + var time = this.convertTimeLimitMinutesToString(value); + this.$('#id_time_limit').val(time); + }, + setExamTmePreference: function (value) { + this.$('#id_timed_examination').prop('checked', value); + if (!this.$('#id_timed_examination').is(':checked')) { + this.$('#id_exam_proctoring').attr('disabled','disabled'); + this.$('#id_time_limit').attr('disabled', 'disabled'); + } + }, + isExamTimeEnabled: function () { + return this.$('#id_timed_examination').is(':checked'); + }, + isValidTimeLimit: function(time_limit) { + var pattern = new RegExp('^([0-9]|0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$'); + return pattern.test(time_limit); + }, + getExamTimeLimit: function () { + return this.$('#id_time_limit').val(); + }, + convertTimeLimitMinutesToString: function (timeLimitMinutes) { + var hoursStr = "" + Math.floor(timeLimitMinutes / 60); + var actualMinutesStr = "" + (timeLimitMinutes % 60); + hoursStr = "00".substring(0, 2 - hoursStr.length) + hoursStr; + actualMinutesStr = "00".substring(0, 2 - actualMinutesStr.length) + actualMinutesStr; + return hoursStr + ":" + actualMinutesStr; + }, + convertTimeLimitToMinutes: function (time_limit) { + var time = time_limit.split(':'); + var total_time = (parseInt(time[0]) * 60) + parseInt(time[1]); + return total_time; + }, + isExamProctoringEnabled: function () { + return this.$('#id_exam_proctoring').is(':checked'); + }, + getRequestData: function () { + var time_limit = this.getExamTimeLimit(); + return { + metadata: { + 'is_time_limited': this.isExamTimeEnabled(), + 'is_proctored_enabled': this.isExamProctoringEnabled(), + 'default_time_limit_minutes': this.convertTimeLimitToMinutes(time_limit) + } + }; + } + }); GradingEditor = AbstractEditor.extend({ templateName: 'grading-editor', className: 'edit-settings-grading', @@ -358,11 +445,19 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', if (xblockInfo.isChapter()) { editors = [ReleaseDateEditor, StaffLockEditor]; } else if (xblockInfo.isSequential()) { - editors = [ReleaseDateEditor, GradingEditor, DueDateEditor, StaffLockEditor]; + editors = [ReleaseDateEditor, GradingEditor, DueDateEditor]; + + // since timed/proctored exams are optional + // we want it before the StaffLockEditor + // to keep it closer to the GradingEditor + if (options.enable_proctored_exams) { + editors.push(TimedExaminationPreferenceEditor); + } + + editors.push(StaffLockEditor); } else if (xblockInfo.isVertical()) { editors = [StaffLockEditor]; } - return new SettingsXBlockModal($.extend({ editors: editors, model: xblockInfo diff --git a/cms/static/sass/elements/_modal-window.scss b/cms/static/sass/elements/_modal-window.scss index ac33a0e8a4..e7d06cf9c0 100644 --- a/cms/static/sass/elements/_modal-window.scss +++ b/cms/static/sass/elements/_modal-window.scss @@ -519,9 +519,14 @@ .wrapper-modal-window-bulkpublish-subsection, .wrapper-modal-window-bulkpublish-unit, .course-outline-modal { - + .exam-time-list-fields { + margin-bottom: ($baseline/2); + } .list-fields { - + .field-message { + color: $gray; + font-size: ($baseline/2); + } .field { display: inline-block; vertical-align: top; @@ -625,7 +630,42 @@ } // UI: staff lock section - .edit-staff-lock { + .edit-staff-lock, .edit-settings-timed-examination { + + .checkbox-cosmetic .input-checkbox { + @extend %cont-text-sr; + + // CASE: unchecked + ~ .tip-warning { + display: block; + } + + // CASE: checked + &:checked { + + ~ .tip-warning { + display: none; + } + } + } + + // needed to override poorly scoped margin-bottom on any label element in a view (from _forms.scss) + .checkbox-cosmetic .label { + margin-bottom: 0; + } + } + + // UI: timed and proctored exam section + .edit-settings-timed-examination { + + // give a little space between the sections + padding-bottom: 10px; + + // indent this group a bit to make it seem like + // it is one group, under a header + .modal-section-content { + margin-left: 25px; + } .checkbox-cosmetic .input-checkbox { @extend %cont-text-sr; diff --git a/cms/static/sass/elements/_modules.scss b/cms/static/sass/elements/_modules.scss index 92ad1f86b6..4af90d4d68 100644 --- a/cms/static/sass/elements/_modules.scss +++ b/cms/static/sass/elements/_modules.scss @@ -249,7 +249,7 @@ opacity: 1.0; } - // reset to remove jquery-ui float + // reset to remove jquery-ui float a.link-tab { float: none; } @@ -546,20 +546,24 @@ $outline-indent-width: $baseline; > .subsection-status .status-grading { opacity: 1.0; } + + > .subsection-status .status-timed-proctored-exam { + opacity: 1.0; + } } // status - grading - .status-grading { + .status-grading, .status-timed-proctored-exam { @include transition(opacity $tmg-f2 ease-in-out 0s); opacity: 0.65; } - .status-grading-value { + .status-grading-value, .status-proctored-exam-value { display: inline-block; vertical-align: middle; } - .status-grading-date { + .status-grading-date, .status-due-date { display: inline-block; vertical-align: middle; margin-left: ($baseline/4); diff --git a/cms/templates/course_outline.html b/cms/templates/course_outline.html index 595e083cff..bc5b6a92d7 100644 --- a/cms/templates/course_outline.html +++ b/cms/templates/course_outline.html @@ -21,7 +21,7 @@ from microsite_configuration import microsite <%block name="header_extras"> -% for template_name in ['course-outline', 'xblock-string-field-editor', 'basic-modal', 'modal-button', 'course-outline-modal', 'due-date-editor', 'release-date-editor', 'grading-editor', 'publish-editor', 'staff-lock-editor']: +% for template_name in ['course-outline', 'xblock-string-field-editor', 'basic-modal', 'modal-button', 'course-outline-modal', 'due-date-editor', 'release-date-editor', 'grading-editor', 'publish-editor', 'staff-lock-editor', 'timed-examination-preference-editor']: diff --git a/cms/templates/js/course-outline.underscore b/cms/templates/js/course-outline.underscore index 97fe255516..94da74b5f0 100644 --- a/cms/templates/js/course-outline.underscore +++ b/cms/templates/js/course-outline.underscore @@ -142,8 +142,19 @@ if (xblockInfo.get('graded')) {

<% } %> + <% if (xblockInfo.get('is_time_limited')) { %> +
+

+ <%= gettext('Proctored Exam') %> + + <%= gettext('Timed and Proctored Exam') %> + <% if (xblockInfo.get('due_date')) { %> + <%= gettext('Due Date') %> <%= xblockInfo.get('due_date') %> + <% } %> +

+
+ <% } %> <% } %> - <% if (statusMessage) { %>
diff --git a/cms/templates/js/timed-examination-preference-editor.underscore b/cms/templates/js/timed-examination-preference-editor.underscore new file mode 100644 index 0000000000..027156cb0f --- /dev/null +++ b/cms/templates/js/timed-examination-preference-editor.underscore @@ -0,0 +1,39 @@ +
+ + +
diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index e5bf35b476..694ab758a0 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -907,6 +907,15 @@ class CourseFields(object): scope=Scope.settings ) + enable_proctored_exams = Boolean( + display_name=_("Enable Proctored Exams"), + help=_( + "Enter true or false. If this value is true, timed and proctored exams are enabled in your course." + ), + default=False, + scope=Scope.settings + ) + minimum_grade_credit = Float( display_name=_("Minimum Grade for Credit"), help=_( diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index e3fe56c646..416db39933 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -49,7 +49,49 @@ class SequenceFields(object): ) -class SequenceModule(SequenceFields, XModule): +class ProctoringFields(object): + """ + Fields that are specific to Proctored or Timed Exams + """ + is_time_limited = Boolean( + display_name=_("Is Time Limited"), + help=_( + "This setting indicates whether students have a limited time" + " to view or interact with this courseware component." + ), + default=False, + scope=Scope.settings, + ) + + default_time_limit_minutes = Integer( + display_name=_("Time Limit in Minutes"), + help=_( + "The number of minutes available to students for viewing or interacting with this courseware component." + ), + default=None, + scope=Scope.settings, + ) + + is_proctored_enabled = Boolean( + display_name=_("Is Proctoring Enabled"), + help=_( + "This setting indicates whether this exam is a proctored exam." + ), + default=False, + scope=Scope.settings, + ) + + is_practice_exam = Boolean( + display_name=_("Is Practice Exam"), + help=_( + "This setting indicates whether this exam is for testing purposes only. Practice exams are not verified." + ), + default=False, + scope=Scope.settings, + ) + + +class SequenceModule(SequenceFields, ProctoringFields, XModule): # pylint: disable=abstract-method ''' Layout module which lays out content in a temporal sequence ''' js = { @@ -153,7 +195,10 @@ class SequenceModule(SequenceFields, XModule): return new_class -class SequenceDescriptor(SequenceFields, MakoModuleDescriptor, XmlDescriptor): +class SequenceDescriptor(SequenceFields, ProctoringFields, MakoModuleDescriptor, XmlDescriptor): + """ + A Sequences Descriptor object + """ mako_template = 'widgets/sequence-edit.html' module_class = SequenceModule diff --git a/common/test/acceptance/pages/studio/settings_advanced.py b/common/test/acceptance/pages/studio/settings_advanced.py index d4a02d23d5..3046abe8bc 100644 --- a/common/test/acceptance/pages/studio/settings_advanced.py +++ b/common/test/acceptance/pages/studio/settings_advanced.py @@ -202,4 +202,5 @@ class AdvancedSettingsPage(CoursePage): 'teams_configuration', 'video_bumper', 'cert_html_view_enabled', + 'enable_proctored_exams', ] diff --git a/lms/envs/common.py b/lms/envs/common.py index 833c8c6d61..cb6b81a65a 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2370,6 +2370,9 @@ OPTIONAL_APPS = ( # milestones 'milestones', + + # edX Proctoring + 'edx_proctoring', ) for app_name in OPTIONAL_APPS: diff --git a/openedx/core/djangoapps/credit/signals.py b/openedx/core/djangoapps/credit/signals.py index 4480f10bed..aed3ef8032 100644 --- a/openedx/core/djangoapps/credit/signals.py +++ b/openedx/core/djangoapps/credit/signals.py @@ -15,10 +15,13 @@ from openedx.core.djangoapps.signals.signals import GRADES_UPDATED log = logging.getLogger(__name__) -@receiver(SignalHandler.course_published) -def listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=unused-argument - """Receive 'course_published' signal and kick off a celery task to update - the credit course requirements. +def on_course_publish(course_key): # pylint: disable=unused-argument + """ + Will receive a delegated 'course_published' signal from cms/djangoapps/contentstore/signals.py + and kick off a celery task to update the credit course requirements. + + IMPORTANT: It is assumed that the edx-proctoring subsystem has been appropriate refreshed + with any on_publish event workflow *BEFORE* this method is called. """ # Import here, because signal is registered at startup, but items in tasks diff --git a/openedx/core/djangoapps/credit/tasks.py b/openedx/core/djangoapps/credit/tasks.py index 82efad96b0..97f36a2b69 100644 --- a/openedx/core/djangoapps/credit/tasks.py +++ b/openedx/core/djangoapps/credit/tasks.py @@ -57,6 +57,9 @@ def _get_course_credit_requirements(course_key): """ Returns the list of credit requirements for the given course. + This will also call into the edx-proctoring subsystem to also + produce proctored exam requirements for credit bearing courses + It returns the minimum_grade_credit and also the ICRV checkpoints if any were added in the course @@ -69,7 +72,10 @@ def _get_course_credit_requirements(course_key): """ credit_xblock_requirements = _get_credit_course_requirement_xblocks(course_key) min_grade_requirement = _get_min_grade_requirement(course_key) - credit_requirements = min_grade_requirement + credit_xblock_requirements + proctored_exams_requirements = _get_proctoring_requirements(course_key) + credit_requirements = ( + min_grade_requirement + credit_xblock_requirements + proctored_exams_requirements + ) return credit_requirements @@ -161,3 +167,44 @@ def _is_credit_requirement(xblock): return False return True + + +def _get_proctoring_requirements(course_key): + """ + Will return list of requirements regarding any exams that have been + marked as proctored exams. For credit-bearing courses, all + proctored exams must be validated and confirmed from a proctoring + standpoint. The passing grade on an exam is not enough. + + Args: + course_key: The key of the course in question + + Returns: + list of requirements dictionary, one per active proctored exam + + """ + + # Note: Need to import here as there appears to be + # a circular reference happening when launching Studio + # process + from edx_proctoring.api import get_all_exams_for_course + + requirements = [ + { + 'namespace': 'proctored_exam', + 'name': 'proctored_exam_id:{id}'.format(id=exam['id']), + 'display_name': exam['exam_name'], + 'criteria': {}, + } + for exam in get_all_exams_for_course(unicode(course_key)) + if exam['is_proctored'] and exam['is_active'] + ] + + log_msg = ( + 'Registering the following as \'proctored_exam\' credit requirements: {log_msg}'.format( + log_msg=requirements + ) + ) + LOGGER.info(log_msg) + + return requirements diff --git a/openedx/core/djangoapps/credit/tests/test_tasks.py b/openedx/core/djangoapps/credit/tests/test_tasks.py index 2ddcabfbd8..216a410ca7 100644 --- a/openedx/core/djangoapps/credit/tests/test_tasks.py +++ b/openedx/core/djangoapps/credit/tests/test_tasks.py @@ -8,11 +8,12 @@ from datetime import datetime from openedx.core.djangoapps.credit.api import get_credit_requirements from openedx.core.djangoapps.credit.exceptions import InvalidCreditRequirements from openedx.core.djangoapps.credit.models import CreditCourse -from openedx.core.djangoapps.credit.signals import listen_for_course_publish -from xmodule.modulestore.django import SignalHandler +from openedx.core.djangoapps.credit.signals import on_course_publish from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls +from edx_proctoring.api import create_exam + class TestTaskExecution(ModuleStoreTestCase): """Set of tests to ensure that the task code will do the right thing when @@ -44,7 +45,6 @@ class TestTaskExecution(ModuleStoreTestCase): def setUp(self): super(TestTaskExecution, self).setUp() - SignalHandler.course_published.disconnect(listen_for_course_publish) self.course = CourseFactory.create(start=datetime(2015, 3, 1)) def test_task_adding_requirements_invalid_course(self): @@ -53,7 +53,7 @@ class TestTaskExecution(ModuleStoreTestCase): """ requirements = get_credit_requirements(self.course.id) self.assertEqual(len(requirements), 0) - listen_for_course_publish(self, self.course.id) + on_course_publish(self.course.id) requirements = get_credit_requirements(self.course.id) self.assertEqual(len(requirements), 0) @@ -67,7 +67,7 @@ class TestTaskExecution(ModuleStoreTestCase): self.add_credit_course(self.course.id) requirements = get_credit_requirements(self.course.id) self.assertEqual(len(requirements), 0) - listen_for_course_publish(self, self.course.id) + on_course_publish(self.course.id) requirements = get_credit_requirements(self.course.id) self.assertEqual(len(requirements), 1) @@ -80,17 +80,100 @@ class TestTaskExecution(ModuleStoreTestCase): self.add_icrv_xblock() requirements = get_credit_requirements(self.course.id) self.assertEqual(len(requirements), 0) - listen_for_course_publish(self, self.course.id) + on_course_publish(self.course.id) requirements = get_credit_requirements(self.course.id) self.assertEqual(len(requirements), 2) + def test_proctored_exam_requirements(self): + """ + Make sure that proctored exams are being registered as requirements + """ + + self.add_credit_course(self.course.id) + create_exam( + course_id=unicode(self.course.id), + content_id='foo', + exam_name='A Proctored Exam', + time_limit_mins=10, + is_proctored=True, + is_active=True + ) + + requirements = get_credit_requirements(self.course.id) + self.assertEqual(len(requirements), 0) + on_course_publish(self.course.id) + + # just inspect the proctored exam requirement + requirements = [ + requirement + for requirement in get_credit_requirements(self.course.id) + if requirement['namespace'] == 'proctored_exam' + ] + + self.assertEqual(len(requirements), 1) + self.assertEqual(requirements[0]['namespace'], 'proctored_exam') + self.assertEqual(requirements[0]['name'], 'proctored_exam_id:1') + self.assertEqual(requirements[0]['display_name'], 'A Proctored Exam') + self.assertEqual(requirements[0]['criteria'], {}) + + def test_proctored_exam_filtering(self): + """ + Make sure that timed or inactive exams do not end up in the requirements table + """ + + self.add_credit_course(self.course.id) + create_exam( + course_id=unicode(self.course.id), + content_id='foo', + exam_name='A Proctored Exam', + time_limit_mins=10, + is_proctored=False, + is_active=True + ) + + requirements = get_credit_requirements(self.course.id) + self.assertEqual(len(requirements), 0) + + on_course_publish(self.course.id) + + requirements = get_credit_requirements(self.course.id) + self.assertEqual(len(requirements), 1) + + # make sure we don't have a proctoring requirement + self.assertFalse([ + requirement + for requirement in requirements + if requirement['namespace'] == 'proctored_exam' + ]) + + create_exam( + course_id=unicode(self.course.id), + content_id='foo2', + exam_name='A Proctored Exam', + time_limit_mins=10, + is_proctored=True, + is_active=False + ) + + on_course_publish(self.course.id) + + requirements = get_credit_requirements(self.course.id) + self.assertEqual(len(requirements), 1) + + # make sure we don't have a proctoring requirement + self.assertFalse([ + requirement + for requirement in requirements + if requirement['namespace'] == 'proctored_exam' + ]) + def test_query_counts(self): self.add_credit_course(self.course.id) self.add_icrv_xblock() with check_mongo_calls(3): - listen_for_course_publish(self, self.course.id) + on_course_publish(self.course.id) @mock.patch( 'openedx.core.djangoapps.credit.tasks.set_credit_requirements', @@ -108,7 +191,7 @@ class TestTaskExecution(ModuleStoreTestCase): self.add_credit_course(self.course.id) requirements = get_credit_requirements(self.course.id) self.assertEqual(len(requirements), 0) - listen_for_course_publish(self, self.course.id) + on_course_publish(self.course.id) requirements = get_credit_requirements(self.course.id) self.assertEqual(len(requirements), 0) diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 61ee55dcbe..608624aa58 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -56,6 +56,9 @@ git+https://github.com/edx/edx-lint.git@ed8c8d2a0267d4d42f43642d193e25f8bd575d9b git+https://github.com/edx/ecommerce-api-client.git@1.1.0#egg=ecommerce-api-client==1.1.0 -e git+https://github.com/edx/edx-user-state-client.git@64a8b603f42669bb7fdca03d364d4e8d3d6ad67d#egg=edx-user-state-client +-e git+https://github.com/edx/edx-proctoring.git@6e7b4dba5b6d7a13c7dc111ae64e0579a1301ff9#egg=edx-proctoring + + # Third Party XBlocks -e git+https://github.com/mitodl/edx-sga@172a90fd2738f8142c10478356b2d9ed3e55334a#egg=edx-sga -e git+https://github.com/open-craft/xblock-poll@v1.0#egg=xblock-poll