Commit 37481199 by Chris Dodge

Integrate timed and proctored exam authoring into Studio

parent 4b4ce5f1
"""
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,
)
""" 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())
"""
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
......@@ -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
......
......@@ -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)
......
......@@ -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):
"""
......
......@@ -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
......@@ -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 = {}
......@@ -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>
......
<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',
]
......@@ -2370,6 +2370,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
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment