Merge pull request #8962 from edx/cdodge/proctoring-studio
Integrate timed and proctored exam authoring into Studio
This commit is contained in:
116
cms/djangoapps/contentstore/proctoring.py
Normal file
116
cms/djangoapps/contentstore/proctoring.py
Normal file
@@ -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,
|
||||
)
|
||||
@@ -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())
|
||||
|
||||
206
cms/djangoapps/contentstore/tests/test_proctoring.py
Normal file
206
cms/djangoapps/contentstore/tests/test_proctoring.py
Normal file
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -812,6 +812,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.
|
||||
@@ -848,9 +849,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1685,6 +1685,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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -871,6 +874,9 @@ OPTIONAL_APPS = (
|
||||
|
||||
# milestones
|
||||
'milestones',
|
||||
|
||||
# edX Proctoring
|
||||
'edx_proctoring',
|
||||
)
|
||||
|
||||
|
||||
@@ -1018,3 +1024,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 = {}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -21,7 +21,7 @@ from microsite_configuration import microsite
|
||||
|
||||
<%block name="header_extras">
|
||||
<link rel="stylesheet" type="text/css" href="${static.url('js/vendor/timepicker/jquery.timepicker.css')}" />
|
||||
% 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']:
|
||||
<script type="text/template" id="${template_name}-tpl">
|
||||
<%static:include path="js/${template_name}.underscore" />
|
||||
</script>
|
||||
|
||||
@@ -142,8 +142,19 @@ if (xblockInfo.get('graded')) {
|
||||
</p>
|
||||
</div>
|
||||
<% } %>
|
||||
<% if (xblockInfo.get('is_time_limited')) { %>
|
||||
<div class="status-timed-proctored-exam">
|
||||
<p>
|
||||
<span class="sr status-proctored-exam-label"> <%= gettext('Proctored Exam') %> </span>
|
||||
<i class="icon fa fa-check"></i>
|
||||
<span class="status-proctored-exam-value"> <%= gettext('Timed and Proctored Exam') %> </span>
|
||||
<% if (xblockInfo.get('due_date')) { %>
|
||||
<span class="status-due-date"> <%= gettext('Due Date') %> <%= xblockInfo.get('due_date') %> </span>
|
||||
<% } %>
|
||||
</p>
|
||||
</div>
|
||||
<% } %>
|
||||
<% } %>
|
||||
|
||||
<% if (statusMessage) { %>
|
||||
<div class="status-message">
|
||||
<i class="icon fa <%= statusIconClass %>"></i>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<form>
|
||||
<h3 class="modal-section-title"><%- gettext('Timed Exam') %></h3>
|
||||
<div class="modal-section-content has-actions">
|
||||
<div class='exam-time-list-fields'>
|
||||
<ul class="list-fields list-input">
|
||||
<li class="field field-checkbox checkbox-cosmetic">
|
||||
<input type="checkbox" id="id_timed_examination" name="timed_examination" class="input input-checkbox" />
|
||||
<label for="id_timed_examination" class="label">
|
||||
<i class="icon fa fa-check-square-o input-checkbox-checked"></i>
|
||||
<i class="icon fa fa-square-o input-checkbox-unchecked"></i>
|
||||
<%- gettext('This exam is timed') %>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class='exam-time-list-fields'>
|
||||
<ul class="list-fields list-input time-limit">
|
||||
<li class="field field-text field-time-limit">
|
||||
<label for="id_time_limit" class="label"><%- gettext('Time Allotted (HH:MM):') %></label>
|
||||
<input type="text" id="id_time_limit" name="time_limit"
|
||||
value=""
|
||||
placeholder="HH:MM" class="time_limit release-time time input input-text" autocomplete="off" />
|
||||
</li>
|
||||
<p class='field-message'><%- gettext('Students see warnings when 20% and 5% of the allotted time remains. In certain cases, students can be granted allowances that give them extra time to complete the exam.') %></p>
|
||||
</ul>
|
||||
</div>
|
||||
<ul class="list-fields list-input">
|
||||
<li class="field field-checkbox checkbox-cosmetic">
|
||||
<input type="checkbox" id="id_exam_proctoring" name="exam_proctoring" class="input input-checkbox" />
|
||||
<label for="id_exam_proctoring" class="label">
|
||||
<i class="icon fa fa-check-square-o input-checkbox-checked"></i>
|
||||
<i class="icon fa fa-square-o input-checkbox-unchecked"></i>
|
||||
<%- gettext('This exam is proctored') %>
|
||||
</label>
|
||||
</li>
|
||||
<p class='field-message'> <%- gettext('Students can choose to take this exam with or without online proctoring, but only students who choose the proctored option are eligible for credit. Proctored exams must also be timed exams.') %> </p>
|
||||
</ul>
|
||||
</div>
|
||||
</form>
|
||||
@@ -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=_(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -202,4 +202,5 @@ class AdvancedSettingsPage(CoursePage):
|
||||
'teams_configuration',
|
||||
'video_bumper',
|
||||
'cert_html_view_enabled',
|
||||
'enable_proctored_exams',
|
||||
]
|
||||
|
||||
@@ -2384,6 +2384,9 @@ OPTIONAL_APPS = (
|
||||
|
||||
# milestones
|
||||
'milestones',
|
||||
|
||||
# edX Proctoring
|
||||
'edx_proctoring',
|
||||
)
|
||||
|
||||
for app_name in OPTIONAL_APPS:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user