Commit 78680678 by chrisndodge

Merge pull request #9744 from edx/cdodge/timed-exams

Timed Exams
parents 717f5dc5 9476898d
...@@ -245,6 +245,7 @@ J. Cliff Dyer <cdyer@edx.org> ...@@ -245,6 +245,7 @@ J. Cliff Dyer <cdyer@edx.org>
Jamie Folsom <jfolsom@mit.edu> Jamie Folsom <jfolsom@mit.edu>
George Schneeloch <gschneel@mit.edu> George Schneeloch <gschneel@mit.edu>
Dustin Gadal <Dustin.Gadal@gmail.com> Dustin Gadal <Dustin.Gadal@gmail.com>
Ibrahim Ahmed <ibrahimahmed443@gmail.com>
Robert Raposa <rraposa@edx.org> Robert Raposa <rraposa@edx.org>
Giovanni Di Milia <gdimilia@mit.edu> Giovanni Di Milia <gdimilia@mit.edu>
Peter Wilkins <pwilkins@mit.edu> Peter Wilkins <pwilkins@mit.edu>
......
...@@ -23,7 +23,7 @@ from edx_proctoring.exceptions import ( ...@@ -23,7 +23,7 @@ from edx_proctoring.exceptions import (
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def register_proctored_exams(course_key): def register_special_exams(course_key):
""" """
This is typically called on a course published signal. The course is examined for sequences 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 that are marked as timed exams. Then these are registered with the edx-proctoring
...@@ -31,13 +31,14 @@ def register_proctored_exams(course_key): ...@@ -31,13 +31,14 @@ def register_proctored_exams(course_key):
registered exams are marked as inactive registered exams are marked as inactive
""" """
if not settings.FEATURES.get('ENABLE_PROCTORED_EXAMS'): if not settings.FEATURES.get('ENABLE_SPECIAL_EXAMS'):
# if feature is not enabled then do a quick exit # if feature is not enabled then do a quick exit
return return
course = modulestore().get_course(course_key) course = modulestore().get_course(course_key)
if not course.enable_proctored_exams: if not course.enable_proctored_exams and not course.enable_timed_exams:
# likewise if course does not have this feature turned on # likewise if course does not have these features turned on
# then quickly exit
return return
# get all sequences, since they can be marked as timed/proctored exams # get all sequences, since they can be marked as timed/proctored exams
...@@ -75,7 +76,8 @@ def register_proctored_exams(course_key): ...@@ -75,7 +76,8 @@ def register_proctored_exams(course_key):
exam_id=exam['id'], exam_id=exam['id'],
exam_name=timed_exam.display_name, exam_name=timed_exam.display_name,
time_limit_mins=timed_exam.default_time_limit_minutes, time_limit_mins=timed_exam.default_time_limit_minutes,
is_proctored=timed_exam.is_proctored_enabled, due_date=timed_exam.due,
is_proctored=timed_exam.is_proctored_exam,
is_practice_exam=timed_exam.is_practice_exam, is_practice_exam=timed_exam.is_practice_exam,
is_active=True is_active=True
) )
...@@ -87,7 +89,8 @@ def register_proctored_exams(course_key): ...@@ -87,7 +89,8 @@ def register_proctored_exams(course_key):
content_id=unicode(timed_exam.location), content_id=unicode(timed_exam.location),
exam_name=timed_exam.display_name, exam_name=timed_exam.display_name,
time_limit_mins=timed_exam.default_time_limit_minutes, time_limit_mins=timed_exam.default_time_limit_minutes,
is_proctored=timed_exam.is_proctored_enabled, due_date=timed_exam.due,
is_proctored=timed_exam.is_proctored_exam,
is_practice_exam=timed_exam.is_practice_exam, is_practice_exam=timed_exam.is_practice_exam,
is_active=True is_active=True
) )
......
...@@ -7,7 +7,7 @@ from django.dispatch import receiver ...@@ -7,7 +7,7 @@ from django.dispatch import receiver
from xmodule.modulestore.django import SignalHandler from xmodule.modulestore.django import SignalHandler
from contentstore.courseware_index import CoursewareSearchIndexer, LibrarySearchIndexer from contentstore.courseware_index import CoursewareSearchIndexer, LibrarySearchIndexer
from contentstore.proctoring import register_proctored_exams from contentstore.proctoring import register_special_exams
from openedx.core.djangoapps.credit.signals import on_course_publish from openedx.core.djangoapps.credit.signals import on_course_publish
...@@ -21,7 +21,7 @@ def listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable= ...@@ -21,7 +21,7 @@ def listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=
# first is to registered exams, the credit subsystem will assume that # 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 # all proctored exams have already been registered, so we have to do that first
register_proctored_exams(course_key) register_special_exams(course_key)
# then call into the credit subsystem (in /openedx/djangoapps/credit) # then call into the credit subsystem (in /openedx/djangoapps/credit)
# to perform any 'on_publish' workflow # to perform any 'on_publish' workflow
......
...@@ -4,6 +4,8 @@ Tests for the edx_proctoring integration into Studio ...@@ -4,6 +4,8 @@ Tests for the edx_proctoring integration into Studio
from mock import patch from mock import patch
import ddt import ddt
from datetime import datetime, timedelta
from pytz import UTC
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
...@@ -13,7 +15,7 @@ from edx_proctoring.api import get_all_exams_for_course ...@@ -13,7 +15,7 @@ from edx_proctoring.api import get_all_exams_for_course
@ddt.ddt @ddt.ddt
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_PROCTORED_EXAMS': True}) @patch.dict('django.conf.settings.FEATURES', {'ENABLE_SPECIAL_EXAMS': True})
class TestProctoredExams(ModuleStoreTestCase): class TestProctoredExams(ModuleStoreTestCase):
""" """
Tests for the publishing of proctored exams Tests for the publishing of proctored exams
...@@ -46,7 +48,7 @@ class TestProctoredExams(ModuleStoreTestCase): ...@@ -46,7 +48,7 @@ class TestProctoredExams(ModuleStoreTestCase):
self.assertEqual(exam['content_id'], unicode(sequence.location)) self.assertEqual(exam['content_id'], unicode(sequence.location))
self.assertEqual(exam['exam_name'], sequence.display_name) self.assertEqual(exam['exam_name'], sequence.display_name)
self.assertEqual(exam['time_limit_mins'], sequence.default_time_limit_minutes) self.assertEqual(exam['time_limit_mins'], sequence.default_time_limit_minutes)
self.assertEqual(exam['is_proctored'], sequence.is_proctored_enabled) self.assertEqual(exam['is_proctored'], sequence.is_proctored_exam)
self.assertEqual(exam['is_active'], expected_active) self.assertEqual(exam['is_active'], expected_active)
@ddt.data( @ddt.data(
...@@ -56,7 +58,7 @@ class TestProctoredExams(ModuleStoreTestCase): ...@@ -56,7 +58,7 @@ class TestProctoredExams(ModuleStoreTestCase):
) )
@ddt.unpack @ddt.unpack
def test_publishing_exam(self, is_time_limited, default_time_limit_minutes, def test_publishing_exam(self, is_time_limited, default_time_limit_minutes,
is_procted_enabled, expected_active, republish): is_proctored_exam, expected_active, republish):
""" """
Happy path testing to see that when a course is published which contains 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 a proctored exam, it will also put an entry into the exam tables
...@@ -70,7 +72,8 @@ class TestProctoredExams(ModuleStoreTestCase): ...@@ -70,7 +72,8 @@ class TestProctoredExams(ModuleStoreTestCase):
graded=True, graded=True,
is_time_limited=is_time_limited, is_time_limited=is_time_limited,
default_time_limit_minutes=default_time_limit_minutes, default_time_limit_minutes=default_time_limit_minutes,
is_proctored_enabled=is_procted_enabled is_proctored_exam=is_proctored_exam,
due=datetime.now(UTC) + timedelta(minutes=default_time_limit_minutes + 1)
) )
listen_for_course_publish(self, self.course.id) listen_for_course_publish(self, self.course.id)
...@@ -102,7 +105,7 @@ class TestProctoredExams(ModuleStoreTestCase): ...@@ -102,7 +105,7 @@ class TestProctoredExams(ModuleStoreTestCase):
graded=True, graded=True,
is_time_limited=True, is_time_limited=True,
default_time_limit_minutes=10, default_time_limit_minutes=10,
is_proctored_enabled=True is_proctored_exam=True
) )
listen_for_course_publish(self, self.course.id) listen_for_course_publish(self, self.course.id)
...@@ -111,7 +114,7 @@ class TestProctoredExams(ModuleStoreTestCase): ...@@ -111,7 +114,7 @@ class TestProctoredExams(ModuleStoreTestCase):
self.assertEqual(len(exams), 1) self.assertEqual(len(exams), 1)
sequence.is_time_limited = False sequence.is_time_limited = False
sequence.is_proctored_enabled = False sequence.is_proctored_exam = False
self.store.update_item(sequence, self.user.id) self.store.update_item(sequence, self.user.id)
...@@ -132,7 +135,7 @@ class TestProctoredExams(ModuleStoreTestCase): ...@@ -132,7 +135,7 @@ class TestProctoredExams(ModuleStoreTestCase):
graded=True, graded=True,
is_time_limited=True, is_time_limited=True,
default_time_limit_minutes=10, default_time_limit_minutes=10,
is_proctored_enabled=True is_proctored_exam=True
) )
listen_for_course_publish(self, self.course.id) listen_for_course_publish(self, self.course.id)
...@@ -153,7 +156,7 @@ class TestProctoredExams(ModuleStoreTestCase): ...@@ -153,7 +156,7 @@ class TestProctoredExams(ModuleStoreTestCase):
exam = exams[0] exam = exams[0]
self.assertEqual(exam['is_active'], False) self.assertEqual(exam['is_active'], False)
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_PROCTORED_EXAMS': False}) @patch.dict('django.conf.settings.FEATURES', {'ENABLE_SPECIAL_EXAMS': False})
def test_feature_flag_off(self): def test_feature_flag_off(self):
""" """
Make sure the feature flag is honored Make sure the feature flag is honored
...@@ -167,7 +170,7 @@ class TestProctoredExams(ModuleStoreTestCase): ...@@ -167,7 +170,7 @@ class TestProctoredExams(ModuleStoreTestCase):
graded=True, graded=True,
is_time_limited=True, is_time_limited=True,
default_time_limit_minutes=10, default_time_limit_minutes=10,
is_proctored_enabled=True is_proctored_exam=True
) )
listen_for_course_publish(self, self.course.id) listen_for_course_publish(self, self.course.id)
...@@ -175,7 +178,13 @@ class TestProctoredExams(ModuleStoreTestCase): ...@@ -175,7 +178,13 @@ class TestProctoredExams(ModuleStoreTestCase):
exams = get_all_exams_for_course(unicode(self.course.id)) exams = get_all_exams_for_course(unicode(self.course.id))
self.assertEqual(len(exams), 0) self.assertEqual(len(exams), 0)
def test_advanced_setting_off(self): @ddt.data(
(True, False, 1),
(False, True, 1),
(False, False, 0),
)
@ddt.unpack
def test_advanced_settings(self, enable_timed_exams, enable_proctored_exams, expected_count):
""" """
Make sure the feature flag is honored Make sure the feature flag is honored
""" """
...@@ -184,7 +193,8 @@ class TestProctoredExams(ModuleStoreTestCase): ...@@ -184,7 +193,8 @@ class TestProctoredExams(ModuleStoreTestCase):
org='edX', org='edX',
course='901', course='901',
run='test_run2', run='test_run2',
enable_proctored_exams=False enable_proctored_exams=enable_proctored_exams,
enable_timed_exams=enable_timed_exams
) )
chapter = ItemFactory.create(parent=self.course, category='chapter', display_name='Test Section') chapter = ItemFactory.create(parent=self.course, category='chapter', display_name='Test Section')
...@@ -195,7 +205,7 @@ class TestProctoredExams(ModuleStoreTestCase): ...@@ -195,7 +205,7 @@ class TestProctoredExams(ModuleStoreTestCase):
graded=True, graded=True,
is_time_limited=True, is_time_limited=True,
default_time_limit_minutes=10, default_time_limit_minutes=10,
is_proctored_enabled=True is_proctored_exam=True
) )
listen_for_course_publish(self, self.course.id) listen_for_course_publish(self, self.course.id)
...@@ -203,4 +213,4 @@ class TestProctoredExams(ModuleStoreTestCase): ...@@ -203,4 +213,4 @@ class TestProctoredExams(ModuleStoreTestCase):
# there shouldn't be any exams because we haven't enabled that # there shouldn't be any exams because we haven't enabled that
# advanced setting flag # advanced setting flag
exams = get_all_exams_for_course(unicode(self.course.id)) exams = get_all_exams_for_course(unicode(self.course.id))
self.assertEqual(len(exams), 0) self.assertEqual(len(exams), expected_count)
...@@ -861,18 +861,19 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F ...@@ -861,18 +861,19 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
"user_partitions": get_user_partition_info(xblock, course=course), "user_partitions": get_user_partition_info(xblock, course=course),
} }
# update xblock_info with proctored_exam information if the feature flag is enabled # update xblock_info with special exam information if the feature flag is enabled
if settings.FEATURES.get('ENABLE_PROCTORED_EXAMS'): if settings.FEATURES.get('ENABLE_SPECIAL_EXAMS'):
if xblock.category == 'course': if xblock.category == 'course':
xblock_info.update({ xblock_info.update({
"enable_proctored_exams": xblock.enable_proctored_exams "enable_proctored_exams": xblock.enable_proctored_exams,
"enable_timed_exams": xblock.enable_timed_exams
}) })
elif xblock.category == 'sequential': elif xblock.category == 'sequential':
xblock_info.update({ xblock_info.update({
"is_proctored_enabled": xblock.is_proctored_enabled, "is_proctored_exam": xblock.is_proctored_exam,
"is_practice_exam": xblock.is_practice_exam,
"is_time_limited": xblock.is_time_limited, "is_time_limited": xblock.is_time_limited,
"default_time_limit_minutes": xblock.default_time_limit_minutes, "default_time_limit_minutes": xblock.default_time_limit_minutes
"is_practice_exam": xblock.is_practice_exam
}) })
# Entrance exam subsection should be hidden. in_entrance_exam is inherited metadata, all children will have it. # Entrance exam subsection should be hidden. in_entrance_exam is inherited metadata, all children will have it.
......
...@@ -1731,7 +1731,7 @@ class TestXBlockInfo(ItemTest): ...@@ -1731,7 +1731,7 @@ class TestXBlockInfo(ItemTest):
else: else:
self.assertIsNone(xblock_info.get('child_info', None)) self.assertIsNone(xblock_info.get('child_info', None))
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_PROCTORED_EXAMS': True}) @patch.dict('django.conf.settings.FEATURES', {'ENABLE_SPECIAL_EXAMS': True})
def test_proctored_exam_xblock_info(self): def test_proctored_exam_xblock_info(self):
self.course.enable_proctored_exams = True self.course.enable_proctored_exams = True
self.course.save() self.course.save()
...@@ -1749,7 +1749,7 @@ class TestXBlockInfo(ItemTest): ...@@ -1749,7 +1749,7 @@ class TestXBlockInfo(ItemTest):
sequential = ItemFactory.create( sequential = ItemFactory.create(
parent_location=self.chapter.location, category='sequential', parent_location=self.chapter.location, category='sequential',
display_name="Test Lesson 1", user_id=self.user.id, display_name="Test Lesson 1", user_id=self.user.id,
is_proctored_enabled=True, is_time_limited=True, is_proctored_exam=True, is_time_limited=True,
default_time_limit_minutes=100 default_time_limit_minutes=100
) )
sequential = modulestore().get_item(sequential.location) sequential = modulestore().get_item(sequential.location)
...@@ -1759,7 +1759,7 @@ class TestXBlockInfo(ItemTest): ...@@ -1759,7 +1759,7 @@ class TestXBlockInfo(ItemTest):
include_children_predicate=ALWAYS, include_children_predicate=ALWAYS,
) )
# exam proctoring should be enabled and time limited. # exam proctoring should be enabled and time limited.
self.assertEqual(xblock_info['is_proctored_enabled'], True) self.assertEqual(xblock_info['is_proctored_exam'], True)
self.assertEqual(xblock_info['is_time_limited'], True) self.assertEqual(xblock_info['is_time_limited'], True)
self.assertEqual(xblock_info['default_time_limit_minutes'], 100) self.assertEqual(xblock_info['default_time_limit_minutes'], 100)
......
...@@ -78,7 +78,7 @@ ...@@ -78,7 +78,7 @@
"SUBDOMAIN_COURSE_LISTINGS": false, "SUBDOMAIN_COURSE_LISTINGS": false,
"ALLOW_ALL_ADVANCED_COMPONENTS": true, "ALLOW_ALL_ADVANCED_COMPONENTS": true,
"ENABLE_CONTENT_LIBRARIES": true, "ENABLE_CONTENT_LIBRARIES": true,
"ENABLE_PROCTORED_EXAMS": true "ENABLE_SPECIAL_EXAMS": true
}, },
"FEEDBACK_SUBMISSION_EMAIL": "", "FEEDBACK_SUBMISSION_EMAIL": "",
"GITHUB_REPO_ROOT": "** OVERRIDDEN **", "GITHUB_REPO_ROOT": "** OVERRIDDEN **",
......
...@@ -104,7 +104,7 @@ FEATURES['PARTNER_SUPPORT_EMAIL'] = 'partner-support@example.com' ...@@ -104,7 +104,7 @@ FEATURES['PARTNER_SUPPORT_EMAIL'] = 'partner-support@example.com'
########################### Entrance Exams ################################# ########################### Entrance Exams #################################
FEATURES['ENTRANCE_EXAMS'] = True FEATURES['ENTRANCE_EXAMS'] = True
FEATURES['ENABLE_PROCTORED_EXAMS'] = True FEATURES['ENABLE_SPECIAL_EXAMS'] = True
# Point the URL used to test YouTube availability to our stub YouTube server # Point the URL used to test YouTube availability to our stub YouTube server
YOUTUBE_PORT = 9080 YOUTUBE_PORT = 9080
......
...@@ -168,9 +168,6 @@ FEATURES = { ...@@ -168,9 +168,6 @@ FEATURES = {
# Show video bumper in Studio # Show video bumper in Studio
'ENABLE_VIDEO_BUMPER': False, 'ENABLE_VIDEO_BUMPER': False,
# Timed Proctored Exams
'ENABLE_PROCTORED_EXAMS': False,
# How many seconds to show the bumper again, default is 7 days: # How many seconds to show the bumper again, default is 7 days:
'SHOW_BUMPER_PERIODICITY': 7 * 24 * 3600, 'SHOW_BUMPER_PERIODICITY': 7 * 24 * 3600,
...@@ -180,8 +177,8 @@ FEATURES = { ...@@ -180,8 +177,8 @@ FEATURES = {
# Can the visibility of the discussion tab be configured on a per-course basis? # Can the visibility of the discussion tab be configured on a per-course basis?
'ALLOW_HIDING_DISCUSSION_TAB': False, 'ALLOW_HIDING_DISCUSSION_TAB': False,
# Timed or Proctored Exams # Special Exams, aka Timed and Proctored Exams
'ENABLE_PROCTORED_EXAMS': False, 'ENABLE_SPECIAL_EXAMS': False,
} }
ENABLE_JASMINE = False ENABLE_JASMINE = False
......
...@@ -141,16 +141,21 @@ define(["jquery", "underscore", "js/views/xblock_outline", "common/js/components ...@@ -141,16 +141,21 @@ define(["jquery", "underscore", "js/views/xblock_outline", "common/js/components
editXBlock: function() { editXBlock: function() {
var enable_proctored_exams = false; var enable_proctored_exams = false;
if (this.model.get('category') === 'sequential' && var enable_timed_exams = false;
this.parentView.parentView.model.has('enable_proctored_exams')) { if (this.model.get('category') === 'sequential') {
if (this.parentView.parentView.model.has('enable_proctored_exams')) {
enable_proctored_exams = this.parentView.parentView.model.get('enable_proctored_exams'); enable_proctored_exams = this.parentView.parentView.model.get('enable_proctored_exams');
}
if (this.parentView.parentView.model.has('enable_timed_exams')) {
enable_timed_exams = this.parentView.parentView.model.get('enable_timed_exams');
}
} }
var modal = CourseOutlineModalsFactory.getModal('edit', this.model, { var modal = CourseOutlineModalsFactory.getModal('edit', this.model, {
onSave: this.refresh.bind(this), onSave: this.refresh.bind(this),
parentInfo: this.parentInfo, parentInfo: this.parentInfo,
enable_proctored_exams: enable_proctored_exams, enable_proctored_exams: enable_proctored_exams,
enable_timed_exams: enable_timed_exams,
xblockType: XBlockViewUtils.getXBlockType( xblockType: XBlockViewUtils.getXBlockType(
this.model.get('category'), this.parentView.model, true this.model.get('category'), this.parentView.model, true
) )
......
...@@ -47,7 +47,9 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', ...@@ -47,7 +47,9 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
return new Editor({ return new Editor({
parentElement: this.$('.modal-section'), parentElement: this.$('.modal-section'),
model: this.model, model: this.model,
xblockType: this.options.xblockType xblockType: this.options.xblockType,
enable_proctored_exams: this.options.enable_proctored_exams,
enable_timed_exams: this.options.enable_timed_exams
}); });
}, this); }, this);
}, },
...@@ -161,7 +163,9 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', ...@@ -161,7 +163,9 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
render: function () { render: function () {
var html = this.template($.extend({}, { var html = this.template($.extend({}, {
xblockInfo: this.model, xblockInfo: this.model,
xblockType: this.options.xblockType xblockType: this.options.xblockType,
enable_proctored_exam: this.options.enable_proctored_exams,
enable_timed_exam: this.options.enable_timed_exams
}, this.getContext())); }, this.getContext()));
this.$el.html(html); this.$el.html(html);
...@@ -261,37 +265,30 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', ...@@ -261,37 +265,30 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
TimedExaminationPreferenceEditor = AbstractEditor.extend({ TimedExaminationPreferenceEditor = AbstractEditor.extend({
templateName: 'timed-examination-preference-editor', templateName: 'timed-examination-preference-editor',
className: 'edit-settings-timed-examination', className: 'edit-settings-timed-examination',
events : { events : {
'change #id_timed_examination': 'timedExamination', 'change #id_not_timed': 'notTimedExam',
'change #id_timed_exam': 'showTimeLimit',
'change #id_practice_exam': 'showTimeLimit',
'change #id_proctored_exam': 'showTimeLimit',
'focusout #id_time_limit': 'timeLimitFocusout' 'focusout #id_time_limit': 'timeLimitFocusout'
}, },
notTimedExam: function (event) {
event.preventDefault();
this.$('#id_time_limit_div').hide();
this.$('#id_time_limit').val('00:00');
},
showTimeLimit: function (event) {
event.preventDefault();
this.$('#id_time_limit_div').show();
this.$('#id_time_limit').val("00:30");
},
timeLimitFocusout: function(event) { timeLimitFocusout: function(event) {
event.preventDefault();
var selectedTimeLimit = $(event.currentTarget).val(); var selectedTimeLimit = $(event.currentTarget).val();
if (!this.isValidTimeLimit(selectedTimeLimit)) { if (!this.isValidTimeLimit(selectedTimeLimit)) {
$(event.currentTarget).val("00:30"); $(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:00');
this.$('#id_exam_proctoring').attr('disabled','disabled');
this.$('#id_time_limit').attr('disabled', 'disabled');
this.$('#id_practice_exam').attr('checked', false);
this.$('#id_practice_exam').attr('disabled','disabled');
}
else {
if (!this.isValidTimeLimit(this.$('#id_time_limit').val())) {
this.$('#id_time_limit').val('00:30');
}
this.$('#id_practice_exam').removeAttr('disabled');
this.$('#id_exam_proctoring').removeAttr('disabled');
this.$('#id_time_limit').removeAttr('disabled');
}
return true;
},
afterRender: function () { afterRender: function () {
AbstractEditor.prototype.afterRender.call(this); AbstractEditor.prototype.afterRender.call(this);
this.$('input.time').timepicker({ this.$('input.time').timepicker({
...@@ -300,35 +297,36 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', ...@@ -300,35 +297,36 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
'maxTime': '05:00', 'maxTime': '05:00',
'forceRoundTime': false 'forceRoundTime': false
}); });
this.setExamType(this.model.get('is_time_limited'), this.model.get('is_proctored_exam'),
this.model.get('is_practice_exam'));
this.setExamTime(this.model.get('default_time_limit_minutes')); 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'));
this.setPracticeExam(this.model.get('is_practice_exam'));
},
setPracticeExam: function(value) {
this.$('#id_practice_exam').prop('checked', value);
}, },
setExamProctoring: function(value) { setExamType: function(is_time_limited, is_proctored_exam, is_practice_exam) {
this.$('#id_exam_proctoring').prop('checked', value); if (!is_time_limited) {
this.$("#id_not_timed").prop('checked', true);
return;
}
this.$('#id_time_limit_div').show();
if (this.options.enable_proctored_exams && is_proctored_exam) {
if (is_practice_exam) {
this.$('#id_practice_exam').prop('checked', true);
} else {
this.$('#id_proctored_exam').prop('checked', true);
}
} else {
// Since we have an early exit at the top of the method
// if the subsection is not time limited, then
// here we rightfully assume that it just a timed exam
this.$("#id_timed_exam").prop('checked', true);
}
}, },
setExamTime: function(value) { setExamTime: function(value) {
var time = this.convertTimeLimitMinutesToString(value); var time = this.convertTimeLimitMinutesToString(value);
this.$('#id_time_limit').val(time); 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');
this.$('#id_practice_exam').attr('disabled', 'disabled');
}
},
isExamTimeEnabled: function () {
return this.$('#id_timed_examination').is(':checked');
},
isPracticeExam: function () {
return this.$('#id_practice_exam').is(':checked');
},
isValidTimeLimit: function(time_limit) { isValidTimeLimit: function(time_limit) {
var pattern = new RegExp('^([0-9]|0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$'); var pattern = new RegExp('^([0-9]|0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$');
return pattern.test(time_limit) && time_limit !== "00:00"; return pattern.test(time_limit) && time_limit !== "00:00";
...@@ -348,16 +346,40 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', ...@@ -348,16 +346,40 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
var total_time = (parseInt(time[0]) * 60) + parseInt(time[1]); var total_time = (parseInt(time[0]) * 60) + parseInt(time[1]);
return total_time; return total_time;
}, },
isExamProctoringEnabled: function () {
return this.$('#id_exam_proctoring').is(':checked');
},
getRequestData: function () { getRequestData: function () {
var is_time_limited;
var is_practice_exam;
var is_proctored_exam;
var time_limit = this.getExamTimeLimit(); var time_limit = this.getExamTimeLimit();
if (this.$("#id_not_timed").is(':checked')){
is_time_limited = false;
is_practice_exam = false;
is_proctored_exam = false;
} else if (this.$("#id_timed_exam").is(':checked')){
is_time_limited = true;
is_practice_exam = false;
is_proctored_exam = false;
} else if (this.$("#id_proctored_exam").is(':checked')){
is_time_limited = true;
is_practice_exam = false;
is_proctored_exam = true;
} else if (this.$("#id_practice_exam").is(':checked')){
is_time_limited = true;
is_practice_exam = true;
is_proctored_exam = true;
}
return { return {
metadata: { metadata: {
'is_practice_exam': this.isPracticeExam(), 'is_practice_exam': is_practice_exam,
'is_time_limited': this.isExamTimeEnabled(), 'is_time_limited': is_time_limited,
'is_proctored_enabled': this.isExamProctoringEnabled(), // We have to use the legacy field name
// as the Ajax handler directly populates
// the xBlocks fields. We will have to
// update this call site when we migrate
// seq_module.py to use 'is_proctored_exam'
'is_proctored_enabled': is_proctored_exam,
'default_time_limit_minutes': this.convertTimeLimitToMinutes(time_limit) 'default_time_limit_minutes': this.convertTimeLimitToMinutes(time_limit)
} }
}; };
...@@ -568,10 +590,8 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', ...@@ -568,10 +590,8 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
} else if (xblockInfo.isSequential()) { } else if (xblockInfo.isSequential()) {
editors = [ReleaseDateEditor, GradingEditor, DueDateEditor]; editors = [ReleaseDateEditor, GradingEditor, DueDateEditor];
// since timed/proctored exams are optional var enable_special_exams = (options.enable_proctored_exams || options.enable_timed_exams);
// we want it before the StaffLockEditor if (enable_special_exams) {
// to keep it closer to the GradingEditor
if (options.enable_proctored_exams) {
editors.push(TimedExaminationPreferenceEditor); editors.push(TimedExaminationPreferenceEditor);
} }
......
...@@ -529,7 +529,7 @@ ...@@ -529,7 +529,7 @@
.wrapper-modal-window-bulkpublish-unit, .wrapper-modal-window-bulkpublish-unit,
.course-outline-modal { .course-outline-modal {
.exam-time-list-fields { .exam-time-list-fields {
margin-bottom: ($baseline/2); margin: 0 0 ($baseline/2) ($baseline/2);
} }
.list-fields { .list-fields {
.field-message { .field-message {
...@@ -688,12 +688,6 @@ ...@@ -688,12 +688,6 @@
// give a little space between the sections // give a little space between the sections
padding-bottom: 10px; 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 { .checkbox-cosmetic .input-checkbox {
@extend %cont-text-sr; @extend %cont-text-sr;
......
...@@ -30,10 +30,23 @@ if (statusType === 'warning') { ...@@ -30,10 +30,23 @@ if (statusType === 'warning') {
statusIconClass = 'fa-lock'; statusIconClass = 'fa-lock';
} }
var gradingType = gettext('Not Graded'); var gradingType = gettext('Ungraded');
if (xblockInfo.get('graded')) { if (xblockInfo.get('graded')) {
gradingType = xblockInfo.get('format') gradingType = xblockInfo.get('format')
} }
var is_proctored_exam = xblockInfo.get('is_proctored_exam');
var is_practice_exam = xblockInfo.get('is_practice_exam');
if (is_proctored_exam) {
if (is_practice_exam) {
var exam_value = gettext('Practice proctored Exam');
} else {
var exam_value = gettext('Proctored Exam');
}
} else {
var exam_value = gettext('Timed Exam');
}
%> %>
<% if (parentInfo) { %> <% if (parentInfo) { %>
<li class="outline-item outline-<%= xblockType %> <%= visibilityClass %> is-draggable <%= includesChildren ? 'is-collapsible' : '' %> <%= isCollapsed ? 'is-collapsed' : '' %>" <li class="outline-item outline-<%= xblockType %> <%= visibilityClass %> is-draggable <%= includesChildren ? 'is-collapsible' : '' %> <%= isCollapsed ? 'is-collapsed' : '' %>"
...@@ -132,26 +145,28 @@ if (xblockInfo.get('graded')) { ...@@ -132,26 +145,28 @@ if (xblockInfo.get('graded')) {
</p> </p>
</div> </div>
<% } %> <% } %>
<% if (xblockInfo.get('due_date') || xblockInfo.get('graded')) { %> <% if (xblockInfo.get('is_time_limited')) { %>
<div class="status-grading"> <div class="status-timed-proctored-exam">
<p> <p>
<span class="sr status-grading-label"> <%= gettext('Graded as:') %> </span> <span class="sr status-grading-label"> <%= gettext('Graded as:') %> </span>
<i class="icon fa fa-check"></i> <i class="icon fa fa-check"></i>
<span class="status-grading-value"> <%= gradingType %> </span> <span class="status-grading-value"> <%= gradingType %> </span>
-
<span class="sr status-proctored-exam-label"> <%- exam_value %> </span>
<span class="status-proctored-exam-value"> <%- exam_value %> </span>
<% if (xblockInfo.get('due_date')) { %> <% if (xblockInfo.get('due_date')) { %>
<span class="status-grading-date"> <%= gettext('Due:') %> <%= xblockInfo.get('due_date') %> </span> <span class="status-grading-date"> <%= gettext('Due:') %> <%= xblockInfo.get('due_date') %> </span>
<% } %> <% } %>
</p> </p>
</div> </div>
<% } %> <% } else if (xblockInfo.get('due_date') || xblockInfo.get('graded')) { %>
<% if (xblockInfo.get('is_time_limited')) { %> <div class="status-grading">
<div class="status-timed-proctored-exam">
<p> <p>
<span class="sr status-proctored-exam-label"> <%= gettext('Proctored Exam') %> </span> <span class="sr status-grading-label"> <%= gettext('Graded as:') %> </span>
<i class="icon fa fa-check"></i> <i class="icon fa fa-check"></i>
<span class="status-proctored-exam-value"> <%= gettext('Timed and Proctored Exam') %> </span> <span class="status-grading-value"> <%= gradingType %> </span>
<% if (xblockInfo.get('due_date')) { %> <% if (xblockInfo.get('due_date')) { %>
<span class="status-due-date"> <%= gettext('Due Date') %> <%= xblockInfo.get('due_date') %> </span> <span class="status-grading-date"> <%= gettext('Due:') %> <%= xblockInfo.get('due_date') %> </span>
<% } %> <% } %>
</p> </p>
</div> </div>
......
<form> <form>
<h3 class="modal-section-title"><%- gettext('Timed Exam') %></h3> <h3 class="modal-section-title"><%- gettext('Additional Options:') %></h3>
<div class="modal-section-content has-actions"> <div class="modal-section-content has-actions">
<div class='exam-time-list-fields'> <div class='exam-time-list-fields'>
<ul class="list-fields list-input"> <ul class="list-fields list-input">
<li class="field field-checkbox checkbox-cosmetic"> <li class="field-radio">
<input type="checkbox" id="id_timed_examination" name="timed_examination" class="input input-checkbox" /> <input type="radio" id="id_not_timed" name="proctored" class="input input-radio" checked="checked"/>
<label for="id_timed_examination" class="label"> <label for="id_not_timed" class="label">
<i class="icon fa fa-check-square-o input-checkbox-checked"></i> <%- gettext('None') %>
<i class="icon fa fa-square-o input-checkbox-unchecked"></i>
<%- gettext('This exam is timed') %>
</label> </label>
</li> </li>
</ul> </ul>
</div> </div>
<div class='exam-time-list-fields'> <div class='exam-time-list-fields'>
<ul class="list-fields list-input">
<li class="field-radio">
<input type="radio" id="id_timed_exam" name="proctored" class="input input-radio" />
<label for="id_timed_exam" class="label" aria-describedby="timed-exam-description">
<%- gettext('Timed') %>
</label>
</li>
<p class='field-message' id='timed-exam-description'> <%- gettext('Use a timed exam to limit the time learners can spend on problems in this subsection. Learners must submit answers before the time expires. You can allow additional time on per learner basis through the Instructor Dashboard.') %> </p>
</ul>
</div>
<% if (enable_proctored_exam) { %>
<div class='exam-time-list-fields'>
<ul class="list-fields list-input">
<li class="field-radio">
<input type="radio" id="id_proctored_exam" name="proctored" class="input input-radio" />
<label for="id_proctored_exam" class="label" aria-describedby="proctored-exam-description">
<%- gettext('Proctored') %>
</label>
</li>
<p class='field-message' id='proctored-exam-description'> <%- gettext('Proctored exams are timed, and software records video of each learner taking the exam. These videos are then reviewed by a third party.') %> </p>
</ul>
</div>
<div class='exam-time-list-fields'>
<ul class="list-fields list-input">
<li class="field-radio">
<input type="radio" id="id_practice_exam" name="proctored" class="input input-radio" />
<label for="id_practice_exam" class="label" aria-describedby="practice-exam-description">
<%- gettext('Practice Proctored') %>
</label>
</li>
<p class='field-message' id='practice-exam-description'> <%- gettext("Use a practice proctored exam to introduce learners to the proctoring tools and processes. Results of the practice exam do not count towards the learner's grade.") %> </p>
</ul>
</div>
<% } %>
<div class='exam-time-list-fields is-hidden' id='id_time_limit_div'>
<ul class="list-fields list-input time-limit"> <ul class="list-fields list-input time-limit">
<li class="field field-text field-time-limit"> <li class="field field-text field-time-limit">
<label for="id_time_limit" class="label"><%- gettext('Time Allotted (HH:MM):') %></label> <label for="id_time_limit" class="label"><%- gettext('Time Allotted (HH:MM):') %> </label>
<input type="text" id="id_time_limit" name="time_limit" <input type="text" id="id_time_limit" name="time_limit"
value="" value="" aria-describedby="time-limit-description"
placeholder="HH:MM" class="time_limit release-time time input input-text" autocomplete="off" /> placeholder="HH:MM" class="time_limit release-time time input input-text" autocomplete="off" />
</li> </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> <p class='field-message' id='time-limit-description'><%- gettext('Learners see warnings when 20% and 5% of the allotted time remains. You can grant learners extra time to complete the exam through the Instructor Dashboard.') %></p>
</ul> </ul>
</div> </div>
<ul class="list-fields list-input">
<li class="field field-checkbox checkbox-cosmetic">
<input type="checkbox" id="id_practice_exam" name="practice_exam" class="input input-checkbox" />
<label for="id_practice_exam" 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 for practice only') %>
</label>
</li>
<p class='field-message'> <%- gettext('Learners can experience the proctoring software setup process and try some example problems. Make sure this practice exam is set up as an ungraded exam.') %> </p>
</ul>
<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> </div>
</form> </form>
...@@ -912,7 +912,17 @@ class CourseFields(object): ...@@ -912,7 +912,17 @@ class CourseFields(object):
enable_proctored_exams = Boolean( enable_proctored_exams = Boolean(
display_name=_("Enable Proctored Exams"), display_name=_("Enable Proctored Exams"),
help=_( help=_(
"Enter true or false. If this value is true, timed and proctored exams are enabled in your course." "Enter true or false. If this value is true, proctored exams are enabled in your course. "
"Note that enabling proctored exams will also enable timed exams."
),
default=False,
scope=Scope.settings
)
enable_timed_exams = Boolean(
display_name=_("Enable Timed Exams"),
help=_(
"Enter true or false. If this value is true, timed exams are enabled in your course."
), ),
default=False, default=False,
scope=Scope.settings scope=Scope.settings
......
...@@ -97,6 +97,16 @@ class ProctoringFields(object): ...@@ -97,6 +97,16 @@ class ProctoringFields(object):
scope=Scope.settings, scope=Scope.settings,
) )
@property
def is_proctored_exam(self):
""" Alias the is_proctored_enabled field to the more legible is_proctored_exam """
return self.is_proctored_enabled
@is_proctored_exam.setter
def is_proctored_exam(self, value):
""" Alias the is_proctored_enabled field to the more legible is_proctored_exam """
self.is_proctored_enabled = value
@XBlock.wants('proctoring') @XBlock.wants('proctoring')
@XBlock.wants('credit') @XBlock.wants('credit')
...@@ -221,14 +231,12 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): ...@@ -221,14 +231,12 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
proctoring_service = self.runtime.service(self, 'proctoring') proctoring_service = self.runtime.service(self, 'proctoring')
credit_service = self.runtime.service(self, 'credit') credit_service = self.runtime.service(self, 'credit')
# Is the feature turned on and do we have all required services # Is this sequence designated as a Timed Examination, which includes
# Also, the ENABLE_PROCTORED_EXAMS feature flag must be set to # Proctored Exams
# True and the Sequence in question, should have the
# fields set to indicate this is a timed/proctored exam
feature_enabled = ( feature_enabled = (
proctoring_service and proctoring_service and
credit_service and credit_service and
proctoring_service.is_feature_enabled() self.is_time_limited
) )
if feature_enabled: if feature_enabled:
user_id = self.runtime.user_id user_id = self.runtime.user_id
...@@ -242,7 +250,8 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): ...@@ -242,7 +250,8 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
self.default_time_limit_minutes if self.default_time_limit_minutes if
self.default_time_limit_minutes else 0 self.default_time_limit_minutes else 0
), ),
'is_practice_exam': self.is_practice_exam 'is_practice_exam': self.is_practice_exam,
'due_date': self.due
} }
# inject the user's credit requirements and fulfillments # inject the user's credit requirements and fulfillments
......
...@@ -118,6 +118,16 @@ class CoursewarePage(CoursePage): ...@@ -118,6 +118,16 @@ class CoursewarePage(CoursePage):
self.q(css=".xblock-student_view .timed-exam .start-timed-exam").first.click() self.q(css=".xblock-student_view .timed-exam .start-timed-exam").first.click()
self.wait_for_element_presence(".proctored_exam_status .exam-timer", "Timer bar") self.wait_for_element_presence(".proctored_exam_status .exam-timer", "Timer bar")
def stop_timed_exam(self):
"""
clicks the stop this timed exam link
"""
self.q(css=".proctored_exam_status button.exam-button-turn-in-exam").first.click()
self.wait_for_element_absence(".proctored_exam_status .exam-button-turn-in-exam", "End Exam Button gone")
self.wait_for_element_presence("button[name='submit-proctored-exam']", "Submit Exam Button")
self.q(css="button[name='submit-proctored-exam']").first.click()
self.wait_for_element_absence(".proctored_exam_status .exam-timer", "Timer bar")
def start_proctored_exam(self): def start_proctored_exam(self):
""" """
clicks the start this timed exam link clicks the start this timed exam link
......
...@@ -66,14 +66,14 @@ class InstructorDashboardPage(CoursePage): ...@@ -66,14 +66,14 @@ class InstructorDashboardPage(CoursePage):
certificates_section.wait_for_page() certificates_section.wait_for_page()
return certificates_section return certificates_section
def select_proctoring(self): def select_special_exams(self):
""" """
Selects the proctoring tab and returns the ProctoringSection Selects the timed exam tab and returns the Special Exams Section
""" """
self.q(css='a[data-section=proctoring]').first.click() self.q(css='a[data-section=special_exams]').first.click()
proctoring_section = ProctoringPage(self.browser) timed_exam_section = SpecialExamsPage(self.browser)
proctoring_section.wait_for_page() timed_exam_section.wait_for_page()
return proctoring_section return timed_exam_section
@staticmethod @staticmethod
def get_asset_path(file_name): def get_asset_path(file_name):
...@@ -114,20 +114,20 @@ class MembershipPage(PageObject): ...@@ -114,20 +114,20 @@ class MembershipPage(PageObject):
return MembershipPageAutoEnrollSection(self.browser) return MembershipPageAutoEnrollSection(self.browser)
class ProctoringPage(PageObject): class SpecialExamsPage(PageObject):
""" """
Proctoring section of the Instructor dashboard. Timed exam section of the Instructor dashboard.
""" """
url = None url = None
def is_browser_on_page(self): def is_browser_on_page(self):
return self.q(css='a[data-section=proctoring].active-section').present return self.q(css='a[data-section=special_exams].active-section').present
def select_allowance_section(self): def select_allowance_section(self):
""" """
Expand the allowance section Expand the allowance section
""" """
allowance_section = ProctoringPageAllowanceSection(self.browser) allowance_section = SpecialExamsPageAllowanceSection(self.browser)
if not self.q(css="div.wrap #ui-accordion-proctoring-accordion-header-0[aria-selected=true]").present: if not self.q(css="div.wrap #ui-accordion-proctoring-accordion-header-0[aria-selected=true]").present:
self.q(css="div.wrap #ui-accordion-proctoring-accordion-header-0").click() self.q(css="div.wrap #ui-accordion-proctoring-accordion-header-0").click()
self.wait_for_element_presence("div.wrap #ui-accordion-proctoring-accordion-header-0[aria-selected=true]", self.wait_for_element_presence("div.wrap #ui-accordion-proctoring-accordion-header-0[aria-selected=true]",
...@@ -139,7 +139,7 @@ class ProctoringPage(PageObject): ...@@ -139,7 +139,7 @@ class ProctoringPage(PageObject):
""" """
Expand the Student Attempts Section Expand the Student Attempts Section
""" """
exam_attempts_section = ProctoringPageAttemptsSection(self.browser) exam_attempts_section = SpecialExamsPageAttemptsSection(self.browser)
if not self.q(css="div.wrap #ui-accordion-proctoring-accordion-header-1[aria-selected=true]").present: if not self.q(css="div.wrap #ui-accordion-proctoring-accordion-header-1[aria-selected=true]").present:
self.q(css="div.wrap #ui-accordion-proctoring-accordion-header-1").click() self.q(css="div.wrap #ui-accordion-proctoring-accordion-header-1").click()
self.wait_for_element_presence("div.wrap #ui-accordion-proctoring-accordion-header-1[aria-selected=true]", self.wait_for_element_presence("div.wrap #ui-accordion-proctoring-accordion-header-1[aria-selected=true]",
...@@ -751,9 +751,9 @@ class MembershipPageAutoEnrollSection(PageObject): ...@@ -751,9 +751,9 @@ class MembershipPageAutoEnrollSection(PageObject):
self.click_upload_file_button() self.click_upload_file_button()
class ProctoringPageAllowanceSection(PageObject): class SpecialExamsPageAllowanceSection(PageObject):
""" """
Allowance section of the Instructor dashboard's Proctoring tab. Allowance section of the Instructor dashboard's Special Exams tab.
""" """
url = None url = None
...@@ -768,14 +768,15 @@ class ProctoringPageAllowanceSection(PageObject): ...@@ -768,14 +768,15 @@ class ProctoringPageAllowanceSection(PageObject):
return self.q(css="a#add-allowance").present return self.q(css="a#add-allowance").present
class ProctoringPageAttemptsSection(PageObject): class SpecialExamsPageAttemptsSection(PageObject):
""" """
Exam Attempts section of the Instructor dashboard's Proctoring tab. Exam Attempts section of the Instructor dashboard's Special Exams tab.
""" """
url = None url = None
def is_browser_on_page(self): def is_browser_on_page(self):
return self.q(css="div.wrap #ui-accordion-proctoring-accordion-header-1[aria-selected=true]").present return self.q(css="div.wrap #ui-accordion-proctoring-accordion-header-1[aria-selected=true]").present and\
self.q(css="#search_attempt_id").present
@property @property
def is_search_text_field_visible(self): def is_search_text_field_visible(self):
......
...@@ -534,8 +534,7 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer): ...@@ -534,8 +534,7 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
""" """
Makes a Proctored exam. Makes a Proctored exam.
""" """
self.q(css="#id_timed_examination").first.click() self.q(css="#id_proctored_exam").first.click()
self.q(css="#id_exam_proctoring").first.click()
self.q(css=".action-save").first.click() self.q(css=".action-save").first.click()
self.wait_for_ajax() self.wait_for_ajax()
...@@ -543,28 +542,59 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer): ...@@ -543,28 +542,59 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
""" """
Makes a timed exam. Makes a timed exam.
""" """
self.q(css="#id_timed_examination").first.click() self.q(css="#id_timed_exam").first.click()
self.q(css=".action-save").first.click() self.q(css=".action-save").first.click()
self.wait_for_ajax() self.wait_for_ajax()
def select_none_exam(self):
"""
Choose "none" exam but do not press enter
"""
self.q(css="#id_not_timed").first.click()
def select_timed_exam(self):
"""
Choose a timed exam but do not press enter
"""
self.q(css="#id_timed_exam").first.click()
def select_proctored_exam(self):
"""
Choose a proctored exam but do not press enter
"""
self.q(css="#id_proctored_exam").first.click()
def select_practice_exam(self):
"""
Choose a practice exam but do not press enter
"""
self.q(css="#id_practice_exam").first.click()
def time_allotted_field_visible(self):
"""
returns whether the time allotted field is visible
"""
return self.q(css="#id_time_limit_div").visible
def proctoring_items_are_displayed(self): def proctoring_items_are_displayed(self):
""" """
Returns True if all the items are found. Returns True if all the items are found.
""" """
# The Timed exam checkbox
if not self.q(css="#id_timed_examination").present: # The None radio button
if not self.q(css="#id_not_timed").present:
return False return False
# The time limit field # The Timed exam radio button
if not self.q(css="#id_time_limit").present: if not self.q(css="#id_timed_exam").present:
return False return False
# The Practice exam checkbox # The Proctored exam radio button
if not self.q(css="#id_practice_exam").present: if not self.q(css="#id_proctored_exam").present:
return False return False
# The Proctored exam checkbox # The Practice exam radio button
if not self.q(css="#id_exam_proctoring").present: if not self.q(css="#id_practice_exam").present:
return False return False
return True return True
......
...@@ -218,4 +218,5 @@ class AdvancedSettingsPage(CoursePage): ...@@ -218,4 +218,5 @@ class AdvancedSettingsPage(CoursePage):
'video_bumper', 'video_bumper',
'cert_html_view_enabled', 'cert_html_view_enabled',
'enable_proctored_exams', 'enable_proctored_exams',
'enable_timed_exams',
] ]
...@@ -253,6 +253,86 @@ class ProctoredExamTest(UniqueCourseTest): ...@@ -253,6 +253,86 @@ class ProctoredExamTest(UniqueCourseTest):
# Then I am taken to the exam with a timer bar showing # Then I am taken to the exam with a timer bar showing
self.assertTrue(self.courseware_page.is_timer_bar_present) self.assertTrue(self.courseware_page.is_timer_bar_present)
def test_time_allotted_field_is_not_visible_with_none_exam(self):
"""
Test that the time allotted text field is not shown if 'none' radio
button is selected
"""
# Given that I am a staff member
# And I have visited the course outline page in studio.
# And the subsection edit dialog is open
LogoutPage(self.browser).visit()
self._auto_auth("STAFF_TESTER", "staff101@example.com", True)
self.course_outline.visit()
self.course_outline.open_exam_settings_dialog()
# When I select the 'None' exams radio button
self.course_outline.select_none_exam()
# Then the time allotted text field becomes invisible
self.assertFalse(self.course_outline.time_allotted_field_visible())
def test_time_allotted_field_is_visible_with_timed_exam(self):
"""
Test that the time allotted text field is shown if timed exam radio
button is selected
"""
# Given that I am a staff member
# And I have visited the course outline page in studio.
# And the subsection edit dialog is open
LogoutPage(self.browser).visit()
self._auto_auth("STAFF_TESTER", "staff101@example.com", True)
self.course_outline.visit()
self.course_outline.open_exam_settings_dialog()
# When I select the timed exams radio button
self.course_outline.select_timed_exam()
# Then the time allotted text field becomes visible
self.assertTrue(self.course_outline.time_allotted_field_visible())
def test_time_allotted_field_is_visible_with_proctored_exam(self):
"""
Test that the time allotted text field is shown if proctored exam radio
button is selected
"""
# Given that I am a staff member
# And I have visited the course outline page in studio.
# And the subsection edit dialog is open
LogoutPage(self.browser).visit()
self._auto_auth("STAFF_TESTER", "staff101@example.com", True)
self.course_outline.visit()
self.course_outline.open_exam_settings_dialog()
# When I select the proctored exams radio button
self.course_outline.select_proctored_exam()
# Then the time allotted text field becomes visible
self.assertTrue(self.course_outline.time_allotted_field_visible())
def test_time_allotted_field_is_visible_with_practice_exam(self):
"""
Test that the time allotted text field is shown if practice exam radio
button is selected
"""
# Given that I am a staff member
# And I have visited the course outline page in studio.
# And the subsection edit dialog is open
LogoutPage(self.browser).visit()
self._auto_auth("STAFF_TESTER", "staff101@example.com", True)
self.course_outline.visit()
self.course_outline.open_exam_settings_dialog()
# When I select the practice exams radio button
self.course_outline.select_practice_exam()
# Then the time allotted text field becomes visible
self.assertTrue(self.course_outline.time_allotted_field_visible())
class CoursewareMultipleVerticalsTest(UniqueCourseTest): class CoursewareMultipleVerticalsTest(UniqueCourseTest):
""" """
......
...@@ -117,6 +117,7 @@ class AutoEnrollmentWithCSVTest(BaseInstructorDashboardTest): ...@@ -117,6 +117,7 @@ class AutoEnrollmentWithCSVTest(BaseInstructorDashboardTest):
self.assertEqual(self.auto_enroll_section.first_notification_message(section_type=self.auto_enroll_section.NOTIFICATION_ERROR), "Make sure that the file you upload is in CSV format with no extraneous characters or rows.") self.assertEqual(self.auto_enroll_section.first_notification_message(section_type=self.auto_enroll_section.NOTIFICATION_ERROR), "Make sure that the file you upload is in CSV format with no extraneous characters or rows.")
@attr('shard_1')
class ProctoredExamsTest(BaseInstructorDashboardTest): class ProctoredExamsTest(BaseInstructorDashboardTest):
""" """
End-to-end tests for Proctoring Sections of the Instructor Dashboard. End-to-end tests for Proctoring Sections of the Instructor Dashboard.
...@@ -169,12 +170,32 @@ class ProctoredExamsTest(BaseInstructorDashboardTest): ...@@ -169,12 +170,32 @@ class ProctoredExamsTest(BaseInstructorDashboardTest):
# Auto-auth register for the course. # Auto-auth register for the course.
self._auto_auth(self.USERNAME, self.EMAIL, False) self._auto_auth(self.USERNAME, self.EMAIL, False)
def _auto_auth(self, username, email, staff, enrollment_mode="honor"): def _auto_auth(self, username, email, staff):
""" """
Logout and login with given credentials. Logout and login with given credentials.
""" """
AutoAuthPage(self.browser, username=username, email=email, AutoAuthPage(self.browser, username=username, email=email,
course_id=self.course_id, staff=staff, enrollment_mode=enrollment_mode).visit() course_id=self.course_id, staff=staff).visit()
def _login_as_a_verified_user(self):
"""
login as a verififed user
"""
self._auto_auth(self.USERNAME, self.EMAIL, False)
# the track selection page cannot be visited. see the other tests to see if any prereq is there.
# Navigate to the track selection page
self.track_selection_page.visit()
# Enter the payment and verification flow by choosing to enroll as verified
self.track_selection_page.enroll('verified')
# Proceed to the fake payment page
self.payment_and_verification_flow.proceed_to_payment()
# Submit payment
self.fake_payment_page.submit_payment()
def _create_a_proctored_exam_and_attempt(self): def _create_a_proctored_exam_and_attempt(self):
""" """
...@@ -186,14 +207,13 @@ class ProctoredExamsTest(BaseInstructorDashboardTest): ...@@ -186,14 +207,13 @@ class ProctoredExamsTest(BaseInstructorDashboardTest):
self._auto_auth("STAFF_TESTER", "staff101@example.com", True) self._auto_auth("STAFF_TESTER", "staff101@example.com", True)
self.course_outline.visit() self.course_outline.visit()
#open the exam settings to make it a proctored exam. # open the exam settings to make it a proctored exam.
self.course_outline.open_exam_settings_dialog() self.course_outline.open_exam_settings_dialog()
self.course_outline.make_exam_proctored() self.course_outline.make_exam_proctored()
time.sleep(2) # Wait for 2 seconds to save the settings.
# login as a verified student and visit the courseware. # login as a verified student and visit the courseware.
LogoutPage(self.browser).visit() LogoutPage(self.browser).visit()
self._auto_auth(self.USERNAME, self.EMAIL, False, enrollment_mode="verified") self._login_as_a_verified_user()
self.courseware_page.visit() self.courseware_page.visit()
# Start the proctored exam. # Start the proctored exam.
...@@ -212,16 +232,18 @@ class ProctoredExamsTest(BaseInstructorDashboardTest): ...@@ -212,16 +232,18 @@ class ProctoredExamsTest(BaseInstructorDashboardTest):
# open the exam settings to make it a proctored exam. # open the exam settings to make it a proctored exam.
self.course_outline.open_exam_settings_dialog() self.course_outline.open_exam_settings_dialog()
self.course_outline.make_exam_timed() self.course_outline.make_exam_timed()
time.sleep(2) # Wait for 2 seconds to save the settings.
# login as a verified student and visit the courseware. # login as a verified student and visit the courseware.
LogoutPage(self.browser).visit() LogoutPage(self.browser).visit()
self._auto_auth(self.USERNAME, self.EMAIL, False, enrollment_mode="verified") self._login_as_a_verified_user()
self.courseware_page.visit() self.courseware_page.visit()
# Start the proctored exam. # Start the timed exam.
self.courseware_page.start_timed_exam() self.courseware_page.start_timed_exam()
# Stop the timed exam.
self.courseware_page.stop_timed_exam()
@flaky # TODO fix this SOL-1183 @flaky # TODO fix this SOL-1183
def test_can_add_remove_allowance(self): def test_can_add_remove_allowance(self):
""" """
...@@ -231,11 +253,11 @@ class ProctoredExamsTest(BaseInstructorDashboardTest): ...@@ -231,11 +253,11 @@ class ProctoredExamsTest(BaseInstructorDashboardTest):
self._create_a_timed_exam_and_attempt() self._create_a_timed_exam_and_attempt()
# When I log in as an instructor, # When I log in as an instructor,
self.log_in_as_instructor() __, __ = self.log_in_as_instructor()
# And visit the Allowance Section of Instructor Dashboard's Proctoring tab # And visit the Allowance Section of Instructor Dashboard's Special Exams tab
instructor_dashboard_page = self.visit_instructor_dashboard() instructor_dashboard_page = self.visit_instructor_dashboard()
allowance_section = instructor_dashboard_page.select_proctoring().select_allowance_section() allowance_section = instructor_dashboard_page.select_special_exams().select_allowance_section()
# Then I can add Allowance to that exam for a student # Then I can add Allowance to that exam for a student
self.assertTrue(allowance_section.is_add_allowance_button_visible) self.assertTrue(allowance_section.is_add_allowance_button_visible)
...@@ -244,16 +266,15 @@ class ProctoredExamsTest(BaseInstructorDashboardTest): ...@@ -244,16 +266,15 @@ class ProctoredExamsTest(BaseInstructorDashboardTest):
""" """
Make sure that Exam attempts are visible and can be reset. Make sure that Exam attempts are visible and can be reset.
""" """
# Given that an exam has been configured to be a proctored exam. # Given that an exam has been configured to be a proctored exam.
self._create_a_timed_exam_and_attempt() self._create_a_timed_exam_and_attempt()
# When I log in as an instructor, # When I log in as an instructor,
self.log_in_as_instructor() __, __ = self.log_in_as_instructor()
# And visit the Student Proctored Exam Attempts Section of Instructor Dashboard's Proctoring tab # And visit the Student Proctored Exam Attempts Section of Instructor Dashboard's Special Exams tab
instructor_dashboard_page = self.visit_instructor_dashboard() instructor_dashboard_page = self.visit_instructor_dashboard()
exam_attempts_section = instructor_dashboard_page.select_proctoring().select_exam_attempts_section() exam_attempts_section = instructor_dashboard_page.select_special_exams().select_exam_attempts_section()
# Then I can see the search text field # Then I can see the search text field
self.assertTrue(exam_attempts_section.is_search_text_field_visible) self.assertTrue(exam_attempts_section.is_search_text_field_visible)
......
...@@ -193,14 +193,14 @@ def toc_for_course(user, request, course, active_chapter, active_section, field_ ...@@ -193,14 +193,14 @@ def toc_for_course(user, request, course, active_chapter, active_section, field_
} }
# #
# Add in rendering context for proctored exams # Add in rendering context if exam is a timed exam (which includes proctored)
# if applicable
# #
is_proctored_enabled = (
getattr(section, 'is_proctored_enabled', False) and section_is_time_limited = (
settings.FEATURES.get('ENABLE_PROCTORED_EXAMS', False) getattr(section, 'is_time_limited', False) and
settings.FEATURES.get('ENABLE_SPECIAL_EXAMS', False)
) )
if is_proctored_enabled: if section_is_time_limited:
# We need to import this here otherwise Lettuce test # We need to import this here otherwise Lettuce test
# harness fails. When running in 'harvest' mode, the # harness fails. When running in 'harvest' mode, the
# test service appears to get into trouble with # test service appears to get into trouble with
...@@ -223,9 +223,9 @@ def toc_for_course(user, request, course, active_chapter, active_section, field_ ...@@ -223,9 +223,9 @@ def toc_for_course(user, request, course, active_chapter, active_section, field_
# This will return None, if (user, course_id, content_id) # This will return None, if (user, course_id, content_id)
# is not applicable # is not applicable
# #
proctoring_attempt_context = None timed_exam_attempt_context = None
try: try:
proctoring_attempt_context = get_attempt_status_summary( timed_exam_attempt_context = get_attempt_status_summary(
user.id, user.id,
unicode(course.id), unicode(course.id),
unicode(section.location) unicode(section.location)
...@@ -237,12 +237,12 @@ def toc_for_course(user, request, course, active_chapter, active_section, field_ ...@@ -237,12 +237,12 @@ def toc_for_course(user, request, course, active_chapter, active_section, field_
# unhandled exception # unhandled exception
log.exception(ex) log.exception(ex)
if proctoring_attempt_context: if timed_exam_attempt_context:
# yes, user has proctoring context about # yes, user has proctoring context about
# this level of the courseware # this level of the courseware
# so add to the accordion data context # so add to the accordion data context
section_context.update({ section_context.update({
'proctoring': proctoring_attempt_context, 'proctoring': timed_exam_attempt_context,
}) })
sections.append(section_context) sections.append(section_context)
......
...@@ -699,7 +699,7 @@ class TestTOC(ModuleStoreTestCase): ...@@ -699,7 +699,7 @@ class TestTOC(ModuleStoreTestCase):
@attr('shard_1') @attr('shard_1')
@ddt.ddt @ddt.ddt
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_PROCTORED_EXAMS': True}) @patch.dict('django.conf.settings.FEATURES', {'ENABLE_SPECIAL_EXAMS': True})
class TestProctoringRendering(ModuleStoreTestCase): class TestProctoringRendering(ModuleStoreTestCase):
"""Check the Table of Contents for a course""" """Check the Table of Contents for a course"""
def setUp(self): def setUp(self):
...@@ -963,7 +963,7 @@ class TestProctoringRendering(ModuleStoreTestCase): ...@@ -963,7 +963,7 @@ class TestProctoringRendering(ModuleStoreTestCase):
sequence = self.modulestore.get_item(usage_key) sequence = self.modulestore.get_item(usage_key)
sequence.is_time_limited = True sequence.is_time_limited = True
sequence.is_proctored_enabled = True sequence.is_proctored_exam = True
sequence.is_practice_exam = is_practice_exam sequence.is_practice_exam = is_practice_exam
self.modulestore.update_item(sequence, self.user.id) self.modulestore.update_item(sequence, self.user.id)
......
...@@ -281,7 +281,7 @@ class TestNavigation(ModuleStoreTestCase, LoginEnrollmentTestCase): ...@@ -281,7 +281,7 @@ class TestNavigation(ModuleStoreTestCase, LoginEnrollmentTestCase):
test_course_id = self.test_course.id.to_deprecated_string() test_course_id = self.test_course.id.to_deprecated_string()
with patch.dict(settings.FEATURES, {'ENABLE_PROCTORED_EXAMS': False}): with patch.dict(settings.FEATURES, {'ENABLE_SPECIAL_EXAMS': False}):
url = reverse( url = reverse(
'courseware', 'courseware',
kwargs={'course_id': test_course_id} kwargs={'course_id': test_course_id}
...@@ -290,7 +290,7 @@ class TestNavigation(ModuleStoreTestCase, LoginEnrollmentTestCase): ...@@ -290,7 +290,7 @@ class TestNavigation(ModuleStoreTestCase, LoginEnrollmentTestCase):
self.assertNotContains(resp, '/static/js/lms-proctoring.js') self.assertNotContains(resp, '/static/js/lms-proctoring.js')
with patch.dict(settings.FEATURES, {'ENABLE_PROCTORED_EXAMS': True}): with patch.dict(settings.FEATURES, {'ENABLE_SPECIAL_EXAMS': True}):
url = reverse( url = reverse(
'courseware', 'courseware',
kwargs={'course_id': test_course_id} kwargs={'course_id': test_course_id}
......
...@@ -11,6 +11,10 @@ from instructor.views.tools import get_student_from_identifier ...@@ -11,6 +11,10 @@ from instructor.views.tools import get_student_from_identifier
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
import instructor.enrollment as enrollment import instructor.enrollment as enrollment
from student.roles import CourseStaffRole
from student import auth
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -69,3 +73,10 @@ class InstructorService(object): ...@@ -69,3 +73,10 @@ class InstructorService(object):
) )
) )
log.error(err_msg) log.error(err_msg)
def is_course_staff(self, user, course_id):
"""
Returns True if the user is the course staff
else Returns False
"""
return auth.user_has_role(user, CourseStaffRole(CourseKey.from_string(course_id)))
...@@ -15,7 +15,7 @@ from xmodule.modulestore.tests.factories import CourseFactory ...@@ -15,7 +15,7 @@ from xmodule.modulestore.tests.factories import CourseFactory
@attr('shard_1') @attr('shard_1')
@patch.dict(settings.FEATURES, {'ENABLE_PROCTORED_EXAMS': True}) @patch.dict(settings.FEATURES, {'ENABLE_SPECIAL_EXAMS': True})
class TestProctoringDashboardViews(SharedModuleStoreTestCase): class TestProctoringDashboardViews(SharedModuleStoreTestCase):
""" """
Check for Proctoring view on the new instructor dashboard Check for Proctoring view on the new instructor dashboard
...@@ -27,7 +27,7 @@ class TestProctoringDashboardViews(SharedModuleStoreTestCase): ...@@ -27,7 +27,7 @@ class TestProctoringDashboardViews(SharedModuleStoreTestCase):
# URL for instructor dash # URL for instructor dash
cls.url = reverse('instructor_dashboard', kwargs={'course_id': cls.course.id.to_deprecated_string()}) cls.url = reverse('instructor_dashboard', kwargs={'course_id': cls.course.id.to_deprecated_string()})
cls.proctoring_link = '<a href="" data-section="proctoring">Proctoring</a>' cls.proctoring_link = '<a href="" data-section="special_exams">Special Exams</a>'
def setUp(self): def setUp(self):
super(TestProctoringDashboardViews, self).setUp() super(TestProctoringDashboardViews, self).setUp()
...@@ -61,11 +61,11 @@ class TestProctoringDashboardViews(SharedModuleStoreTestCase): ...@@ -61,11 +61,11 @@ class TestProctoringDashboardViews(SharedModuleStoreTestCase):
self.assertFalse(self.proctoring_link in response.content) self.assertFalse(self.proctoring_link in response.content)
self.assertFalse('Allowance Section' in response.content) self.assertFalse('Allowance Section' in response.content)
@patch.dict(settings.FEATURES, {'ENABLE_PROCTORED_EXAMS': False}) @patch.dict(settings.FEATURES, {'ENABLE_SPECIAL_EXAMS': False})
def test_no_tab_flag_unset(self): def test_no_tab_flag_unset(self):
""" """
Test Pass Proctoring Tab is not in the Instructor Dashboard Special Exams tab will not be visible if
if the feature flag 'ENABLE_PROCTORED_EXAMS' is unset. the user is not a staff member.
""" """
self.instructor.is_staff = True self.instructor.is_staff = True
self.instructor.save() self.instructor.save()
......
...@@ -6,6 +6,7 @@ import json ...@@ -6,6 +6,7 @@ import json
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from courseware.models import StudentModule from courseware.models import StudentModule
from instructor.access import allow_access
from instructor.services import InstructorService from instructor.services import InstructorService
from instructor.tests.test_tools import msk_from_problem_urlname from instructor.tests.test_tools import msk_from_problem_urlname
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
...@@ -114,3 +115,21 @@ class InstructorServiceTests(SharedModuleStoreTestCase): ...@@ -114,3 +115,21 @@ class InstructorServiceTests(SharedModuleStoreTestCase):
self.other_problem_urlname self.other_problem_urlname
) )
self.assertIsNone(result) self.assertIsNone(result)
def test_is_user_staff(self):
"""
Test to assert that the usrr is staff or not
"""
result = self.service.is_course_staff(
self.student,
unicode(self.course.id)
)
self.assertFalse(result)
# allow staff access to the student
allow_access(self.course, self.student, 'staff')
result = self.service.is_course_staff(
self.student,
unicode(self.course.id)
)
self.assertTrue(result)
...@@ -142,15 +142,18 @@ def instructor_dashboard_2(request, course_id): ...@@ -142,15 +142,18 @@ def instructor_dashboard_2(request, course_id):
if course_mode_has_price and (access['finance_admin'] or access['sales_admin']): if course_mode_has_price and (access['finance_admin'] or access['sales_admin']):
sections.append(_section_e_commerce(course, access, paid_modes[0], is_white_label, is_white_label)) sections.append(_section_e_commerce(course, access, paid_modes[0], is_white_label, is_white_label))
# Gate access to Proctoring tab # Gate access to Special Exam tab depending if either timed exams or proctored exams
# only global staff (user.is_staff) is allowed to see this tab # are enabled in the course
can_see_proctoring = (
settings.FEATURES.get('ENABLE_PROCTORED_EXAMS', False) and # NOTE: For now, if we only have procotred exams enabled, then only platform Staff
course.enable_proctored_exams and # (user.is_staff) will be able to view the special exams tab. This may
request.user.is_staff # change in the future
can_see_special_exams = (
((course.enable_proctored_exams and request.user.is_staff) or course.enable_timed_exams) and
settings.FEATURES.get('ENABLE_SPECIAL_EXAMS', False)
) )
if can_see_proctoring: if can_see_special_exams:
sections.append(_section_proctoring(course, access)) sections.append(_section_special_exams(course, access))
# Certificates panel # Certificates panel
# This is used to generate example certificates # This is used to generate example certificates
...@@ -232,13 +235,13 @@ def _section_e_commerce(course, access, paid_mode, coupons_enabled, reports_enab ...@@ -232,13 +235,13 @@ def _section_e_commerce(course, access, paid_mode, coupons_enabled, reports_enab
return section_data return section_data
def _section_proctoring(course, access): def _section_special_exams(course, access):
""" Provide data for the corresponding dashboard section """ """ Provide data for the corresponding dashboard section """
course_key = course.id course_key = course.id
section_data = { section_data = {
'section_key': 'proctoring', 'section_key': 'special_exams',
'section_display_name': _('Proctoring'), 'section_display_name': _('Special Exams'),
'access': access, 'access': access,
'course_id': unicode(course_key) 'course_id': unicode(course_key)
} }
...@@ -491,7 +494,7 @@ def _section_data_download(course, access): ...@@ -491,7 +494,7 @@ def _section_data_download(course, access):
course_key = course.id course_key = course.id
show_proctored_report_button = ( show_proctored_report_button = (
settings.FEATURES.get('ENABLE_PROCTORED_EXAMS', False) and settings.FEATURES.get('ENABLE_SPECIAL_EXAMS', False) and
course.enable_proctored_exams course.enable_proctored_exams
) )
......
...@@ -122,7 +122,7 @@ FEATURES['ENABLE_PAYMENT_FAKE'] = True ...@@ -122,7 +122,7 @@ FEATURES['ENABLE_PAYMENT_FAKE'] = True
FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True
FEATURES['REQUIRE_COURSE_EMAIL_AUTH'] = False FEATURES['REQUIRE_COURSE_EMAIL_AUTH'] = False
FEATURES['ENABLE_PROCTORED_EXAMS'] = True FEATURES['ENABLE_SPECIAL_EXAMS'] = True
# Don't actually send any requests to Software Secure for student identity # Don't actually send any requests to Software Secure for student identity
# verification. # verification.
......
...@@ -90,7 +90,7 @@ ...@@ -90,7 +90,7 @@
"MODE_CREATION_FOR_TESTING": true, "MODE_CREATION_FOR_TESTING": true,
"AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING": true, "AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING": true,
"ENABLE_COURSE_DISCOVERY": true, "ENABLE_COURSE_DISCOVERY": true,
"ENABLE_PROCTORED_EXAMS": true "ENABLE_SPECIAL_EXAMS": true
}, },
"FEEDBACK_SUBMISSION_EMAIL": "", "FEEDBACK_SUBMISSION_EMAIL": "",
"GITHUB_REPO_ROOT": "** OVERRIDDEN **", "GITHUB_REPO_ROOT": "** OVERRIDDEN **",
......
...@@ -135,7 +135,7 @@ FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True ...@@ -135,7 +135,7 @@ FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True
FEATURES['MILESTONES_APP'] = True FEATURES['MILESTONES_APP'] = True
FEATURES['ENTRANCE_EXAMS'] = True FEATURES['ENTRANCE_EXAMS'] = True
FEATURES['ENABLE_PROCTORED_EXAMS'] = True FEATURES['ENABLE_SPECIAL_EXAMS'] = True
# Point the URL used to test YouTube availability to our stub YouTube server # Point the URL used to test YouTube availability to our stub YouTube server
YOUTUBE_PORT = 9080 YOUTUBE_PORT = 9080
......
...@@ -391,8 +391,8 @@ FEATURES = { ...@@ -391,8 +391,8 @@ FEATURES = {
# How many seconds to show the bumper again, default is 7 days: # How many seconds to show the bumper again, default is 7 days:
'SHOW_BUMPER_PERIODICITY': 7 * 24 * 3600, 'SHOW_BUMPER_PERIODICITY': 7 * 24 * 3600,
# Timed Proctored Exams # Special Exams, aka Timed and Proctored Exams
'ENABLE_PROCTORED_EXAMS': False, 'ENABLE_SPECIAL_EXAMS': False,
# Enable OpenBadge support. See the BADGR_* settings later in this file. # Enable OpenBadge support. See the BADGR_* settings later in this file.
'ENABLE_OPENBADGES': False, 'ENABLE_OPENBADGES': False,
...@@ -1230,10 +1230,26 @@ courseware_js = ( ...@@ -1230,10 +1230,26 @@ courseware_js = (
) )
proctoring_js = ( proctoring_js = (
['proctoring/js/models/*.js'] + [
['proctoring/js/collections/*.js'] + 'proctoring/js/models/proctored_exam_allowance_model.js',
['proctoring/js/views/*.js'] + 'proctoring/js/models/proctored_exam_attempt_model.js',
['proctoring/js/*.js'] 'proctoring/js/models/proctored_exam_model.js'
] +
[
'proctoring/js/collections/proctored_exam_allowance_collection.js',
'proctoring/js/collections/proctored_exam_attempt_collection.js',
'proctoring/js/collections/proctored_exam_collection.js'
] +
[
'proctoring/js/views/Backbone.ModalDialog.js',
'proctoring/js/views/proctored_exam_add_allowance_view.js',
'proctoring/js/views/proctored_exam_allowance_view.js',
'proctoring/js/views/proctored_exam_attempt_view.js',
'proctoring/js/views/proctored_exam_view.js'
] +
[
'proctoring/js/proctored_app.js'
]
) )
# Before a student accesses courseware, we do not # Before a student accesses courseware, we do not
......
...@@ -47,14 +47,15 @@ def run(): ...@@ -47,14 +47,15 @@ def run():
# register any dependency injections that we need to support in edx_proctoring # register any dependency injections that we need to support in edx_proctoring
# right now edx_proctoring is dependent on the openedx.core.djangoapps.credit # right now edx_proctoring is dependent on the openedx.core.djangoapps.credit
# as well as the instructor dashboard (for deleting student attempts) if settings.FEATURES.get('ENABLE_SPECIAL_EXAMS'):
if settings.FEATURES.get('ENABLE_PROCTORED_EXAMS'):
# Import these here to avoid circular dependencies of the form: # Import these here to avoid circular dependencies of the form:
# edx-platform app --> DRF --> django translation --> edx-platform app # edx-platform app --> DRF --> django translation --> edx-platform app
from edx_proctoring.runtime import set_runtime_service from edx_proctoring.runtime import set_runtime_service
from instructor.services import InstructorService from instructor.services import InstructorService
from openedx.core.djangoapps.credit.services import CreditService from openedx.core.djangoapps.credit.services import CreditService
set_runtime_service('credit', CreditService()) set_runtime_service('credit', CreditService())
# register InstructorService (for deleting student attempts and user staff access roles)
set_runtime_service('instructor', InstructorService()) set_runtime_service('instructor', InstructorService())
# In order to allow modules to use a handler url, we need to # In order to allow modules to use a handler url, we need to
......
...@@ -183,15 +183,14 @@ setup_instructor_dashboard_sections = (idash_content) -> ...@@ -183,15 +183,14 @@ setup_instructor_dashboard_sections = (idash_content) ->
constructor: window.InstructorDashboard.sections.Certificates constructor: window.InstructorDashboard.sections.Certificates
$element: idash_content.find ".#{CSS_IDASH_SECTION}#certificates" $element: idash_content.find ".#{CSS_IDASH_SECTION}#certificates"
] ]
# proctoring can be feature disabled # proctoring can be feature disabled
if edx.instructor_dashboard.proctoring != undefined if edx.instructor_dashboard.proctoring != undefined
sections_to_initialize = sections_to_initialize.concat [ sections_to_initialize = sections_to_initialize.concat [
constructor: edx.instructor_dashboard.proctoring.ProctoredExamAllowanceView constructor: edx.instructor_dashboard.proctoring.ProctoredExamAllowanceView
$element: idash_content.find ".#{CSS_IDASH_SECTION}#proctoring" $element: idash_content.find ".#{CSS_IDASH_SECTION}#special_exams"
, ,
constructor: edx.instructor_dashboard.proctoring.ProctoredExamAttemptView constructor: edx.instructor_dashboard.proctoring.ProctoredExamAttemptView
$element: idash_content.find ".#{CSS_IDASH_SECTION}#proctoring" $element: idash_content.find ".#{CSS_IDASH_SECTION}#special_exams"
] ]
sections_to_initialize.map ({constructor, $element}) -> sections_to_initialize.map ({constructor, $element}) ->
......
...@@ -136,18 +136,20 @@ div.course-wrapper { ...@@ -136,18 +136,20 @@ div.course-wrapper {
} }
} }
button.gated-sequence { button.gated-sequence {
background-color: transparent; background: $transparent;
border-bottom: none; color: $blue-d1;
border: none;
box-shadow: none; box-shadow: none;
text-align: left; @include text-align(left);
@extend %t-copy-base;
width: 100%; width: 100%;
&:hover { &:hover {
background-color: transparent; background-color: transparent;
color: $link-hover;
} }
} }
button.gated-sequence > a {
color: #147ABA;
}
span.proctored-exam-code { span.proctored-exam-code {
margin-top: 5px; margin-top: 5px;
font-size: 1.3em; font-size: 1.3em;
...@@ -155,6 +157,7 @@ div.course-wrapper { ...@@ -155,6 +157,7 @@ div.course-wrapper {
.gated-sequence { .gated-sequence {
color: #147ABA; color: #147ABA;
font-weight: 600; font-weight: 600;
padding: ($baseline / 1.5) ($baseline / 4);
a.start-timed-exam { a.start-timed-exam {
cursor: pointer; cursor: pointer;
color: #147ABA; color: #147ABA;
...@@ -169,6 +172,25 @@ div.course-wrapper { ...@@ -169,6 +172,25 @@ div.course-wrapper {
.proctored-exam-select-code { .proctored-exam-select-code {
margin-left: 30px; margin-left: 30px;
} }
.exam-action-button {
@extend %t-weight4;
margin-right: $baseline;
background-image: none;
box-shadow: none;
text-shadow: none;
&.btn-pl-primary {
@extend %btn-pl-primary-base;
border: 0;
&:hover,
&:focus {
border: 0;
}
}
}
background-color: #F2F4F5; background-color: #F2F4F5;
padding: 30px; padding: 30px;
font-size: 16px; font-size: 16px;
...@@ -202,32 +224,26 @@ div.course-wrapper { ...@@ -202,32 +224,26 @@ div.course-wrapper {
border-left: 4px solid #C93B34 !important; border-left: 4px solid #C93B34 !important;
margin: 0 auto; margin: 0 auto;
} }
&.warning {
@include border-left(4px solid $warning-color);
margin: 0 auto;
}
} }
div.proctored-exam { div.proctored-exam {
@extend .timed-exam; @extend .timed-exam;
.proctored-exam-message { .proctored-exam-message {
border-top: ($baseline/10) solid rgb(207, 216, 220); border-top: ($baseline/10) solid rgb(207, 216, 220);
padding-top: 25px; padding-top: 25px;
} }
// specialized padding override just for the entrance page // specialized padding override just for the entrance page
&.entrance { &.entrance {
button.gated-sequence { button.gated-sequence {
padding: 0 ($baseline*5) 0 ($baseline*2.5); padding: 0 ($baseline*5) 0 ($baseline*2.5);
} }
} }
button {
background: #126F9A;
color: $white;
font-size: 16px;
padding: 16px 30px;
margin-bottom: 10px;
font-weight: 200;
border: none;
&:hover {
background-color: #035E88;
}
}
hr { hr {
border-bottom: 1px solid rgb(207, 216, 220); border-bottom: 1px solid rgb(207, 216, 220);
} }
...@@ -300,115 +316,6 @@ div.course-wrapper { ...@@ -300,115 +316,6 @@ div.course-wrapper {
@include float(right); @include float(right);
margin-top: $baseline; margin-top: $baseline;
} }
.btn {
@extend %t-strong;
transition: color $tmg-f3 ease-in-out 0s,border-color
$tmg-f3 ease-in-out 0s,background
$tmg-f3 ease-in-out 0s,box-shadow
$tmg-f3 ease-in-out 0s;;
// Display: inline, side-by-side
display: inline-block;
border-style: solid;
border-radius: 3px;
border-width: 1px;
// Display: block, one button per line, full width
&.block {
display: block;
width: 100%;
}
// STATE: is disabled
&:disabled,
&.is-disabled {
pointer-events: none;
outline: none;
cursor: default;
}
}
.btn-base {
@extend %t-copy-base;
padding: $baseline/2 $baseline;
}
// ----------------------------
// #DEFAULT
// ----------------------------
.btn-default {
border-color: rgb(0, 121, 188);
background: $white-t1;
color: rgb(0, 121, 188);;
// STATE: hover and focus
&:hover,
&.is-hovered,
&:focus,
&.is-focused {
background: rgb(0, 121, 188);
color: $white;
box-shadow: none;
text-shadow: none;
}
// STATE: is pressed or active
&:active,
&.is-pressed,
&.is-active {
border-color: $m-blue-d5;
background: $m-blue-d5;
box-shadow: none;
text-shadow: none;
}
// STATE: is disabled
&:disabled,
&.is-disabled {
border-color: $m-gray-d1;
background: $white-t1;
color: $m-gray-d3;
}
}
// ----------------------------
// #PRIMARY
// ----------------------------
.btn-primary {
border-color: rgb(0, 121, 188);
background: rgb(0, 121, 188);
color: $white;
box-shadow: none;
text-shadow: none;
// STATE: hover and focus
&:hover,
&.is-hovered,
&:focus,
&.is-focused {
border-color: $m-blue-d5;
background: $m-blue-d5;
box-shadow: none;
text-shadow: none;
}
// STATE: is pressed or active
&:active,
&.is-pressed,
&.is-active {
border-color: rgb(0, 121, 188);
background: rgb(0, 121, 188);
box-shadow: none;
text-shadow: none;
}
// STATE: is disabled
&:disabled,
&.is-disabled {
border-color: $m-gray-d1;
background: $white-t1;
color: $m-gray-d3;
}
}
} }
.footer-sequence { .footer-sequence {
padding: 30px 0px 20px 0px; padding: 30px 0px 20px 0px;
......
...@@ -1933,17 +1933,21 @@ input[name="subject"] { ...@@ -1933,17 +1933,21 @@ input[name="subject"] {
padding-left: $baseline; padding-left: $baseline;
} }
th { th {
@extend %t-action2;
text-align: left; text-align: left;
border-bottom: 1px solid $border-color-1; border-bottom: 1px solid $border-color-1;
font-size: 16px;
&.attempt-allowed-time { &.attempt-allowed-time {
width: 90px; width: 90px;
} }
&.attempt-type {
width: 90px;
}
&.attempt-started-at { &.attempt-started-at {
width: 160px; width: 170px;
} }
&.attempt-completed-at { &.attempt-completed-at {
width: 160px; width: 160px;
text-align: center;
} }
&.attempt-status { &.attempt-status {
width: 100px; width: 100px;
...@@ -1982,7 +1986,7 @@ input[name="subject"] { ...@@ -1982,7 +1986,7 @@ input[name="subject"] {
line-height: normal; line-height: normal;
font-size: 14px; font-size: 14px;
} }
td:nth-child(5), td:first-child{ td:first-child{
@include padding-left($baseline); @include padding-left($baseline);
} }
td:nth-child(2){ td:nth-child(2){
...@@ -1990,7 +1994,7 @@ input[name="subject"] { ...@@ -1990,7 +1994,7 @@ input[name="subject"] {
@include padding-right(0px); @include padding-right(0px);
word-wrap: break-word; word-wrap: break-word;
} }
td:nth-child(5), td:nth-child(4){ td:nth-child(5), td:nth-child(4), td:nth-child(6){
@include padding-left(0); @include padding-left(0);
text-align: center; text-align: center;
} }
...@@ -1998,7 +2002,7 @@ input[name="subject"] { ...@@ -1998,7 +2002,7 @@ input[name="subject"] {
word-wrap: break-word; word-wrap: break-word;
text-align: center; text-align: center;
} }
td:nth-child(6){ td:nth-child(7){
word-wrap: break-word; word-wrap: break-word;
text-align: center; text-align: center;
} }
......
...@@ -57,24 +57,28 @@ ...@@ -57,24 +57,28 @@
width: 100%; width: 100%;
} }
.exam-timer { .exam-timer {
@include line-height(39);
background-color: rgb(229, 234, 236); background-color: rgb(229, 234, 236);
padding-left: 42px; padding: $baseline ($baseline*2);
padding-right: 32px;
border-left: 4px solid $m-blue-l1; border-left: 4px solid $m-blue-l1;
margin: 0 auto; margin: 0 auto;
color: $gray-d2; color: $gray-d2;
@include font-size(14); @include font-size(14);
.exam-text {
display: inline-block;
width: calc(100% - 250px);
}
a { a {
color: rgb(9, 121, 186); color: rgb(9, 121, 186);
} }
span.pull-right { .pull-right {
color: $gray-d1; color: $gray-d1;
@include line-height(39);
b { b {
color: $gray-d3; color: $gray-d3;
} }
} }
.turn_in_exam {
margin-top: -6px;
}
&.low-time { &.low-time {
color: $gray-l3; color: $gray-l3;
background-color: rgb(79, 88, 92); background-color: rgb(79, 88, 92);
...@@ -82,11 +86,23 @@ ...@@ -82,11 +86,23 @@
color: $white; color: $white;
text-decoration: underline; text-decoration: underline;
} }
span.pull-right { .pull-right {
color: $gray-l3; color: $gray-l3;
b { b {
color: $white; color: $white;
} }
.exam-button-turn-in-exam {
background-color: transparent;
border: 1px solid $white;
color: $white;
&:hover {
border: 1px solid $white;
background-color: $white;
color: $action-primary-bg;
}
}
} }
} }
&.warning { &.warning {
...@@ -97,7 +113,20 @@ ...@@ -97,7 +113,20 @@
color: $white; color: $white;
} }
.exam-button-turn-in-exam { .exam-button-turn-in-exam {
@extend %btn-pl-primary-base;
@extend %t-action3;
@extend %t-weight4;
margin-right: $baseline; margin-right: $baseline;
border: 0;
background-image: none;
padding: ($baseline/5) ($baseline*.75);
box-shadow: none;
text-shadow: none;
&:hover,
&:focus {
border: 0;
}
} }
} }
} }
...@@ -31,10 +31,10 @@ masquerade_group_id = masquerade.group_id if masquerade else None ...@@ -31,10 +31,10 @@ masquerade_group_id = masquerade.group_id if masquerade else None
staff_selected = selected(not masquerade or masquerade.role != "student") staff_selected = selected(not masquerade or masquerade.role != "student")
specific_student_selected = selected(not staff_selected and masquerade.user_name) specific_student_selected = selected(not staff_selected and masquerade.user_name)
student_selected = selected(not staff_selected and not specific_student_selected and not masquerade_group_id) student_selected = selected(not staff_selected and not specific_student_selected and not masquerade_group_id)
include_proctoring = settings.FEATURES.get('ENABLE_PROCTORED_EXAMS', False) and course.enable_proctored_exams include_special_exams = settings.FEATURES.get('ENABLE_SPECIAL_EXAMS', False) and (course.enable_proctored_exams or course.enable_timed_exams)
%> %>
% if include_proctoring: % if include_special_exams:
<%static:js group='proctoring'/> <%static:js group='proctoring'/>
% for template_name in ["proctored-exam-status"]: % for template_name in ["proctored-exam-status"]:
<script type="text/template" id="${template_name}-tpl"> <script type="text/template" id="${template_name}-tpl">
......
...@@ -8,7 +8,7 @@ from django.conf import settings ...@@ -8,7 +8,7 @@ from django.conf import settings
from edxnotes.helpers import is_feature_enabled as is_edxnotes_enabled from edxnotes.helpers import is_feature_enabled as is_edxnotes_enabled
%> %>
<% <%
include_proctoring = settings.FEATURES.get('ENABLE_PROCTORED_EXAMS', False) and course.enable_proctored_exams include_special_exams = settings.FEATURES.get('ENABLE_SPECIAL_EXAMS', False) and (course.enable_proctored_exams or course.enable_timed_exams)
%> %>
<%def name="course_name()"> <%def name="course_name()">
<% return _("{course_number} Courseware").format(course_number=course.display_number_with_default) %> <% return _("{course_number} Courseware").format(course_number=course.display_number_with_default) %>
...@@ -38,7 +38,7 @@ ${page_title_breadcrumbs(course_name())} ...@@ -38,7 +38,7 @@ ${page_title_breadcrumbs(course_name())}
% endfor % endfor
% endif % endif
% if include_proctoring: % if include_special_exams:
% for template_name in ["proctored-exam-status"]: % for template_name in ["proctored-exam-status"]:
<script type="text/template" id="${template_name}-tpl"> <script type="text/template" id="${template_name}-tpl">
<%static:include path="courseware/${template_name}.underscore" /> <%static:include path="courseware/${template_name}.underscore" />
......
...@@ -6,11 +6,13 @@ ...@@ -6,11 +6,13 @@
.replace(/>/g, '&gt;') .replace(/>/g, '&gt;')
} }
%> %>
<%= interpolate_text('You are taking "{exam_link}" as a {exam_type} exam. The timer on the right shows the time remaining in the exam.', {exam_link: "<a href='" + exam_url_path + "'>"+gtLtEscape(exam_display_name)+"</a>", exam_type: (!_.isUndefined(arguments[0].exam_type)) ? exam_type : gettext('timed')}) %> <div class='exam-text'>
<span id="turn_in_exam_id" class="pull-right"> <%= interpolate_text('You are taking "{exam_link}" as a {exam_type} exam. The timer on the right shows the time remaining in the exam.', {exam_link: "<a href='" + exam_url_path + "'>"+gtLtEscape(exam_display_name)+"</a>", exam_type: (!_.isUndefined(arguments[0].exam_type)) ? exam_type : gettext('timed')}) %>
<span id="turn_in_exam_id"> </div>
<div id="turn_in_exam_id" class="pull-right turn_in_exam">
<span>
<% if(attempt_status !== 'ready_to_submit') {%> <% if(attempt_status !== 'ready_to_submit') {%>
<button class="exam-button-turn-in-exam"> <button class="exam-button-turn-in-exam btn btn-pl-primary">
<%- gettext("End My Exam") %> <%- gettext("End My Exam") %>
</button> </button>
<% } %> <% } %>
...@@ -20,6 +22,6 @@ ...@@ -20,6 +22,6 @@
<b> <b>
</b> </b>
</span> </span>
</span> </div>
</div> </div>
...@@ -11,7 +11,7 @@ import pytz ...@@ -11,7 +11,7 @@ import pytz
<div class="special-allowance-container" data-course-id="${ section_data['course_id'] }"></div> <div class="special-allowance-container" data-course-id="${ section_data['course_id'] }"></div>
</div> </div>
<div class="wrap"> <div class="wrap">
<h2>${_('Student Proctored Exam Section')}</h2> <h2>${_('Student Special Exam Attempts')}</h2>
<div class="student-proctored-exam-container" data-course-id="${ section_data['course_id'] }"></div> <div class="student-proctored-exam-container" data-course-id="${ section_data['course_id'] }"></div>
</div> </div>
</div> </div>
......
...@@ -356,6 +356,7 @@ def get_credit_requirement_status(course_key, username, namespace=None, name=Non ...@@ -356,6 +356,7 @@ def get_credit_requirement_status(course_key, username, namespace=None, name=Non
"reason": {}, "reason": {},
"status": "failed", "status": "failed",
"status_date": "2015-06-26 07:49:13", "status_date": "2015-06-26 07:49:13",
"order": 0,
}, },
{ {
"namespace": "proctored_exam", "namespace": "proctored_exam",
...@@ -365,6 +366,7 @@ def get_credit_requirement_status(course_key, username, namespace=None, name=Non ...@@ -365,6 +366,7 @@ def get_credit_requirement_status(course_key, username, namespace=None, name=Non
"reason": {}, "reason": {},
"status": "satisfied", "status": "satisfied",
"status_date": "2015-06-26 11:07:42", "status_date": "2015-06-26 11:07:42",
"order": 1,
}, },
{ {
"namespace": "grade", "namespace": "grade",
...@@ -374,6 +376,7 @@ def get_credit_requirement_status(course_key, username, namespace=None, name=Non ...@@ -374,6 +376,7 @@ def get_credit_requirement_status(course_key, username, namespace=None, name=Non
"reason": {"final_grade": 0.95}, "reason": {"final_grade": 0.95},
"status": "satisfied", "status": "satisfied",
"status_date": "2015-06-26 11:07:44", "status_date": "2015-06-26 11:07:44",
"order": 2,
}, },
] ]
...@@ -394,6 +397,7 @@ def get_credit_requirement_status(course_key, username, namespace=None, name=Non ...@@ -394,6 +397,7 @@ def get_credit_requirement_status(course_key, username, namespace=None, name=Non
"reason": requirement_status.reason if requirement_status else None, "reason": requirement_status.reason if requirement_status else None,
"status": requirement_status.status if requirement_status else None, "status": requirement_status.status if requirement_status else None,
"status_date": requirement_status.modified if requirement_status else None, "status_date": requirement_status.modified if requirement_status else None,
"order": requirement.order,
}) })
return statuses return statuses
......
...@@ -304,16 +304,27 @@ class CreditRequirementApiTests(CreditApiTestBase): ...@@ -304,16 +304,27 @@ class CreditRequirementApiTests(CreditApiTestBase):
# Initially, the status should be None # Initially, the status should be None
req_status = api.get_credit_requirement_status(self.course_key, "staff", namespace="grade", name="grade") req_status = api.get_credit_requirement_status(self.course_key, "staff", namespace="grade", name="grade")
self.assertEqual(req_status[0]["status"], None) self.assertEqual(req_status[0]["status"], None)
self.assertEqual(req_status[0]["order"], 0)
# Set the requirement to "satisfied" and check that it's actually set # Set the requirement to "satisfied" and check that it's actually set
api.set_credit_requirement_status("staff", self.course_key, "grade", "grade") api.set_credit_requirement_status("staff", self.course_key, "grade", "grade")
req_status = api.get_credit_requirement_status(self.course_key, "staff", namespace="grade", name="grade") req_status = api.get_credit_requirement_status(self.course_key, "staff", namespace="grade", name="grade")
self.assertEqual(req_status[0]["status"], "satisfied") self.assertEqual(req_status[0]["status"], "satisfied")
self.assertEqual(req_status[0]["order"], 0)
# Set the requirement to "failed" and check that it's actually set # Set the requirement to "failed" and check that it's actually set
api.set_credit_requirement_status("staff", self.course_key, "grade", "grade", status="failed") api.set_credit_requirement_status("staff", self.course_key, "grade", "grade", status="failed")
req_status = api.get_credit_requirement_status(self.course_key, "staff", namespace="grade", name="grade") req_status = api.get_credit_requirement_status(self.course_key, "staff", namespace="grade", name="grade")
self.assertEqual(req_status[0]["status"], "failed") self.assertEqual(req_status[0]["status"], "failed")
self.assertEqual(req_status[0]["order"], 0)
req_status = api.get_credit_requirement_status(self.course_key, "staff")
self.assertEqual(req_status[0]["status"], "failed")
self.assertEqual(req_status[0]["order"], 0)
# make sure the 'order' on the 2nd requiemtn is set correctly (aka 1)
self.assertEqual(req_status[1]["status"], None)
self.assertEqual(req_status[1]["order"], 1)
# Set the requirement to "declined" and check that it's actually set # Set the requirement to "declined" and check that it's actually set
api.set_credit_requirement_status( api.set_credit_requirement_status(
......
...@@ -57,7 +57,9 @@ git+https://github.com/edx/edx-lint.git@v0.3.0#egg=edx_lint==0.3.0 ...@@ -57,7 +57,9 @@ git+https://github.com/edx/edx-lint.git@v0.3.0#egg=edx_lint==0.3.0
git+https://github.com/edx/edx-reverification-block.git@0.0.4#egg=edx-reverification-block==0.0.4 git+https://github.com/edx/edx-reverification-block.git@0.0.4#egg=edx-reverification-block==0.0.4
-e git+https://github.com/edx/edx-user-state-client.git@30c0ad4b9f57f8d48d6943eb585ec8a9205f4469#egg=edx-user-state-client -e git+https://github.com/edx/edx-user-state-client.git@30c0ad4b9f57f8d48d6943eb585ec8a9205f4469#egg=edx-user-state-client
git+https://github.com/edx/edx-organizations.git@release-2015-09-22#egg=edx-organizations==0.1.6 git+https://github.com/edx/edx-organizations.git@release-2015-09-22#egg=edx-organizations==0.1.6
git+https://github.com/edx/edx-proctoring.git@0.9.16#egg=edx-proctoring==0.9.16
git+https://github.com/edx/edx-proctoring.git@0.10.15#egg=edx-proctoring==0.10.15
# Third Party XBlocks # Third Party XBlocks
-e git+https://github.com/mitodl/edx-sga@172a90fd2738f8142c10478356b2d9ed3e55334a#egg=edx-sga -e git+https://github.com/mitodl/edx-sga@172a90fd2738f8142c10478356b2d9ed3e55334a#egg=edx-sga
......
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