diff --git a/cms/djangoapps/contentstore/proctoring.py b/cms/djangoapps/contentstore/proctoring.py index 644da6b177..f456ceb0ee 100644 --- a/cms/djangoapps/contentstore/proctoring.py +++ b/cms/djangoapps/contentstore/proctoring.py @@ -15,9 +15,13 @@ from edx_proctoring.api import ( update_exam, create_exam, get_all_exams_for_course, + update_review_policy, + create_exam_review_policy, + remove_review_policy, ) from edx_proctoring.exceptions import ( - ProctoredExamNotFoundException + ProctoredExamNotFoundException, + ProctoredExamReviewPolicyNotFoundException ) log = logging.getLogger(__name__) @@ -72,7 +76,7 @@ def register_special_exams(course_key): try: exam = get_exam_by_content_id(unicode(course_key), unicode(timed_exam.location)) # update case, make sure everything is synced - update_exam( + exam_id = update_exam( exam_id=exam['id'], exam_name=timed_exam.display_name, time_limit_mins=timed_exam.default_time_limit_minutes, @@ -83,6 +87,7 @@ def register_special_exams(course_key): ) msg = 'Updated timed exam {exam_id}'.format(exam_id=exam['id']) log.info(msg) + except ProctoredExamNotFoundException: exam_id = create_exam( course_id=unicode(course_key), @@ -97,6 +102,30 @@ def register_special_exams(course_key): msg = 'Created new timed exam {exam_id}'.format(exam_id=exam_id) log.info(msg) + # only create/update exam policy for the proctored exams + if timed_exam.is_proctored_exam and not timed_exam.is_practice_exam: + try: + update_review_policy( + exam_id=exam_id, + set_by_user_id=timed_exam.edited_by, + review_policy=timed_exam.exam_review_rules + ) + except ProctoredExamReviewPolicyNotFoundException: + if timed_exam.exam_review_rules: # won't save an empty rule. + create_exam_review_policy( + exam_id=exam_id, + set_by_user_id=timed_exam.edited_by, + review_policy=timed_exam.exam_review_rules + ) + msg = 'Created new exam review policy with exam_id {exam_id}'.format(exam_id=exam_id) + log.info(msg) + else: + try: + # remove any associated review policy + remove_review_policy(exam_id=exam_id) + except ProctoredExamReviewPolicyNotFoundException: + pass + # then see which exams we have in edx-proctoring that are not in # our current list. That means the the user has disabled it exams = get_all_exams_for_course(course_key) diff --git a/cms/djangoapps/contentstore/tests/test_proctoring.py b/cms/djangoapps/contentstore/tests/test_proctoring.py index 535245fc81..d7e08f85b2 100644 --- a/cms/djangoapps/contentstore/tests/test_proctoring.py +++ b/cms/djangoapps/contentstore/tests/test_proctoring.py @@ -11,7 +11,10 @@ from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from contentstore.signals import listen_for_course_publish -from edx_proctoring.api import get_all_exams_for_course +from edx_proctoring.api import ( + get_all_exams_for_course, + get_review_policy_by_exam_id +) @ddt.ddt @@ -44,21 +47,28 @@ class TestProctoredExams(ModuleStoreTestCase): self.assertEqual(len(exams), 1) exam = exams[0] + + if exam['is_proctored'] and not exam['is_practice_exam']: + # get the review policy object + exam_review_policy = get_review_policy_by_exam_id(exam['id']) + self.assertEqual(exam_review_policy['review_policy'], sequence.exam_review_rules) + self.assertEqual(exam['course_id'], unicode(self.course.id)) self.assertEqual(exam['content_id'], unicode(sequence.location)) self.assertEqual(exam['exam_name'], sequence.display_name) self.assertEqual(exam['time_limit_mins'], sequence.default_time_limit_minutes) self.assertEqual(exam['is_proctored'], sequence.is_proctored_exam) + self.assertEqual(exam['is_practice_exam'], sequence.is_practice_exam) self.assertEqual(exam['is_active'], expected_active) @ddt.data( - (True, 10, True, True, False), - (True, 10, False, True, False), - (True, 10, True, True, True), + (True, 10, True, False, True, False), + (True, 10, False, False, True, False), + (True, 10, True, True, True, True), ) @ddt.unpack def test_publishing_exam(self, is_time_limited, default_time_limit_minutes, - is_proctored_exam, expected_active, republish): + is_proctored_exam, is_practice_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 @@ -73,7 +83,9 @@ class TestProctoredExams(ModuleStoreTestCase): is_time_limited=is_time_limited, default_time_limit_minutes=default_time_limit_minutes, is_proctored_exam=is_proctored_exam, - due=datetime.now(UTC) + timedelta(minutes=default_time_limit_minutes + 1) + is_practice_exam=is_practice_exam, + due=datetime.now(UTC) + timedelta(minutes=default_time_limit_minutes + 1), + exam_review_rules="allow_use_of_paper" ) listen_for_course_publish(self, self.course.id) @@ -205,7 +217,8 @@ class TestProctoredExams(ModuleStoreTestCase): graded=True, is_time_limited=True, default_time_limit_minutes=10, - is_proctored_exam=True + is_proctored_exam=True, + exam_review_rules="allow_use_of_paper" ) listen_for_course_publish(self, self.course.id) diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 40f910cb0a..320c001894 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -869,6 +869,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F "is_proctored_exam": xblock.is_proctored_exam, "is_practice_exam": xblock.is_practice_exam, "is_time_limited": xblock.is_time_limited, + "exam_review_rules": xblock.exam_review_rules, "default_time_limit_minutes": xblock.default_time_limit_minutes }) diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index e92dfcf756..d0af45138f 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -49,6 +49,7 @@ class CourseMetadata(object): 'is_proctored_enabled', 'is_time_limited', 'is_practice_exam', + 'exam_review_rules', 'self_paced' ] diff --git a/cms/static/js/spec/views/pages/course_outline_spec.js b/cms/static/js/spec/views/pages/course_outline_spec.js index 56ee53b2a5..dfde0ff04d 100644 --- a/cms/static/js/spec/views/pages/course_outline_spec.js +++ b/cms/static/js/spec/views/pages/course_outline_spec.js @@ -216,7 +216,7 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "common/js/components/u 'course-outline', 'xblock-string-field-editor', 'modal-button', 'basic-modal', 'course-outline-modal', 'release-date-editor', 'due-date-editor', 'grading-editor', 'publish-editor', - 'staff-lock-editor', 'timed-examination-preference-editor' + 'staff-lock-editor', 'settings-tab-section', 'timed-examination-preference-editor' ]); appendSetFixtures(mockOutlinePage); mockCourseJSON = createMockCourseJSON({}, [ @@ -580,7 +580,8 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "common/js/components/u describe("Subsection", function() { var getDisplayNameWrapper, setEditModalValues, mockServerValuesJson, - selectDisableSpecialExams, selectTimedExam, selectProctoredExam, selectPracticeExam; + selectDisableSpecialExams, selectGeneralSettings, selectAdvancedSettings, + selectTimedExam, selectProctoredExam, selectPracticeExam; getDisplayNameWrapper = function() { return getItemHeaders('subsection').find('.wrapper-xblock-field'); @@ -597,6 +598,14 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "common/js/components/u this.$("#id_not_timed").prop('checked', true).trigger('change'); }; + selectGeneralSettings = function() { + this.$(".modal-section .general-settings-button").click(); + }; + + selectAdvancedSettings = function() { + this.$(".modal-section .advanced-settings-button").click(); + }; + selectTimedExam = function(time_limit) { this.$("#id_timed_exam").prop('checked', true).trigger('change'); this.$("#id_time_limit").val(time_limit); @@ -701,6 +710,22 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "common/js/components/u collapseItemsAndVerifyState('subsection'); expandItemsAndVerifyState('subsection'); }); + + it('can show general settings', function() { + createCourseOutlinePage(this, mockCourseJSON, false); + outlinePage.$('.outline-subsection .configure-button').click(); + selectGeneralSettings(); + expect($('.modal-section .general-settings-button')).toHaveClass('active'); + expect($('.modal-section .advanced-settings-button')).not.toHaveClass('active'); + }); + + it('can show advanced settings', function() { + createCourseOutlinePage(this, mockCourseJSON, false); + outlinePage.$('.outline-subsection .configure-button').click(); + selectAdvancedSettings(); + expect($('.modal-section .general-settings-button')).not.toHaveClass('active'); + expect($('.modal-section .advanced-settings-button')).toHaveClass('active'); + }); it('can be edited', function() { createCourseOutlinePage(this, mockCourseJSON, false); @@ -715,6 +740,7 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "common/js/components/u "visible_to_staff_only": true, "start":"2014-07-09T00:00:00.000Z", "due":"2014-07-10T00:00:00.000Z", + "exam_review_rules": "", "is_time_limited": true, "is_practice_exam": false, "is_proctored_enabled": true, diff --git a/cms/static/js/views/modals/course_outline_modals.js b/cms/static/js/views/modals/course_outline_modals.js index db3286ec82..155255e066 100644 --- a/cms/static/js/views/modals/course_outline_modals.js +++ b/cms/static/js/views/modals/course_outline_modals.js @@ -84,7 +84,9 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', getContext: function () { return $.extend({ xblockInfo: this.model, - introductionMessage: this.getIntroductionMessage() + introductionMessage: this.getIntroductionMessage(), + enable_proctored_exams: this.options.enable_proctored_exams, + enable_timed_exams: this.options.enable_timed_exams }); }, @@ -114,6 +116,78 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', gettext('Change the settings for %(display_name)s'), { display_name: this.model.get('display_name') }, true ); + }, + + initializeEditors: function () { + var special_exams_editors = this.options.special_exam_editors; + if (typeof special_exams_editors !== 'undefined' && special_exams_editors.length > 0) { + var tabs_html = this.loadTemplate('settings-tab-section'); + this.$('.modal-section').html(tabs_html); + this.options.editors = _.map(this.options.editors, function (Editor) { + return new Editor({ + parentElement: this.$('.modal-section .general-settings'), + model: this.model, + xblockType: this.options.xblockType, + enable_proctored_exams: this.options.enable_proctored_exams, + enable_timed_exams: this.options.enable_timed_exams + }); + }, this); + + this.options.special_exam_editors = _.map(special_exams_editors, function (Editor) { + return new Editor({ + parentElement: this.$('.modal-section .advanced-settings'), + model: this.model, + xblockType: this.options.xblockType, + enable_proctored_exams: this.options.enable_proctored_exams, + enable_timed_exams: this.options.enable_timed_exams + }); + }, this); + this.hideAdvancedSettings(); + } else { + CourseOutlineXBlockModal.prototype.initializeEditors.call(this); + } + }, + + events: { + 'click .action-save': 'save', + 'click .general-settings-button': 'showGeneralSettings', + 'click .advanced-settings-button': 'showAdvancedSettings' + }, + + /** + * Return request data. + * @return {Object} + */ + getRequestData: function () { + var combined_editors = this.options.editors.concat(this.options.special_exam_editors); + var requestData = _.map(combined_editors, function (editor) { + return editor.getRequestData(); + }); + return $.extend.apply(this, [true, {}].concat(requestData)); + }, + + hideAdvancedSettings: function() { + this.$('.modal-section .general-settings-button').addClass('active'); + this.$('.modal-section .advanced-settings-button').removeClass('active'); + this.$('.modal-section .general-settings').show(); + this.$('.modal-section .advanced-settings').hide(); + + }, + + hideGeneralSettings: function() { + this.$('.modal-section .general-settings-button').removeClass('active'); + this.$('.modal-section .advanced-settings-button').addClass('active'); + this.$('.modal-section .general-settings').hide(); + this.$('.modal-section .advanced-settings').show(); + }, + showGeneralSettings: function (event) { + event.preventDefault(); + this.hideAdvancedSettings(); + }, + + showAdvancedSettings: function (event) { + event.preventDefault(); + this.hideGeneralSettings(); } }); @@ -267,20 +341,40 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', className: 'edit-settings-timed-examination', events : { 'change #id_not_timed': 'notTimedExam', - 'change #id_timed_exam': 'showTimeLimit', - 'change #id_practice_exam': 'showTimeLimit', - 'change #id_proctored_exam': 'showTimeLimit', + 'change #id_timed_exam': 'setTimedExam', + 'change #id_practice_exam': 'setPracticeExam', + 'change #id_proctored_exam': 'setProctoredExam', 'focusout #id_time_limit': 'timeLimitFocusout' }, notTimedExam: function (event) { event.preventDefault(); this.$('#id_time_limit_div').hide(); + this.$('.exam-review-rules-list-fields').hide(); this.$('#id_time_limit').val('00:00'); }, - showTimeLimit: function (event) { - event.preventDefault(); + selectSpecialExam: function (showRulesField) { this.$('#id_time_limit_div').show(); - this.$('#id_time_limit').val("00:30"); + if (!this.isValidTimeLimit(this.$('#id_time_limit').val())) { + this.$('#id_time_limit').val('00:30'); + } + if (showRulesField) { + this.$('.exam-review-rules-list-fields').show(); + } + else { + this.$('.exam-review-rules-list-fields').hide(); + } + }, + setTimedExam: function (event) { + event.preventDefault(); + this.selectSpecialExam(false); + }, + setPracticeExam: function (event) { + event.preventDefault(); + this.selectSpecialExam(false); + }, + setProctoredExam: function (event) { + event.preventDefault(); + this.selectSpecialExam(true); }, timeLimitFocusout: function(event) { event.preventDefault(); @@ -301,6 +395,8 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', 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.setReviewRules(this.model.get('exam_review_rules')); }, setExamType: function(is_time_limited, is_proctored_exam, is_practice_exam) { if (!is_time_limited) { @@ -309,12 +405,14 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', } this.$('#id_time_limit_div').show(); + this.$('.exam-review-rules-list-fields').hide(); 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); + this.$('.exam-review-rules-list-fields').show(); } } else { // Since we have an early exit at the top of the method @@ -327,6 +425,9 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', var time = this.convertTimeLimitMinutesToString(value); this.$('#id_time_limit').val(time); }, + setReviewRules: function (value) { + this.$('#id_exam_review_rules').val(value); + }, 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"; @@ -351,6 +452,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', var is_practice_exam; var is_proctored_exam; var time_limit = this.getExamTimeLimit(); + var exam_review_rules = this.$('#id_exam_review_rules').val(); if (this.$("#id_not_timed").is(':checked')){ is_time_limited = false; @@ -374,6 +476,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', metadata: { 'is_practice_exam': is_practice_exam, 'is_time_limited': is_time_limited, + 'exam_review_rules': exam_review_rules, // We have to use the legacy field name // as the Ajax handler directly populates // the xBlocks fields. We will have to @@ -584,6 +687,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', getEditModal: function (xblockInfo, options) { var editors = []; + var special_exam_editors = []; if (xblockInfo.isChapter()) { editors = [ReleaseDateEditor, StaffLockEditor]; @@ -592,7 +696,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', var enable_special_exams = (options.enable_proctored_exams || options.enable_timed_exams); if (enable_special_exams) { - editors.push(TimedExaminationPreferenceEditor); + special_exam_editors.push(TimedExaminationPreferenceEditor); } editors.push(StaffLockEditor); @@ -610,6 +714,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', } return new SettingsXBlockModal($.extend({ editors: editors, + special_exam_editors: special_exam_editors, model: xblockInfo }, options)); }, diff --git a/cms/static/sass/elements/_modal-window.scss b/cms/static/sass/elements/_modal-window.scss index f57b2798c5..958080e4f9 100644 --- a/cms/static/sass/elements/_modal-window.scss +++ b/cms/static/sass/elements/_modal-window.scss @@ -99,6 +99,37 @@ &:last-child { margin-bottom: 0; } + .settings-tab { + margin-bottom: $baseline; + border-bottom: 1px solid $gray-l3; + + li.settings-section { + display: inline-block; + margin-right: $baseline; + + .general-settings-button, + .advanced-settings-button { + @extend %t-copy-sub1; + @extend %t-regular; + background-image: none; + background-color: $white; + color: $mediumGrey; + border-radius: 0; + box-shadow: none; + border: 0; + padding: ($baseline/4) ($baseline/2); + text-transform: uppercase; + &:hover { + background-color: $white; + color: $blue; + } + &.active { + border-bottom: 4px solid $blue-d2; + color: $offBlack; + } + } + } + } } .modal-section-title { @@ -528,7 +559,8 @@ .wrapper-modal-window-bulkpublish-subsection, .wrapper-modal-window-bulkpublish-unit, .course-outline-modal { - .exam-time-list-fields { + .exam-time-list-fields, + .exam-review-rules-list-fields { margin: 0 0 ($baseline/2) ($baseline/2); } .list-fields { diff --git a/cms/templates/course_outline.html b/cms/templates/course_outline.html index 083950b824..bf360056c5 100644 --- a/cms/templates/course_outline.html +++ b/cms/templates/course_outline.html @@ -22,7 +22,7 @@ from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration <%block name="header_extras"> -% for template_name in ['course-outline', 'xblock-string-field-editor', 'basic-modal', 'modal-button', 'course-outline-modal', 'due-date-editor', 'release-date-editor', 'grading-editor', 'publish-editor', 'staff-lock-editor', 'verification-access-editor', 'timed-examination-preference-editor']: +% for template_name in ['course-outline', 'xblock-string-field-editor', 'basic-modal', 'modal-button', 'course-outline-modal', 'due-date-editor', 'release-date-editor', 'grading-editor', 'publish-editor', 'staff-lock-editor', 'verification-access-editor', 'timed-examination-preference-editor', 'settings-tab-section']: diff --git a/cms/templates/js/course-outline-modal.underscore b/cms/templates/js/course-outline-modal.underscore index 51a2d79f13..cda1793672 100644 --- a/cms/templates/js/course-outline-modal.underscore +++ b/cms/templates/js/course-outline-modal.underscore @@ -1,7 +1,14 @@ +<% +var enable_proctored_exams = enable_proctored_exams; +var enable_timed_exams = enable_timed_exams; +%> +