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