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>
Jamie Folsom <jfolsom@mit.edu>
George Schneeloch <gschneel@mit.edu>
Dustin Gadal <Dustin.Gadal@gmail.com>
Ibrahim Ahmed <ibrahimahmed443@gmail.com>
Robert Raposa <rraposa@edx.org>
Giovanni Di Milia <gdimilia@mit.edu>
Peter Wilkins <pwilkins@mit.edu>
......
......@@ -23,7 +23,7 @@ from edx_proctoring.exceptions import (
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
that are marked as timed exams. Then these are registered with the edx-proctoring
......@@ -31,13 +31,14 @@ def register_proctored_exams(course_key):
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
return
course = modulestore().get_course(course_key)
if not course.enable_proctored_exams:
# likewise if course does not have this feature turned on
if not course.enable_proctored_exams and not course.enable_timed_exams:
# likewise if course does not have these features turned on
# then quickly exit
return
# get all sequences, since they can be marked as timed/proctored exams
......@@ -75,7 +76,8 @@ def register_proctored_exams(course_key):
exam_id=exam['id'],
exam_name=timed_exam.display_name,
time_limit_mins=timed_exam.default_time_limit_minutes,
is_proctored=timed_exam.is_proctored_enabled,
due_date=timed_exam.due,
is_proctored=timed_exam.is_proctored_exam,
is_practice_exam=timed_exam.is_practice_exam,
is_active=True
)
......@@ -87,7 +89,8 @@ def register_proctored_exams(course_key):
content_id=unicode(timed_exam.location),
exam_name=timed_exam.display_name,
time_limit_mins=timed_exam.default_time_limit_minutes,
is_proctored=timed_exam.is_proctored_enabled,
due_date=timed_exam.due,
is_proctored=timed_exam.is_proctored_exam,
is_practice_exam=timed_exam.is_practice_exam,
is_active=True
)
......
......@@ -7,7 +7,7 @@ from django.dispatch import receiver
from xmodule.modulestore.django import SignalHandler
from contentstore.courseware_index import CoursewareSearchIndexer, LibrarySearchIndexer
from contentstore.proctoring import register_proctored_exams
from contentstore.proctoring import register_special_exams
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=
# first is to registered exams, the credit subsystem will assume that
# all proctored exams have already been registered, so we have to do that first
register_proctored_exams(course_key)
register_special_exams(course_key)
# then call into the credit subsystem (in /openedx/djangoapps/credit)
# to perform any 'on_publish' workflow
......
......@@ -4,6 +4,8 @@ Tests for the edx_proctoring integration into Studio
from mock import patch
import ddt
from datetime import datetime, timedelta
from pytz import UTC
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
......@@ -13,7 +15,7 @@ from edx_proctoring.api import get_all_exams_for_course
@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):
"""
Tests for the publishing of proctored exams
......@@ -46,7 +48,7 @@ class TestProctoredExams(ModuleStoreTestCase):
self.assertEqual(exam['content_id'], unicode(sequence.location))
self.assertEqual(exam['exam_name'], sequence.display_name)
self.assertEqual(exam['time_limit_mins'], sequence.default_time_limit_minutes)
self.assertEqual(exam['is_proctored'], sequence.is_proctored_enabled)
self.assertEqual(exam['is_proctored'], sequence.is_proctored_exam)
self.assertEqual(exam['is_active'], expected_active)
@ddt.data(
......@@ -56,7 +58,7 @@ class TestProctoredExams(ModuleStoreTestCase):
)
@ddt.unpack
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
a proctored exam, it will also put an entry into the exam tables
......@@ -70,7 +72,8 @@ class TestProctoredExams(ModuleStoreTestCase):
graded=True,
is_time_limited=is_time_limited,
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)
......@@ -102,7 +105,7 @@ class TestProctoredExams(ModuleStoreTestCase):
graded=True,
is_time_limited=True,
default_time_limit_minutes=10,
is_proctored_enabled=True
is_proctored_exam=True
)
listen_for_course_publish(self, self.course.id)
......@@ -111,7 +114,7 @@ class TestProctoredExams(ModuleStoreTestCase):
self.assertEqual(len(exams), 1)
sequence.is_time_limited = False
sequence.is_proctored_enabled = False
sequence.is_proctored_exam = False
self.store.update_item(sequence, self.user.id)
......@@ -132,7 +135,7 @@ class TestProctoredExams(ModuleStoreTestCase):
graded=True,
is_time_limited=True,
default_time_limit_minutes=10,
is_proctored_enabled=True
is_proctored_exam=True
)
listen_for_course_publish(self, self.course.id)
......@@ -153,7 +156,7 @@ class TestProctoredExams(ModuleStoreTestCase):
exam = exams[0]
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):
"""
Make sure the feature flag is honored
......@@ -167,7 +170,7 @@ class TestProctoredExams(ModuleStoreTestCase):
graded=True,
is_time_limited=True,
default_time_limit_minutes=10,
is_proctored_enabled=True
is_proctored_exam=True
)
listen_for_course_publish(self, self.course.id)
......@@ -175,7 +178,13 @@ class TestProctoredExams(ModuleStoreTestCase):
exams = get_all_exams_for_course(unicode(self.course.id))
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
"""
......@@ -184,7 +193,8 @@ class TestProctoredExams(ModuleStoreTestCase):
org='edX',
course='901',
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')
......@@ -195,7 +205,7 @@ class TestProctoredExams(ModuleStoreTestCase):
graded=True,
is_time_limited=True,
default_time_limit_minutes=10,
is_proctored_enabled=True
is_proctored_exam=True
)
listen_for_course_publish(self, self.course.id)
......@@ -203,4 +213,4 @@ class TestProctoredExams(ModuleStoreTestCase):
# there shouldn't be any exams because we haven't enabled that
# advanced setting flag
exams = get_all_exams_for_course(unicode(self.course.id))
self.assertEqual(len(exams), 0)
self.assertEqual(len(exams), expected_count)
......@@ -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),
}
# update xblock_info with proctored_exam information if the feature flag is enabled
if settings.FEATURES.get('ENABLE_PROCTORED_EXAMS'):
# update xblock_info with special exam information if the feature flag is enabled
if settings.FEATURES.get('ENABLE_SPECIAL_EXAMS'):
if xblock.category == 'course':
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':
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,
"default_time_limit_minutes": xblock.default_time_limit_minutes,
"is_practice_exam": xblock.is_practice_exam
"default_time_limit_minutes": xblock.default_time_limit_minutes
})
# Entrance exam subsection should be hidden. in_entrance_exam is inherited metadata, all children will have it.
......
......@@ -1731,7 +1731,7 @@ class TestXBlockInfo(ItemTest):
else:
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):
self.course.enable_proctored_exams = True
self.course.save()
......@@ -1749,7 +1749,7 @@ class TestXBlockInfo(ItemTest):
sequential = ItemFactory.create(
parent_location=self.chapter.location, category='sequential',
display_name="Test Lesson 1", user_id=self.user.id,
is_proctored_enabled=True, is_time_limited=True,
is_proctored_exam=True, is_time_limited=True,
default_time_limit_minutes=100
)
sequential = modulestore().get_item(sequential.location)
......@@ -1759,7 +1759,7 @@ class TestXBlockInfo(ItemTest):
include_children_predicate=ALWAYS,
)
# exam proctoring should be enabled and time limited.
self.assertEqual(xblock_info['is_proctored_enabled'], True)
self.assertEqual(xblock_info['is_proctored_exam'], True)
self.assertEqual(xblock_info['is_time_limited'], True)
self.assertEqual(xblock_info['default_time_limit_minutes'], 100)
......
......@@ -78,7 +78,7 @@
"SUBDOMAIN_COURSE_LISTINGS": false,
"ALLOW_ALL_ADVANCED_COMPONENTS": true,
"ENABLE_CONTENT_LIBRARIES": true,
"ENABLE_PROCTORED_EXAMS": true
"ENABLE_SPECIAL_EXAMS": true
},
"FEEDBACK_SUBMISSION_EMAIL": "",
"GITHUB_REPO_ROOT": "** OVERRIDDEN **",
......
......@@ -104,7 +104,7 @@ FEATURES['PARTNER_SUPPORT_EMAIL'] = 'partner-support@example.com'
########################### Entrance Exams #################################
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
YOUTUBE_PORT = 9080
......
......@@ -168,9 +168,6 @@ FEATURES = {
# Show video bumper in Studio
'ENABLE_VIDEO_BUMPER': False,
# Timed Proctored Exams
'ENABLE_PROCTORED_EXAMS': False,
# How many seconds to show the bumper again, default is 7 days:
'SHOW_BUMPER_PERIODICITY': 7 * 24 * 3600,
......@@ -180,8 +177,8 @@ FEATURES = {
# Can the visibility of the discussion tab be configured on a per-course basis?
'ALLOW_HIDING_DISCUSSION_TAB': False,
# Timed or Proctored Exams
'ENABLE_PROCTORED_EXAMS': False,
# Special Exams, aka Timed and Proctored Exams
'ENABLE_SPECIAL_EXAMS': False,
}
ENABLE_JASMINE = False
......
......@@ -141,16 +141,21 @@ define(["jquery", "underscore", "js/views/xblock_outline", "common/js/components
editXBlock: function() {
var enable_proctored_exams = false;
if (this.model.get('category') === 'sequential' &&
this.parentView.parentView.model.has('enable_proctored_exams')) {
enable_proctored_exams = this.parentView.parentView.model.get('enable_proctored_exams');
var enable_timed_exams = false;
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');
}
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, {
onSave: this.refresh.bind(this),
parentInfo: this.parentInfo,
enable_proctored_exams: enable_proctored_exams,
enable_timed_exams: enable_timed_exams,
xblockType: XBlockViewUtils.getXBlockType(
this.model.get('category'), this.parentView.model, true
)
......
......@@ -47,7 +47,9 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
return new Editor({
parentElement: this.$('.modal-section'),
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);
},
......@@ -161,7 +163,9 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
render: function () {
var html = this.template($.extend({}, {
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.$el.html(html);
......@@ -261,37 +265,30 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
TimedExaminationPreferenceEditor = AbstractEditor.extend({
templateName: 'timed-examination-preference-editor',
className: 'edit-settings-timed-examination',
events : {
'change #id_timed_examination': 'timedExamination',
'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'
},
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) {
event.preventDefault();
var selectedTimeLimit = $(event.currentTarget).val();
if (!this.isValidTimeLimit(selectedTimeLimit)) {
$(event.currentTarget).val("00:30");
}
},
timedExamination: function (event) {
event.preventDefault();
if (!$(event.currentTarget).is(':checked')) {
this.$('#id_exam_proctoring').attr('checked', false);
this.$('#id_time_limit').val('00: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 () {
AbstractEditor.prototype.afterRender.call(this);
this.$('input.time').timepicker({
......@@ -300,35 +297,36 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
'maxTime': '05:00',
'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.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) {
this.$('#id_exam_proctoring').prop('checked', value);
setExamType: function(is_time_limited, is_proctored_exam, is_practice_exam) {
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) {
var time = this.convertTimeLimitMinutesToString(value);
this.$('#id_time_limit').val(time);
},
setExamTmePreference: function (value) {
this.$('#id_timed_examination').prop('checked', value);
if (!this.$('#id_timed_examination').is(':checked')) {
this.$('#id_exam_proctoring').attr('disabled','disabled');
this.$('#id_time_limit').attr('disabled', 'disabled');
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) {
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";
......@@ -348,16 +346,40 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
var total_time = (parseInt(time[0]) * 60) + parseInt(time[1]);
return total_time;
},
isExamProctoringEnabled: function () {
return this.$('#id_exam_proctoring').is(':checked');
},
getRequestData: function () {
var is_time_limited;
var is_practice_exam;
var is_proctored_exam;
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 {
metadata: {
'is_practice_exam': this.isPracticeExam(),
'is_time_limited': this.isExamTimeEnabled(),
'is_proctored_enabled': this.isExamProctoringEnabled(),
'is_practice_exam': is_practice_exam,
'is_time_limited': is_time_limited,
// 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)
}
};
......@@ -568,10 +590,8 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
} else if (xblockInfo.isSequential()) {
editors = [ReleaseDateEditor, GradingEditor, DueDateEditor];
// since timed/proctored exams are optional
// we want it before the StaffLockEditor
// to keep it closer to the GradingEditor
if (options.enable_proctored_exams) {
var enable_special_exams = (options.enable_proctored_exams || options.enable_timed_exams);
if (enable_special_exams) {
editors.push(TimedExaminationPreferenceEditor);
}
......
......@@ -529,7 +529,7 @@
.wrapper-modal-window-bulkpublish-unit,
.course-outline-modal {
.exam-time-list-fields {
margin-bottom: ($baseline/2);
margin: 0 0 ($baseline/2) ($baseline/2);
}
.list-fields {
.field-message {
......@@ -688,12 +688,6 @@
// give a little space between the sections
padding-bottom: 10px;
// indent this group a bit to make it seem like
// it is one group, under a header
.modal-section-content {
margin-left: 25px;
}
.checkbox-cosmetic .input-checkbox {
@extend %cont-text-sr;
......
......@@ -30,10 +30,23 @@ if (statusType === 'warning') {
statusIconClass = 'fa-lock';
}
var gradingType = gettext('Not Graded');
var gradingType = gettext('Ungraded');
if (xblockInfo.get('graded')) {
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) { %>
<li class="outline-item outline-<%= xblockType %> <%= visibilityClass %> is-draggable <%= includesChildren ? 'is-collapsible' : '' %> <%= isCollapsed ? 'is-collapsed' : '' %>"
......@@ -132,26 +145,28 @@ if (xblockInfo.get('graded')) {
</p>
</div>
<% } %>
<% if (xblockInfo.get('due_date') || xblockInfo.get('graded')) { %>
<div class="status-grading">
<% if (xblockInfo.get('is_time_limited')) { %>
<div class="status-timed-proctored-exam">
<p>
<span class="sr status-grading-label"> <%= gettext('Graded as:') %> </span>
<i class="icon fa fa-check"></i>
<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')) { %>
<span class="status-grading-date"> <%= gettext('Due:') %> <%= xblockInfo.get('due_date') %> </span>
<% } %>
</p>
</div>
<% } %>
<% if (xblockInfo.get('is_time_limited')) { %>
<div class="status-timed-proctored-exam">
<% } else if (xblockInfo.get('due_date') || xblockInfo.get('graded')) { %>
<div class="status-grading">
<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>
<span class="status-proctored-exam-value"> <%= gettext('Timed and Proctored Exam') %> </span>
<span class="status-grading-value"> <%= gradingType %> </span>
<% 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>
</div>
......
<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='exam-time-list-fields'>
<ul class="list-fields list-input">
<li class="field field-checkbox checkbox-cosmetic">
<input type="checkbox" id="id_timed_examination" name="timed_examination" class="input input-checkbox" />
<label for="id_timed_examination" class="label">
<i class="icon fa fa-check-square-o input-checkbox-checked"></i>
<i class="icon fa fa-square-o input-checkbox-unchecked"></i>
<%- gettext('This exam is timed') %>
<li class="field-radio">
<input type="radio" id="id_not_timed" name="proctored" class="input input-radio" checked="checked"/>
<label for="id_not_timed" class="label">
<%- gettext('None') %>
</label>
</li>
</ul>
</div>
<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">
<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"
value=""
value="" aria-describedby="time-limit-description"
placeholder="HH:MM" class="time_limit release-time time input input-text" autocomplete="off" />
</li>
<p class='field-message'><%- gettext('Students see warnings when 20% and 5% of the allotted time remains. In certain cases, students can be granted allowances that give them extra time to complete the exam.') %></p>
<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>
</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>
</form>
......@@ -912,7 +912,17 @@ class CourseFields(object):
enable_proctored_exams = Boolean(
display_name=_("Enable Proctored Exams"),
help=_(
"Enter true or false. If this value is true, timed and proctored exams are enabled in your course."
"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,
scope=Scope.settings
......
......@@ -97,6 +97,16 @@ class ProctoringFields(object):
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('credit')
......@@ -221,14 +231,12 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
proctoring_service = self.runtime.service(self, 'proctoring')
credit_service = self.runtime.service(self, 'credit')
# Is the feature turned on and do we have all required services
# Also, the ENABLE_PROCTORED_EXAMS feature flag must be set to
# True and the Sequence in question, should have the
# fields set to indicate this is a timed/proctored exam
# Is this sequence designated as a Timed Examination, which includes
# Proctored Exams
feature_enabled = (
proctoring_service and
credit_service and
proctoring_service.is_feature_enabled()
self.is_time_limited
)
if feature_enabled:
user_id = self.runtime.user_id
......@@ -242,7 +250,8 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
self.default_time_limit_minutes if
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
......
......@@ -118,6 +118,16 @@ class CoursewarePage(CoursePage):
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")
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):
"""
clicks the start this timed exam link
......
......@@ -66,14 +66,14 @@ class InstructorDashboardPage(CoursePage):
certificates_section.wait_for_page()
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()
proctoring_section = ProctoringPage(self.browser)
proctoring_section.wait_for_page()
return proctoring_section
self.q(css='a[data-section=special_exams]').first.click()
timed_exam_section = SpecialExamsPage(self.browser)
timed_exam_section.wait_for_page()
return timed_exam_section
@staticmethod
def get_asset_path(file_name):
......@@ -114,20 +114,20 @@ class MembershipPage(PageObject):
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
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):
"""
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:
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]",
......@@ -139,7 +139,7 @@ class ProctoringPage(PageObject):
"""
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:
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]",
......@@ -751,9 +751,9 @@ class MembershipPageAutoEnrollSection(PageObject):
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
......@@ -768,14 +768,15 @@ class ProctoringPageAllowanceSection(PageObject):
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
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
def is_search_text_field_visible(self):
......
......@@ -534,8 +534,7 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
"""
Makes a Proctored exam.
"""
self.q(css="#id_timed_examination").first.click()
self.q(css="#id_exam_proctoring").first.click()
self.q(css="#id_proctored_exam").first.click()
self.q(css=".action-save").first.click()
self.wait_for_ajax()
......@@ -543,28 +542,59 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
"""
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.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):
"""
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
# The time limit field
if not self.q(css="#id_time_limit").present:
# The Timed exam radio button
if not self.q(css="#id_timed_exam").present:
return False
# The Practice exam checkbox
if not self.q(css="#id_practice_exam").present:
# The Proctored exam radio button
if not self.q(css="#id_proctored_exam").present:
return False
# The Proctored exam checkbox
if not self.q(css="#id_exam_proctoring").present:
# The Practice exam radio button
if not self.q(css="#id_practice_exam").present:
return False
return True
......
......@@ -218,4 +218,5 @@ class AdvancedSettingsPage(CoursePage):
'video_bumper',
'cert_html_view_enabled',
'enable_proctored_exams',
'enable_timed_exams',
]
......@@ -253,6 +253,86 @@ class ProctoredExamTest(UniqueCourseTest):
# Then I am taken to the exam with a timer bar showing
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):
"""
......
......@@ -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.")
@attr('shard_1')
class ProctoredExamsTest(BaseInstructorDashboardTest):
"""
End-to-end tests for Proctoring Sections of the Instructor Dashboard.
......@@ -169,12 +170,32 @@ class ProctoredExamsTest(BaseInstructorDashboardTest):
# Auto-auth register for the course.
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.
"""
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):
"""
......@@ -186,14 +207,13 @@ class ProctoredExamsTest(BaseInstructorDashboardTest):
self._auto_auth("STAFF_TESTER", "staff101@example.com", True)
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.make_exam_proctored()
time.sleep(2) # Wait for 2 seconds to save the settings.
# login as a verified student and visit the courseware.
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()
# Start the proctored exam.
......@@ -212,16 +232,18 @@ class ProctoredExamsTest(BaseInstructorDashboardTest):
# open the exam settings to make it a proctored exam.
self.course_outline.open_exam_settings_dialog()
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.
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()
# Start the proctored exam.
# Start the timed exam.
self.courseware_page.start_timed_exam()
# Stop the timed exam.
self.courseware_page.stop_timed_exam()
@flaky # TODO fix this SOL-1183
def test_can_add_remove_allowance(self):
"""
......@@ -231,11 +253,11 @@ class ProctoredExamsTest(BaseInstructorDashboardTest):
self._create_a_timed_exam_and_attempt()
# 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()
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
self.assertTrue(allowance_section.is_add_allowance_button_visible)
......@@ -244,16 +266,15 @@ class ProctoredExamsTest(BaseInstructorDashboardTest):
"""
Make sure that Exam attempts are visible and can be reset.
"""
# Given that an exam has been configured to be a proctored exam.
self._create_a_timed_exam_and_attempt()
# 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()
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
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_
}
#
# Add in rendering context for proctored exams
# if applicable
# Add in rendering context if exam is a timed exam (which includes proctored)
#
is_proctored_enabled = (
getattr(section, 'is_proctored_enabled', False) and
settings.FEATURES.get('ENABLE_PROCTORED_EXAMS', False)
section_is_time_limited = (
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
# harness fails. When running in 'harvest' mode, the
# test service appears to get into trouble with
......@@ -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)
# is not applicable
#
proctoring_attempt_context = None
timed_exam_attempt_context = None
try:
proctoring_attempt_context = get_attempt_status_summary(
timed_exam_attempt_context = get_attempt_status_summary(
user.id,
unicode(course.id),
unicode(section.location)
......@@ -237,12 +237,12 @@ def toc_for_course(user, request, course, active_chapter, active_section, field_
# unhandled exception
log.exception(ex)
if proctoring_attempt_context:
if timed_exam_attempt_context:
# yes, user has proctoring context about
# this level of the courseware
# so add to the accordion data context
section_context.update({
'proctoring': proctoring_attempt_context,
'proctoring': timed_exam_attempt_context,
})
sections.append(section_context)
......
......@@ -699,7 +699,7 @@ class TestTOC(ModuleStoreTestCase):
@attr('shard_1')
@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):
"""Check the Table of Contents for a course"""
def setUp(self):
......@@ -963,7 +963,7 @@ class TestProctoringRendering(ModuleStoreTestCase):
sequence = self.modulestore.get_item(usage_key)
sequence.is_time_limited = True
sequence.is_proctored_enabled = True
sequence.is_proctored_exam = True
sequence.is_practice_exam = is_practice_exam
self.modulestore.update_item(sequence, self.user.id)
......
......@@ -281,7 +281,7 @@ class TestNavigation(ModuleStoreTestCase, LoginEnrollmentTestCase):
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(
'courseware',
kwargs={'course_id': test_course_id}
......@@ -290,7 +290,7 @@ class TestNavigation(ModuleStoreTestCase, LoginEnrollmentTestCase):
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(
'courseware',
kwargs={'course_id': test_course_id}
......
......@@ -11,6 +11,10 @@ from instructor.views.tools import get_student_from_identifier
from django.core.exceptions import ObjectDoesNotExist
import instructor.enrollment as enrollment
from student.roles import CourseStaffRole
from student import auth
log = logging.getLogger(__name__)
......@@ -69,3 +73,10 @@ class InstructorService(object):
)
)
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
@attr('shard_1')
@patch.dict(settings.FEATURES, {'ENABLE_PROCTORED_EXAMS': True})
@patch.dict(settings.FEATURES, {'ENABLE_SPECIAL_EXAMS': True})
class TestProctoringDashboardViews(SharedModuleStoreTestCase):
"""
Check for Proctoring view on the new instructor dashboard
......@@ -27,7 +27,7 @@ class TestProctoringDashboardViews(SharedModuleStoreTestCase):
# URL for instructor dash
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):
super(TestProctoringDashboardViews, self).setUp()
......@@ -61,11 +61,11 @@ class TestProctoringDashboardViews(SharedModuleStoreTestCase):
self.assertFalse(self.proctoring_link 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):
"""
Test Pass Proctoring Tab is not in the Instructor Dashboard
if the feature flag 'ENABLE_PROCTORED_EXAMS' is unset.
Special Exams tab will not be visible if
the user is not a staff member.
"""
self.instructor.is_staff = True
self.instructor.save()
......
......@@ -6,6 +6,7 @@ import json
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from courseware.models import StudentModule
from instructor.access import allow_access
from instructor.services import InstructorService
from instructor.tests.test_tools import msk_from_problem_urlname
from nose.plugins.attrib import attr
......@@ -114,3 +115,21 @@ class InstructorServiceTests(SharedModuleStoreTestCase):
self.other_problem_urlname
)
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):
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))
# Gate access to Proctoring tab
# only global staff (user.is_staff) is allowed to see this tab
can_see_proctoring = (
settings.FEATURES.get('ENABLE_PROCTORED_EXAMS', False) and
course.enable_proctored_exams and
request.user.is_staff
# Gate access to Special Exam tab depending if either timed exams or proctored exams
# are enabled in the course
# NOTE: For now, if we only have procotred exams enabled, then only platform Staff
# (user.is_staff) will be able to view the special exams tab. This may
# 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:
sections.append(_section_proctoring(course, access))
if can_see_special_exams:
sections.append(_section_special_exams(course, access))
# Certificates panel
# This is used to generate example certificates
......@@ -232,13 +235,13 @@ def _section_e_commerce(course, access, paid_mode, coupons_enabled, reports_enab
return section_data
def _section_proctoring(course, access):
def _section_special_exams(course, access):
""" Provide data for the corresponding dashboard section """
course_key = course.id
section_data = {
'section_key': 'proctoring',
'section_display_name': _('Proctoring'),
'section_key': 'special_exams',
'section_display_name': _('Special Exams'),
'access': access,
'course_id': unicode(course_key)
}
......@@ -491,7 +494,7 @@ def _section_data_download(course, access):
course_key = course.id
show_proctored_report_button = (
settings.FEATURES.get('ENABLE_PROCTORED_EXAMS', False) and
settings.FEATURES.get('ENABLE_SPECIAL_EXAMS', False) and
course.enable_proctored_exams
)
......
......@@ -122,7 +122,7 @@ FEATURES['ENABLE_PAYMENT_FAKE'] = True
FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True
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
# verification.
......
......@@ -90,7 +90,7 @@
"MODE_CREATION_FOR_TESTING": true,
"AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING": true,
"ENABLE_COURSE_DISCOVERY": true,
"ENABLE_PROCTORED_EXAMS": true
"ENABLE_SPECIAL_EXAMS": true
},
"FEEDBACK_SUBMISSION_EMAIL": "",
"GITHUB_REPO_ROOT": "** OVERRIDDEN **",
......
......@@ -135,7 +135,7 @@ FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True
FEATURES['MILESTONES_APP'] = 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
YOUTUBE_PORT = 9080
......
......@@ -391,8 +391,8 @@ FEATURES = {
# How many seconds to show the bumper again, default is 7 days:
'SHOW_BUMPER_PERIODICITY': 7 * 24 * 3600,
# Timed Proctored Exams
'ENABLE_PROCTORED_EXAMS': False,
# Special Exams, aka Timed and Proctored Exams
'ENABLE_SPECIAL_EXAMS': False,
# Enable OpenBadge support. See the BADGR_* settings later in this file.
'ENABLE_OPENBADGES': False,
......@@ -1230,10 +1230,26 @@ courseware_js = (
)
proctoring_js = (
['proctoring/js/models/*.js'] +
['proctoring/js/collections/*.js'] +
['proctoring/js/views/*.js'] +
['proctoring/js/*.js']
[
'proctoring/js/models/proctored_exam_allowance_model.js',
'proctoring/js/models/proctored_exam_attempt_model.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
......
......@@ -47,14 +47,15 @@ def run():
# register any dependency injections that we need to support in edx_proctoring
# 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_PROCTORED_EXAMS'):
if settings.FEATURES.get('ENABLE_SPECIAL_EXAMS'):
# Import these here to avoid circular dependencies of the form:
# edx-platform app --> DRF --> django translation --> edx-platform app
from edx_proctoring.runtime import set_runtime_service
from instructor.services import InstructorService
from openedx.core.djangoapps.credit.services import CreditService
set_runtime_service('credit', CreditService())
# register InstructorService (for deleting student attempts and user staff access roles)
set_runtime_service('instructor', InstructorService())
# In order to allow modules to use a handler url, we need to
......
......@@ -183,15 +183,14 @@ setup_instructor_dashboard_sections = (idash_content) ->
constructor: window.InstructorDashboard.sections.Certificates
$element: idash_content.find ".#{CSS_IDASH_SECTION}#certificates"
]
# proctoring can be feature disabled
if edx.instructor_dashboard.proctoring != undefined
sections_to_initialize = sections_to_initialize.concat [
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
$element: idash_content.find ".#{CSS_IDASH_SECTION}#proctoring"
$element: idash_content.find ".#{CSS_IDASH_SECTION}#special_exams"
]
sections_to_initialize.map ({constructor, $element}) ->
......
......@@ -136,18 +136,20 @@ div.course-wrapper {
}
}
button.gated-sequence {
background-color: transparent;
border-bottom: none;
background: $transparent;
color: $blue-d1;
border: none;
box-shadow: none;
text-align: left;
@include text-align(left);
@extend %t-copy-base;
width: 100%;
&:hover {
background-color: transparent;
color: $link-hover;
}
}
button.gated-sequence > a {
color: #147ABA;
}
span.proctored-exam-code {
margin-top: 5px;
font-size: 1.3em;
......@@ -155,6 +157,7 @@ div.course-wrapper {
.gated-sequence {
color: #147ABA;
font-weight: 600;
padding: ($baseline / 1.5) ($baseline / 4);
a.start-timed-exam {
cursor: pointer;
color: #147ABA;
......@@ -169,6 +172,25 @@ div.course-wrapper {
.proctored-exam-select-code {
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;
padding: 30px;
font-size: 16px;
......@@ -202,32 +224,26 @@ div.course-wrapper {
border-left: 4px solid #C93B34 !important;
margin: 0 auto;
}
&.warning {
@include border-left(4px solid $warning-color);
margin: 0 auto;
}
}
div.proctored-exam {
@extend .timed-exam;
.proctored-exam-message {
border-top: ($baseline/10) solid rgb(207, 216, 220);
padding-top: 25px;
}
// specialized padding override just for the entrance page
&.entrance {
button.gated-sequence {
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 {
border-bottom: 1px solid rgb(207, 216, 220);
}
......@@ -300,115 +316,6 @@ div.course-wrapper {
@include float(right);
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 {
padding: 30px 0px 20px 0px;
......
......@@ -1933,17 +1933,21 @@ input[name="subject"] {
padding-left: $baseline;
}
th {
@extend %t-action2;
text-align: left;
border-bottom: 1px solid $border-color-1;
font-size: 16px;
&.attempt-allowed-time {
width: 90px;
}
&.attempt-type {
width: 90px;
}
&.attempt-started-at {
width: 160px;
width: 170px;
}
&.attempt-completed-at {
width: 160px;
text-align: center;
}
&.attempt-status {
width: 100px;
......@@ -1982,7 +1986,7 @@ input[name="subject"] {
line-height: normal;
font-size: 14px;
}
td:nth-child(5), td:first-child{
td:first-child{
@include padding-left($baseline);
}
td:nth-child(2){
......@@ -1990,7 +1994,7 @@ input[name="subject"] {
@include padding-right(0px);
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);
text-align: center;
}
......@@ -1998,7 +2002,7 @@ input[name="subject"] {
word-wrap: break-word;
text-align: center;
}
td:nth-child(6){
td:nth-child(7){
word-wrap: break-word;
text-align: center;
}
......
......@@ -57,24 +57,28 @@
width: 100%;
}
.exam-timer {
@include line-height(39);
background-color: rgb(229, 234, 236);
padding-left: 42px;
padding-right: 32px;
padding: $baseline ($baseline*2);
border-left: 4px solid $m-blue-l1;
margin: 0 auto;
color: $gray-d2;
@include font-size(14);
.exam-text {
display: inline-block;
width: calc(100% - 250px);
}
a {
color: rgb(9, 121, 186);
}
span.pull-right {
.pull-right {
color: $gray-d1;
@include line-height(39);
b {
color: $gray-d3;
}
}
.turn_in_exam {
margin-top: -6px;
}
&.low-time {
color: $gray-l3;
background-color: rgb(79, 88, 92);
......@@ -82,11 +86,23 @@
color: $white;
text-decoration: underline;
}
span.pull-right {
.pull-right {
color: $gray-l3;
b {
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 {
......@@ -97,7 +113,20 @@
color: $white;
}
.exam-button-turn-in-exam {
@extend %btn-pl-primary-base;
@extend %t-action3;
@extend %t-weight4;
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
staff_selected = selected(not masquerade or masquerade.role != "student")
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)
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'/>
% for template_name in ["proctored-exam-status"]:
<script type="text/template" id="${template_name}-tpl">
......
......@@ -8,7 +8,7 @@ from django.conf import settings
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()">
<% return _("{course_number} Courseware").format(course_number=course.display_number_with_default) %>
......@@ -38,7 +38,7 @@ ${page_title_breadcrumbs(course_name())}
% endfor
% endif
% if include_proctoring:
% if include_special_exams:
% for template_name in ["proctored-exam-status"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="courseware/${template_name}.underscore" />
......
......@@ -6,11 +6,13 @@
.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')}) %>
<span id="turn_in_exam_id" class="pull-right">
<span id="turn_in_exam_id">
<div class='exam-text'>
<%= 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>
<div id="turn_in_exam_id" class="pull-right turn_in_exam">
<span>
<% 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") %>
</button>
<% } %>
......@@ -20,6 +22,6 @@
<b>
</b>
</span>
</span>
</div>
</div>
......@@ -11,7 +11,7 @@ import pytz
<div class="special-allowance-container" data-course-id="${ section_data['course_id'] }"></div>
</div>
<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>
</div>
......
......@@ -356,6 +356,7 @@ def get_credit_requirement_status(course_key, username, namespace=None, name=Non
"reason": {},
"status": "failed",
"status_date": "2015-06-26 07:49:13",
"order": 0,
},
{
"namespace": "proctored_exam",
......@@ -365,6 +366,7 @@ def get_credit_requirement_status(course_key, username, namespace=None, name=Non
"reason": {},
"status": "satisfied",
"status_date": "2015-06-26 11:07:42",
"order": 1,
},
{
"namespace": "grade",
......@@ -374,6 +376,7 @@ def get_credit_requirement_status(course_key, username, namespace=None, name=Non
"reason": {"final_grade": 0.95},
"status": "satisfied",
"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
"reason": requirement_status.reason if requirement_status else None,
"status": requirement_status.status if requirement_status else None,
"status_date": requirement_status.modified if requirement_status else None,
"order": requirement.order,
})
return statuses
......
......@@ -304,16 +304,27 @@ class CreditRequirementApiTests(CreditApiTestBase):
# Initially, the status should be None
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]["order"], 0)
# Set the requirement to "satisfied" and check that it's actually set
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")
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
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")
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
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
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
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
-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