Merge pull request #8962 from edx/cdodge/proctoring-studio

Integrate timed and proctored exam authoring into Studio
This commit is contained in:
chrisndodge
2015-07-23 15:53:17 -04:00
26 changed files with 896 additions and 39 deletions

View 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,
)

View File

@@ -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())

View 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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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):
"""

View File

@@ -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

View File

@@ -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)

View File

@@ -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 = {}

View File

@@ -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() {

View File

@@ -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
)

View File

@@ -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

View File

@@ -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;

View File

@@ -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);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>